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

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); 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 // Sorting Tests
// ============================================= // =============================================

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(); 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 // Invitations Tab Tests
// ============================================= // =============================================

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); 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 // Employee Permission Tests
// ============================================= // =============================================

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); 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 // Sorting Tests
// ============================================= // =============================================

View File

@@ -2,11 +2,24 @@
import type { Client } from '@/packages/api/src'; import type { Client } from '@/packages/api/src';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline'; 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 { useClientsStore } from '@/utils/useClients';
import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue'; import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import ClientEditModal from '@/Components/Common/Client/ClientEditModal.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(); const { projects } = useProjectsQuery();
@@ -33,38 +46,63 @@ const showEditModal = ref(false);
</script> </script>
<template> <template>
<TableRow> <ContextMenu>
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal> <ContextMenuTrigger as-child>
<div <TableRow>
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"> <ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
<span> <div
{{ client.name }} 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> <span>
</div> {{ client.name }}
<div </span>
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary"> </div>
<span class="text-text-secondary"> {{ projectCount }} Projects </span> <div
</div> class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<div <span class="text-text-secondary"> {{ projectCount }} Projects </span>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium"> </div>
<template v-if="client.is_archived"> <div
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<span>Archived</span> <template v-if="client.is_archived">
</template> <ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<template v-else> <span>Archived</span>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon> </template>
<span>Active</span> <template v-else>
</template> <CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
</div> <span>Active</span>
<div </template>
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"> </div>
<ClientMoreOptionsDropdown <div
:client="client" 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">
@edit="showEditModal = true" <ClientMoreOptionsDropdown
@archive="archiveClient" :client="client"
@delete="deleteClient"></ClientMoreOptionsDropdown> @edit="showEditModal = true"
</div> @archive="archiveClient"
</TableRow> @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> </template>
<style scoped></style> <style scoped></style>

View File

@@ -2,12 +2,24 @@
import type { Member, Organization } from '@/packages/api/src'; import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline'; 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 MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification'; 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 { computed, type ComputedRef, inject, ref } from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue'; import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.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 MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import { capitalizeFirstLetter } from '../../../utils/format'; import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money'; import { formatCents } from '../../../packages/ui/src/utils/money';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/packages/ui/src';
const props = defineProps<{ const props = defineProps<{
member: Member; member: Member;
@@ -55,73 +74,112 @@ const userHasValidMailAddress = computed(() => {
</script> </script>
<template> <template>
<TableRow> <ContextMenu>
<div <ContextMenuTrigger as-child>
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"> <TableRow>
<span> <div
{{ member.name }} 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> <span>
</div> {{ member.name }}
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> </span>
{{ member.email }} </div>
</div> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> {{ member.email }}
{{ capitalizeFirstLetter(member.role) }} </div>
</div> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> {{ capitalizeFirstLetter(member.role) }}
{{ </div>
member.billable_rate <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
? formatCents( {{
member.billable_rate, member.billable_rate
organization?.currency, ? formatCents(
organization?.currency_format, member.billable_rate,
organization?.currency_symbol, organization?.currency,
organization?.number_format 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"> </div>
<template v-if="member.is_placeholder === false"> <div
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<span>Active</span> <template v-if="member.is_placeholder === false">
</template> <CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<template v-else> <span>Active</span>
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon> </template>
<span>Inactive</span> <template v-else>
</template> <UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
</div> <span>Inactive</span>
<div </template>
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"> </div>
<SecondaryButton <div
v-if=" 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">
member.is_placeholder === true && <SecondaryButton
canInvitePlaceholderMembers() && v-if="
userHasValidMailAddress member.is_placeholder === true &&
" canInvitePlaceholderMembers() &&
size="small" userHasValidMailAddress
@click="invitePlaceholder(member.id)" "
>Invite size="small"
</SecondaryButton> @click="invitePlaceholder(member.id)"
<MemberMoreOptionsDropdown >Invite
:member="member" </SecondaryButton>
@edit="showEditMemberModal = true" <MemberMoreOptionsDropdown
@delete="removeMember" :member="member"
@merge="showMergeMemberModal = true" @edit="showEditMemberModal = true"
@make-placeholder=" @delete="removeMember"
showMakeMemberPlaceholderModal = true @merge="showMergeMemberModal = true"
"></MemberMoreOptionsDropdown> @make-placeholder="
</div> showMakeMemberPlaceholderModal = true
<MemberEditModal v-model:show="showEditMemberModal" :member="member"></MemberEditModal> "></MemberMoreOptionsDropdown>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal> </div>
<MemberMakePlaceholderModal <MemberEditModal
v-model:show="showMakeMemberPlaceholderModal" v-model:show="showEditMemberModal"
:member="member"></MemberMakePlaceholderModal> :member="member"></MemberEditModal>
<MemberDeleteModal <MemberMergeModal
v-model:show="showDeleteMemberModal" v-model:show="showMergeMemberModal"
:member="member"></MemberDeleteModal> :member="member"></MemberMergeModal>
</TableRow> <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> </template>
<style scoped></style> <style scoped></style>

View File

@@ -3,6 +3,11 @@ import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreO
import type { Project } from '@/packages/api/src'; import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline'; 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 { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery'; import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects'; 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 UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/packages/ui/src';
const { clients } = useClientsQuery(); const { clients } = useClientsQuery();
const { tasks } = useTasksQuery(); const { tasks } = useTasksQuery();
@@ -69,71 +82,99 @@ const showEditProjectModal = ref(false);
<ProjectEditModal <ProjectEditModal
v-model:show="showEditProjectModal" v-model:show="showEditProjectModal"
:original-project="project"></ProjectEditModal> :original-project="project"></ProjectEditModal>
<TableRow :href="route('projects.show', { project: project.id })"> <ContextMenu>
<div <ContextMenuTrigger as-child>
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"> <TableRow :href="route('projects.show', { project: project.id })">
<div <div
:style="{ 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">
backgroundColor: project.color, <div
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`, :style="{
}" backgroundColor: project.color,
class="w-3 h-3 rounded-full"></div> boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
<span class="overflow-ellipsis overflow-hidden"> }"
{{ project.name }} class="w-3 h-3 rounded-full"></div>
</span> <span class="overflow-ellipsis overflow-hidden">
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span> {{ project.name }}
</div> </span>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary"> <span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden"> </div>
{{ client?.name }} <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
</div> <div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
<div v-else>No client</div> {{ client?.name }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div v-else>No client</div>
<div v-if="project.spent_time"> </div>
{{ <div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
formatHumanReadableDuration( <div v-if="project.spent_time">
project.spent_time, {{
organization?.interval_format, formatHumanReadableDuration(
organization?.number_format project.spent_time,
) organization?.interval_format,
}} organization?.number_format
</div> )
<div v-else>--</div> }}
</div> </div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary"> <div v-else>--</div>
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge> </div>
<EstimatedTimeProgress <div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
v-else-if="project.estimated_time" <UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
:estimated="project.estimated_time" <EstimatedTimeProgress
:current="project.spent_time"></EstimatedTimeProgress> v-else-if="project.estimated_time"
<span v-else> -- </span> :estimated="project.estimated_time"
</div> :current="project.spent_time"></EstimatedTimeProgress>
<div <span v-else> -- </span>
v-if="showBillableRate" </div>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div
{{ billableRateInfo }} v-if="showBillableRate"
</div> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div {{ billableRateInfo }}
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium"> </div>
<template v-if="project.is_archived"> <div
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon> class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<span>Archived</span> <template v-if="project.is_archived">
</template> <ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<template v-else> <span>Archived</span>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon> </template>
<span>Active</span> <template v-else>
</template> <CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
</div> <span>Active</span>
<div </template>
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"> </div>
<ProjectMoreOptionsDropdown <div
:project="project" 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">
@edit="showEditProjectModal = true" <ProjectMoreOptionsDropdown
@archive="archiveProject" :project="project"
@delete="deleteProject"></ProjectMoreOptionsDropdown> @edit="showEditProjectModal = true"
</div> @archive="archiveProject"
</TableRow> @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> </template>
<style scoped></style> <style scoped></style>

View File

@@ -6,6 +6,14 @@ import TagEditModal from '@/Components/Common/Tag/TagEditModal.vue';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import { canDeleteTags, canUpdateTags } from '@/utils/permissions'; import { canDeleteTags, canUpdateTags } from '@/utils/permissions';
import { ref } from 'vue'; 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<{ const props = defineProps<{
tag: Tag; tag: Tag;
@@ -19,23 +27,44 @@ function deleteTag() {
</script> </script>
<template> <template>
<TableRow> <ContextMenu>
<div <ContextMenuTrigger as-child>
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"> <TableRow>
<span> <div
{{ tag.name }} 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> <span>
</div> {{ tag.name }}
<div </span>
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"> </div>
<TagMoreOptionsDropdown <div
v-if="canDeleteTags() || canUpdateTags()" 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">
:tag="tag" <TagMoreOptionsDropdown
@edit="showTagEditModal = true" v-if="canDeleteTags() || canUpdateTags()"
@delete="deleteTag"></TagMoreOptionsDropdown> :tag="tag"
</div> @edit="showTagEditModal = true"
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal> @delete="deleteTag"></TagMoreOptionsDropdown>
</TableRow> </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> </template>
<style scoped></style> <style scoped></style>