diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 42d99da8..09891f22 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -6,6 +6,7 @@ import { formatCentsWithOrganizationDefaults } from './utils/money'; import { createProjectViaApi, createPublicProjectViaApi, + createProjectMemberViaApi, createTaskViaApi, createClientViaApi, createTimeEntryViaApi, @@ -217,6 +218,59 @@ test('test that creating a non-billable project works', async ({ page }) => { await expect(page.getByTestId('project_table')).toContainText(newProjectName); }); +test('test that creating a public project via the modal works', async ({ page }) => { + const newProjectName = 'Public Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + // Visibility defaults to Private — switch it to Public + await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private'); + await page.getByRole('dialog').locator('#visibility').click(); + await page.getByRole('option', { name: 'Public' }).click(); + + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.is_public === true + ), + ]); + + await expect(page.getByTestId('project_table')).toContainText(newProjectName); +}); + +test('test that changing a project to public via the edit modal works', async ({ page, ctx }) => { + const newProjectName = 'Edit Visibility Project ' + Math.floor(1 + Math.random() * 10000); + await createProjectViaApi(ctx, { name: newProjectName }); + + await goToProjectsOverview(page); + await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); + + const projectRow = page.getByRole('row').filter({ hasText: newProjectName }).first(); + await projectRow.getByRole('button').click(); + await page.locator(`[aria-label='Edit Project ${newProjectName}']`).click(); + + // Loaded as Private — switch it to Public + await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private'); + await page.getByRole('dialog').locator('#visibility').click(); + await page.getByRole('option', { name: 'Public' }).click(); + + await Promise.all([ + page.getByRole('button', { name: 'Update Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.is_public === true + ), + ]); +}); + test('test that switching from custom rate to default rate clears billable rate', async ({ page, ctx, @@ -925,6 +979,39 @@ test.describe('Employee Projects Restrictions', () => { employee.page.locator(`[aria-label='Delete Project ${projectName}']`) ).not.toBeVisible(); }); + + test('employee does not see private projects they are not a member of', async ({ + ctx, + employee, + }) => { + const publicName = 'EmpPublicVisible ' + Math.floor(Math.random() * 10000); + const privateName = 'EmpPrivateHidden ' + Math.floor(Math.random() * 10000); + await createPublicProjectViaApi(ctx, { name: publicName }); + // createProjectViaApi defaults to is_public: false (private); the employee is not a member + await createProjectViaApi(ctx, { name: privateName }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 }); + + // The public project is visible — confirms the list has loaded + await expect(employee.page.getByText(publicName)).toBeVisible({ timeout: 10000 }); + + // The private project the employee is not a member of must not appear + await expect(employee.page.getByText(privateName)).not.toBeVisible(); + }); + + test('employee can see a private project they are a member of', async ({ ctx, employee }) => { + const projectName = 'EmpPrivateMember ' + Math.floor(Math.random() * 10000); + const project = await createProjectViaApi(ctx, { name: projectName }); + // Add the employee as a project member so the private project becomes visible to them + await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId }); + + await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 }); + + // The private project is visible because the employee is a member + await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + }); }); test.describe('Employee Billable Rate Visibility', () => { diff --git a/resources/js/Components/Common/Project/ProjectEditModal.vue b/resources/js/Components/Common/Project/ProjectEditModal.vue index d9b53e28..d788c4ef 100644 --- a/resources/js/Components/Common/Project/ProjectEditModal.vue +++ b/resources/js/Components/Common/Project/ProjectEditModal.vue @@ -19,6 +19,7 @@ import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field'; import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue'; import { getOrganizationCurrencyString } from '@/utils/money'; import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue'; +import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue'; import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { getCurrentOrganizationId } from '@/utils/useUser'; @@ -44,6 +45,7 @@ const project = ref({ billable_rate: props.originalProject.billable_rate, is_billable: props.originalProject.is_billable, estimated_time: props.originalProject.estimated_time, + is_public: props.originalProject.is_public, }); async function submit() { @@ -126,6 +128,7 @@ async function submitBillableRate() { v-if="isAllowedToPerformPremiumAction()" v-model="project.estimated_time" @submit="submit()"> + +
+ + +
{
-
+
{{ billableRateFormatted }} / h @@ -118,6 +118,7 @@ const shownTasks = computed(() => { Default Rate Non-Billable + {{ project?.is_public ? 'Public' : 'Private' }}
diff --git a/resources/js/packages/ui/src/Project/ProjectCreateModal.vue b/resources/js/packages/ui/src/Project/ProjectCreateModal.vue index 618e86d2..3623bc32 100644 --- a/resources/js/packages/ui/src/Project/ProjectCreateModal.vue +++ b/resources/js/packages/ui/src/Project/ProjectCreateModal.vue @@ -15,6 +15,7 @@ import { UserCircleIcon } from '@heroicons/vue/20/solid'; import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue'; import { Field, FieldGroup, FieldLabel } from '../field'; import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue'; +import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue'; import type { Client } from '@/packages/api/src'; const show = defineModel('show', { default: false }); @@ -41,6 +42,7 @@ const project = ref({ billable_rate: null, is_billable: false, estimated_time: null, + is_public: false, }); async function submit() { @@ -53,6 +55,7 @@ async function submit() { billable_rate: null, is_billable: false, estimated_time: null, + is_public: false, }; } @@ -123,6 +126,7 @@ const currentClientName = computed(() => { v-if="enableEstimatedTime" v-model="project.estimated_time" @submit="submit()"> +