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()"> + +
+ + +
+import { computed } from 'vue'; +import { GlobeAltIcon } from '@heroicons/vue/16/solid'; +import { DropdownMenuItem } from '@/packages/ui/src'; +import BaseFilterBadge from './BaseFilterBadge.vue'; + +type VisibilityValue = 'public' | 'private' | 'all'; + +const props = defineProps<{ + value: VisibilityValue; +}>(); + +const emit = defineEmits<{ + remove: []; + 'update:value': [value: VisibilityValue]; +}>(); + +const visibilityOptions = [ + { id: 'public' as const, name: 'Public' }, + { id: 'private' as const, name: 'Private' }, +]; + +const label = computed(() => { + return visibilityOptions.find((opt) => opt.id === props.value)?.name ?? 'Visibility'; +}); + +function updateVisibility(visibility: VisibilityValue) { + emit('update:value', visibility); +} + + + diff --git a/resources/js/Components/Common/Project/ProjectsFilterDropdown.vue b/resources/js/Components/Common/Project/ProjectsFilterDropdown.vue index 18a1ba73..24504678 100644 --- a/resources/js/Components/Common/Project/ProjectsFilterDropdown.vue +++ b/resources/js/Components/Common/Project/ProjectsFilterDropdown.vue @@ -1,6 +1,6 @@ @@ -102,6 +120,25 @@ const hasActiveFilters = computed(() => { + + + + + Visibility + + + + {{ option.name }} + + + + diff --git a/resources/js/Pages/ProjectShow.vue b/resources/js/Pages/ProjectShow.vue index 0675ae94..0f66d32b 100644 --- a/resources/js/Pages/ProjectShow.vue +++ b/resources/js/Pages/ProjectShow.vue @@ -109,7 +109,7 @@ const shownTasks = computed(() => {
-
+
{{ billableRateFormatted }} / h @@ -118,6 +118,7 @@ const shownTasks = computed(() => { Default Rate Non-Billable + {{ project?.is_public ? 'Public' : 'Private' }}
diff --git a/resources/js/Pages/Projects.vue b/resources/js/Pages/Projects.vue index f096032b..113e7f11 100644 --- a/resources/js/Pages/Projects.vue +++ b/resources/js/Pages/Projects.vue @@ -20,6 +20,7 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { useStorage } from '@vueuse/core'; import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue'; import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue'; +import ProjectVisibilityFilterBadge from '@/Components/Common/Project/ProjectVisibilityFilterBadge.vue'; import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue'; import { NO_CLIENT_ID } from '@/Components/Common/Project/constants'; import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue'; @@ -36,6 +37,7 @@ interface ProjectTableState { filters: { clientIds: string[]; status: 'active' | 'archived' | 'all'; + visibility: 'public' | 'private' | 'all'; }; } @@ -47,6 +49,7 @@ const tableState = useStorage( filters: { clientIds: [], status: 'all', + visibility: 'all', }, }, undefined, @@ -69,6 +72,14 @@ const filteredProjects = computed(() => { return false; } + // Visibility filter + if (tableState.value.filters.visibility === 'public' && !project.is_public) { + return false; + } + if (tableState.value.filters.visibility === 'private' && project.is_public) { + return false; + } + // Client filter const hasClientFilter = tableState.value.filters.clientIds.length > 0; if (hasClientFilter) { @@ -91,6 +102,10 @@ function removeStatusFilter() { tableState.value.filters.status = 'all'; } +function removeVisibilityFilter() { + tableState.value.filters.visibility = 'all'; +} + function removeClientFilter() { tableState.value.filters.clientIds = []; } @@ -152,6 +167,15 @@ const showBillableRate = computed(() => { tableState.filters.status = $event as 'active' | 'archived' | 'all' " /> + + ({ 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()"> +