Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
9b4871cdb7 Bump the major-updates group across 1 directory with 14 updates
Bumps the major-updates group with 13 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/vue-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/vue-query-devtools) | `5.91.0` | `6.1.33` |
| [lucide-vue-next](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-vue-next) | `0.487.0` | `1.0.0` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge) | `2.6.1` | `3.6.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.2` | `10.0.1` |
| [@inertiajs/vue3](https://github.com/inertiajs/inertia/tree/HEAD/packages/vue3) | `2.3.13` | `3.2.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.7` | `25.9.1` |
| [laravel-vite-plugin](https://github.com/laravel/vite-plugin) | `2.1.0` | `3.1.0` |
| [postcss-import](https://github.com/postcss/postcss-import) | `15.1.0` | `16.1.1` |
| [postcss-nesting](https://github.com/csstools/postcss-plugins/tree/HEAD/plugins/postcss-nesting) | `12.1.5` | `14.0.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.19` | `4.3.0` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.3` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.1` | `8.0.14` |
| [vite-plugin-dts](https://github.com/qmhc/unplugin-dts/tree/HEAD/packages/vite-plugin-dts) | `4.5.4` | `5.0.1` |



Updates `@tanstack/vue-query-devtools` from 5.91.0 to 6.1.33
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/vue-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/vue-query-devtools@6.1.33/packages/vue-query-devtools)

Updates `lucide-vue-next` from 0.487.0 to 1.0.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.0.0/packages/lucide-vue-next)

Updates `tailwind-merge` from 2.6.1 to 3.6.0
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v2.6.1...v3.6.0)

Updates `@eslint/js` from 9.39.2 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@inertiajs/vue3` from 2.3.13 to 3.2.0
- [Release notes](https://github.com/inertiajs/inertia/releases)
- [Changelog](https://github.com/inertiajs/inertia/blob/3.x/CHANGELOG.md)
- [Commits](https://github.com/inertiajs/inertia/commits/v3.2.0/packages/vue3)

Updates `@types/node` from 22.19.7 to 25.9.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `axios` from 1.13.6 to 1.16.1
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.6...v1.16.1)

Updates `laravel-vite-plugin` from 2.1.0 to 3.1.0
- [Release notes](https://github.com/laravel/vite-plugin/releases)
- [Changelog](https://github.com/laravel/vite-plugin/blob/3.x/CHANGELOG.md)
- [Upgrade guide](https://github.com/laravel/vite-plugin/blob/3.x/UPGRADE.md)
- [Commits](https://github.com/laravel/vite-plugin/compare/v2.1.0...v3.1.0)

Updates `postcss-import` from 15.1.0 to 16.1.1
- [Release notes](https://github.com/postcss/postcss-import/releases)
- [Changelog](https://github.com/postcss/postcss-import/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-import/compare/15.1.0...16.1.1)

Updates `postcss-nesting` from 12.1.5 to 14.0.0
- [Changelog](https://github.com/csstools/postcss-plugins/blob/main/plugins/postcss-nesting/CHANGELOG.md)
- [Commits](https://github.com/csstools/postcss-plugins/commits/HEAD/plugins/postcss-nesting)

Updates `tailwindcss` from 3.4.19 to 4.3.0
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/tailwindcss)

Updates `typescript` from 5.9.3 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

Updates `vite` from 7.3.1 to 8.0.14
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.14/packages/vite)

Updates `vite-plugin-dts` from 4.5.4 to 5.0.1
- [Release notes](https://github.com/qmhc/unplugin-dts/releases)
- [Changelog](https://github.com/qmhc/unplugin-dts/blob/main/packages/vite-plugin-dts/CHANGELOG.md)
- [Commits](https://github.com/qmhc/unplugin-dts/commits/vite-plugin-dts@5.0.1/packages/vite-plugin-dts)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@inertiajs/vue3"
  dependency-version: 3.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@tanstack/vue-query-devtools"
  dependency-version: 6.1.28
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 25.6.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: major-updates
- dependency-name: laravel-vite-plugin
  dependency-version: 3.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: lucide-vue-next
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-import
  dependency-version: 16.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-nesting
  dependency-version: 14.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwind-merge
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite
  dependency-version: 8.0.12
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite-plugin-dts
  dependency-version: 5.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 11:58:52 +00:00
18 changed files with 1084 additions and 2791 deletions

View File

@@ -6,7 +6,6 @@ import { formatCentsWithOrganizationDefaults } from './utils/money';
import { import {
createProjectViaApi, createProjectViaApi,
createPublicProjectViaApi, createPublicProjectViaApi,
createProjectMemberViaApi,
createTaskViaApi, createTaskViaApi,
createClientViaApi, createClientViaApi,
createTimeEntryViaApi, createTimeEntryViaApi,
@@ -218,59 +217,6 @@ test('test that creating a non-billable project works', async ({ page }) => {
await expect(page.getByTestId('project_table')).toContainText(newProjectName); 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 ({ test('test that switching from custom rate to default rate clears billable rate', async ({
page, page,
ctx, ctx,
@@ -979,39 +925,6 @@ test.describe('Employee Projects Restrictions', () => {
employee.page.locator(`[aria-label='Delete Project ${projectName}']`) employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
).not.toBeVisible(); ).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', () => { test.describe('Employee Billable Rate Visibility', () => {

3244
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,26 +19,26 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^10.0.1",
"@inertiajs/vue3": "^2.0.0", "@inertiajs/vue3": "^3.2.0",
"@playwright/test": "^1.41.1", "@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/chroma-js": "^3.1.0", "@types/chroma-js": "^3.1.0",
"@types/node": "^22.10.10", "@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/tsconfig": "^0.8.0", "@vue/tsconfig": "^0.8.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.6.4", "axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"laravel-vite-plugin": "^2.1.0", "laravel-vite-plugin": "^3.1.0",
"openapi-zod-client": "^1.16.2", "openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"postcss-import": "^15.1.0", "postcss-import": "^16.1.1",
"postcss-nesting": "^12.1.5", "postcss-nesting": "^14.0.0",
"tailwindcss": "^3.4.13", "tailwindcss": "^4.3.0",
"typescript": "^5.7.3", "typescript": "^6.0.3",
"vite": "^7.0.0", "vite": "^8.0.14",
"vite-plugin-checker": "^0.12.0", "vite-plugin-checker": "^0.12.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-tsc": "^3.0.0" "vue-tsc": "^3.0.0"
@@ -51,7 +51,7 @@
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.3.1", "@tanstack/vue-form": "^1.3.1",
"@tanstack/vue-query": "^5.56.2", "@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0", "@tanstack/vue-query-devtools": "^6.1.33",
"@tanstack/vue-table": "^8.21.2", "@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^14.3.0",
@@ -64,12 +64,12 @@
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"focus-trap": "^8.0.0", "focus-trap": "^8.0.0",
"lucide-vue-next": "^0.487.0", "lucide-vue-next": "^1.0.0",
"parse-duration": "^2.0.1", "parse-duration": "^2.0.1",
"pinia": "^3.0.0", "pinia": "^3.0.0",
"radix-vue": "^1.9.6", "radix-vue": "^1.9.6",
"reka-ui": "^2.8.2", "reka-ui": "^2.8.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0", "vue-echarts": "^8.0.0",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@@ -19,7 +19,6 @@ import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';
import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue'; import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue'; import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -45,7 +44,6 @@ const project = ref<CreateProjectBody>({
billable_rate: props.originalProject.billable_rate, billable_rate: props.originalProject.billable_rate,
is_billable: props.originalProject.is_billable, is_billable: props.originalProject.is_billable,
estimated_time: props.originalProject.estimated_time, estimated_time: props.originalProject.estimated_time,
is_public: props.originalProject.is_public,
}); });
async function submit() { async function submit() {
@@ -128,7 +126,6 @@ async function submitBillableRate() {
v-if="isAllowedToPerformPremiumAction()" v-if="isAllowedToPerformPremiumAction()"
v-model="project.estimated_time" v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection> @submit="submit()"></EstimatedTimeSection>
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
</FieldGroup> </FieldGroup>
</template> </template>
<template #footer> <template #footer>

View File

@@ -13,8 +13,7 @@ export type SortColumn =
| 'spent_time' | 'spent_time'
| 'progress' | 'progress'
| 'billable_rate' | 'billable_rate'
| 'status' | 'status';
| 'visibility';
export type SortDirection = 'asc' | 'desc'; export type SortDirection = 'asc' | 'desc';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src'; import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
@@ -103,10 +102,6 @@ const columns = computed(() => [
id: 'status', id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0), accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
}, },
{
id: 'visibility',
accessorFn: (row: Project) => (row.is_public ? 1 : 0),
},
]); ]);
// Columns with sortDescFirst get desc as default direction on first click. // Columns with sortDescFirst get desc as default direction on first click.
@@ -154,7 +149,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
} }
const gridTemplate = computed(() => { const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`; return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
}); });
</script> </script>
@@ -176,7 +171,7 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection" :sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns" :desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading> @sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center"> <div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon> <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold"> <h3 class="text-text-primary font-semibold">
{{ {{

View File

@@ -86,14 +86,6 @@ function isChevronUp(column: SortColumn): boolean {
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" /> <ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span> <span v-else class="w-4 h-4"></span>
</div> </div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('visibility')">
Visibility
<ChevronDownIcon v-if="isChevronDown('visibility')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('visibility')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -7,8 +7,6 @@ import {
PencilSquareIcon, PencilSquareIcon,
ArchiveBoxIcon as ArchiveBoxIconSolid, ArchiveBoxIcon as ArchiveBoxIconSolid,
TrashIcon, TrashIcon,
GlobeAltIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid'; } 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';
@@ -143,17 +141,6 @@ const showEditProjectModal = ref(false);
<span>Active</span> <span>Active</span>
</template> </template>
</div> </div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_public">
<GlobeAltIcon class="w-4 text-icon-default"></GlobeAltIcon>
<span>Public</span>
</template>
<template v-else>
<LockClosedIcon class="w-4 text-icon-default"></LockClosedIcon>
<span>Private</span>
</template>
</div>
<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"> 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 <ProjectMoreOptionsDropdown

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
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);
}
</script>
<template>
<BaseFilterBadge
:icon="GlobeAltIcon"
:label="label"
filter-name="Visibility"
@remove="emit('remove')">
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[value === option.id && 'bg-accent text-accent-foreground']"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</BaseFilterBadge>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid'; import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue'; import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import { import {
DropdownMenu, DropdownMenu,
@@ -19,7 +19,6 @@ import { NO_CLIENT_ID } from './constants';
export interface ProjectFilters { export interface ProjectFilters {
status: 'active' | 'archived' | 'all'; status: 'active' | 'archived' | 'all';
visibility: 'public' | 'private' | 'all';
clientIds: string[]; clientIds: string[];
} }
@@ -37,11 +36,6 @@ const statusOptions = [
{ id: 'archived' as const, name: 'Archived' }, { id: 'archived' as const, name: 'Archived' },
]; ];
const visibilityOptions = [
{ id: 'public' as const, name: 'Public' },
{ id: 'private' as const, name: 'Private' },
];
const open = ref(false); const open = ref(false);
function updateStatus(status: 'active' | 'archived' | 'all') { function updateStatus(status: 'active' | 'archived' | 'all') {
@@ -52,14 +46,6 @@ function updateStatus(status: 'active' | 'archived' | 'all') {
open.value = false; open.value = false;
} }
function updateVisibility(visibility: 'public' | 'private' | 'all') {
emit('update:filters', {
...props.filters,
visibility,
});
open.value = false;
}
function toggleClient(clientId: string) { function toggleClient(clientId: string) {
const clientIds = props.filters.clientIds.includes(clientId) const clientIds = props.filters.clientIds.includes(clientId)
? props.filters.clientIds.filter((id) => id !== clientId) ? props.filters.clientIds.filter((id) => id !== clientId)
@@ -83,11 +69,7 @@ function toggleNoClient() {
} }
const hasActiveFilters = computed(() => { const hasActiveFilters = computed(() => {
return ( return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
props.filters.status !== 'all' ||
props.filters.visibility !== 'all' ||
props.filters.clientIds.length > 0
);
}); });
</script> </script>
@@ -120,25 +102,6 @@ const hasActiveFilters = computed(() => {
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<!-- Visibility Filter -->
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<GlobeAltIcon class="h-4 w-4 text-icon-default" />
<span>Visibility</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[
filters.visibility === option.id && 'bg-accent text-accent-foreground',
]"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Client Filter --> <!-- Client Filter -->
<DropdownMenuSub v-if="clients.length > 0"> <DropdownMenuSub v-if="clients.length > 0">
<DropdownMenuSubTrigger class="gap-2"> <DropdownMenuSubTrigger class="gap-2">

View File

@@ -109,7 +109,7 @@ const shownTasks = computed(() => {
</div> </div>
</li> </li>
</ol> </ol>
<div class="px-4 space-x-1"> <div class="px-4">
<Badge v-if="project?.billable_rate"> <Badge v-if="project?.billable_rate">
{{ billableRateFormatted }} {{ billableRateFormatted }}
/ h / h
@@ -118,7 +118,6 @@ const shownTasks = computed(() => {
Default Rate Default Rate
</Badge> </Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge> <Badge v-if="!project?.is_billable"> Non-Billable </Badge>
<Badge>{{ project?.is_public ? 'Public' : 'Private' }}</Badge>
</div> </div>
</nav> </nav>
<div> <div>

View File

@@ -20,7 +20,6 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue'; import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.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 ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants'; import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
@@ -37,7 +36,6 @@ interface ProjectTableState {
filters: { filters: {
clientIds: string[]; clientIds: string[];
status: 'active' | 'archived' | 'all'; status: 'active' | 'archived' | 'all';
visibility: 'public' | 'private' | 'all';
}; };
} }
@@ -49,17 +47,10 @@ const tableState = useStorage<ProjectTableState>(
filters: { filters: {
clientIds: [], clientIds: [],
status: 'all', status: 'all',
visibility: 'all',
}, },
}, },
undefined, undefined,
{ { mergeDefaults: true }
mergeDefaults: (storage, defaults) => ({
...defaults,
...storage,
filters: { ...defaults.filters, ...storage.filters },
}),
}
); );
function handleSort(column: SortColumn, direction: SortDirection) { function handleSort(column: SortColumn, direction: SortDirection) {
@@ -78,14 +69,6 @@ const filteredProjects = computed(() => {
return false; 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 // Client filter
const hasClientFilter = tableState.value.filters.clientIds.length > 0; const hasClientFilter = tableState.value.filters.clientIds.length > 0;
if (hasClientFilter) { if (hasClientFilter) {
@@ -108,10 +91,6 @@ function removeStatusFilter() {
tableState.value.filters.status = 'all'; tableState.value.filters.status = 'all';
} }
function removeVisibilityFilter() {
tableState.value.filters.visibility = 'all';
}
function removeClientFilter() { function removeClientFilter() {
tableState.value.filters.clientIds = []; tableState.value.filters.clientIds = [];
} }
@@ -173,15 +152,6 @@ const showBillableRate = computed(() => {
tableState.filters.status = $event as 'active' | 'archived' | 'all' tableState.filters.status = $event as 'active' | 'archived' | 'all'
" /> " />
<ProjectVisibilityFilterBadge
v-if="tableState.filters.visibility !== 'all'"
data-testid="visibility-filter-badge"
:value="tableState.filters.visibility"
@remove="removeVisibilityFilter"
@update:value="
tableState.filters.visibility = $event as 'public' | 'private' | 'all'
" />
<ProjectClientFilterBadge <ProjectClientFilterBadge
v-if="tableState.filters.clientIds.length > 0" v-if="tableState.filters.clientIds.length > 0"
data-testid="client-filter-badge" data-testid="client-filter-badge"

View File

@@ -28,7 +28,7 @@
"author": "solidtime", "author": "solidtime",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"vite-plugin-dts": "^4.0.3" "vite-plugin-dts": "^5.0.1"
}, },
"peerDependencies": { "peerDependencies": {
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",

View File

@@ -50,7 +50,7 @@
"devDependencies": { "devDependencies": {
"@types/chroma-js": "^3.1.0", "@types/chroma-js": "^3.1.0",
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"vite-plugin-dts": "^4.0.3", "vite-plugin-dts": "^5.0.1",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -15,7 +15,6 @@ import { UserCircleIcon } from '@heroicons/vue/20/solid';
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue'; import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
import { Field, FieldGroup, FieldLabel } from '../field'; import { Field, FieldGroup, FieldLabel } from '../field';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue'; 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'; import type { Client } from '@/packages/api/src';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
@@ -42,7 +41,6 @@ const project = ref<CreateProjectBody>({
billable_rate: null, billable_rate: null,
is_billable: false, is_billable: false,
estimated_time: null, estimated_time: null,
is_public: false,
}); });
async function submit() { async function submit() {
@@ -55,7 +53,6 @@ async function submit() {
billable_rate: null, billable_rate: null,
is_billable: false, is_billable: false,
estimated_time: null, estimated_time: null,
is_public: false,
}; };
} }
@@ -126,7 +123,6 @@ const currentClientName = computed(() => {
v-if="enableEstimatedTime" v-if="enableEstimatedTime"
v-model="project.estimated_time" v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection> @submit="submit()"></EstimatedTimeSection>
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
</FieldGroup> </FieldGroup>
</template> </template>
<template #footer> <template #footer>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Field, FieldDescription, FieldLabel } from '../field';
import { GlobeAltIcon } from '@heroicons/vue/20/solid';
const isPublic = defineModel<boolean>({ default: false });
const visibility = computed({
get: () => (isPublic.value ? 'public' : 'private'),
set: (value: string) => {
isPublic.value = value === 'public';
},
});
const description = computed(() =>
isPublic.value
? 'This project is visible to all members of the organization.'
: 'This project is only visible to its project members.'
);
</script>
<template>
<Field>
<FieldLabel :icon="GlobeAltIcon" for="visibility">Visibility</FieldLabel>
<Select v-model="visibility">
<SelectTrigger id="visibility">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">Private</SelectItem>
<SelectItem value="public">Public</SelectItem>
</SelectContent>
</Select>
<FieldDescription>{{ description }}</FieldDescription>
</Field>
</template>
<style scoped></style>

View File

@@ -31,7 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<div <div
:class=" :class="
cn( cn(
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto' 'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-20 xl:pt-32 justify-center overflow-auto'
) )
"> ">
<DialogContent <DialogContent

View File

@@ -12,7 +12,7 @@ const props = defineProps<{
data-slot="field-group" data-slot="field-group"
:class=" :class="
cn( cn(
'group/field-group @container/field-group flex w-full flex-col gap-6 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4', 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class props.class
) )
"> ">

View File

@@ -308,22 +308,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden(); $response->assertForbidden();
} }
public function test_show_endpoint_fails_if_employee_tries_to_access_public_project(): void
{
// Arrange
// Employees do not have the projects:view:all permission that the show endpoint requires,
// so they are forbidden even from public projects (they list them via the index endpoint instead).
$data = $this->createUserWithRole(Role::Employee);
$publicProject = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $publicProject->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void
{ {
// Arrange // Arrange
@@ -343,29 +327,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden(); $response->assertForbidden();
} }
public function test_store_endpoint_fails_if_user_is_employee(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Project::class, [
'name' => $projectFake->name,
'organization_id' => $data->organization->getKey(),
]);
}
public function test_store_endpoint_highest_possible_billable_rate_can_be_stored_in_database(): void public function test_store_endpoint_highest_possible_billable_rate_can_be_stored_in_database(): void
{ {
// Arrange // Arrange
@@ -707,124 +668,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_store_endpoint_creates_public_project(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_public' => true,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->etc()
);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'organization_id' => $projectFake->organization_id,
'is_public' => true,
]);
}
public function test_store_endpoint_creates_private_project_if_is_public_is_false(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_public' => false,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->etc()
);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'organization_id' => $projectFake->organization_id,
'is_public' => false,
]);
}
public function test_store_endpoint_creates_private_project_by_default_if_is_public_is_not_given(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->etc()
);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'organization_id' => $projectFake->organization_id,
'is_public' => false,
]);
}
public function test_store_endpoint_fails_if_is_public_is_not_boolean(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_public' => 'public',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['is_public']);
}
public function test_update_endpoint_fails_if_user_is_not_part_of_project_organization(): void public function test_update_endpoint_fails_if_user_is_not_part_of_project_organization(): void
{ {
// Arrange // Arrange
@@ -870,30 +713,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden(); $response->assertForbidden();
} }
public function test_update_endpoint_fails_if_user_is_employee(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Employee Updated Name',
'color' => $project->color,
'client_id' => null,
'is_billable' => $project->is_billable,
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Project::class, [
'id' => $project->getKey(),
'name' => 'Employee Updated Name',
]);
}
public function test_update_endpoint_can_update_project_if_project_name_already_exists_in_organization_but_with_different_client(): void public function test_update_endpoint_can_update_project_if_project_name_already_exists_in_organization_but_with_different_client(): void
{ {
// Arrange // Arrange
@@ -1138,120 +957,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$this->assertFalse($project->is_archived); $this->assertFalse($project->is_archived);
} }
public function test_update_endpoint_can_make_a_private_project_public(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
'is_public' => true,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->etc()
);
$project->refresh();
$this->assertTrue($project->is_public);
}
public function test_update_endpoint_can_make_a_public_project_private(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
'is_public' => false,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->etc()
);
$project->refresh();
$this->assertFalse($project->is_public);
}
public function test_update_endpoint_keeps_project_visibility_if_is_public_is_not_given(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->etc()
);
$project->refresh();
$this->assertTrue($project->is_public);
}
public function test_update_endpoint_fails_if_is_public_is_not_boolean(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
'is_public' => 'public',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['is_public']);
$project->refresh();
$this->assertTrue($project->is_public);
}
public function test_update_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void public function test_update_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void
{ {
// Arrange // Arrange
@@ -1470,23 +1175,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden(); $response->assertForbidden();
} }
public function test_destroy_endpoint_fails_if_user_is_employee(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Project::class, [
'id' => $project->getKey(),
]);
}
public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_task(): void public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_task(): void
{ {
// Arrange // Arrange