feat: overhaul project workspace navigation
linter / quality (pull_request) Successful in 1m28s
tests / ci (8.4) (pull_request) Successful in 1m47s

This commit is contained in:
2026-07-18 17:52:25 +02:00
parent a46695c2eb
commit ff796aa10a
45 changed files with 1790 additions and 995 deletions
@@ -35,6 +35,7 @@ class BuildProjectBacklogPayload
)->resolve(),
'canCreateTickets' => $canManageTickets,
'canEditTickets' => $canManageTickets,
'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
'project' => ProjectResource::make($project)->resolve(),
'status' => $status,
'ticketStatusOptions' => TicketStatusResource::collection(
@@ -40,6 +40,7 @@ class BuildProjectBoardPayload
)->resolve(),
'canCreateTickets' => $canManageTickets,
'canEditTickets' => $canManageTickets,
'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
'project' => ProjectResource::make($project)->resolve(),
'status' => $status,
];
@@ -0,0 +1,111 @@
<?php
namespace App\Actions\Projects;
use App\Actions\Notifications\BuildProjectNotificationSettingsPayload;
use App\Actions\Permissions\PermissionService;
use App\Actions\Repositories\BuildProjectRepositorySettingsPayload;
use App\Enums\ProjectSettingsSection;
use App\Http\Resources\PermissionOptionResource;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource;
use App\Http\Resources\TeamResource;
use App\Http\Resources\UserResource;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Arr;
class BuildProjectSettingsPayload
{
public function __construct(
private readonly BuildProjectBoardSettingsPayload $buildProjectBoardSettingsPayload,
private readonly BuildProjectNotificationSettingsPayload $buildProjectNotificationSettingsPayload,
private readonly BuildProjectRepositorySettingsPayload $buildProjectRepositorySettingsPayload,
private readonly BuildProjectTicketMetadataSettingsPayload $buildProjectTicketMetadataSettingsPayload,
private readonly PermissionService $permissionService,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
* @return array<string, mixed>
*/
public function handle(
Project $project,
User $user,
ProjectSettingsSection $section,
mixed $status = null,
mixed $error = null,
): array {
return [
'activeSection' => $section->value,
'error' => $error,
'project' => ProjectResource::make($project)->resolve(),
'sectionData' => $this->sectionData($project, $user, $section),
'status' => $status,
];
}
/**
* @return array<string, mixed>
*/
private function sectionData(
Project $project,
User $user,
ProjectSettingsSection $section,
): array {
$payload = match ($section) {
ProjectSettingsSection::General => $this->generalData($project),
ProjectSettingsSection::Board => $this->buildProjectBoardSettingsPayload->handle($project),
ProjectSettingsSection::Metadata => $this->buildProjectTicketMetadataSettingsPayload->handle($project),
ProjectSettingsSection::Repositories => $this->buildProjectRepositorySettingsPayload->handle($project, $user),
ProjectSettingsSection::Notifications => $this->buildProjectNotificationSettingsPayload->handle($project),
ProjectSettingsSection::Access => $this->accessData($project),
};
return Arr::except($payload, ['error', 'project', 'status']);
}
/**
* @return array<string, mixed>
*/
private function generalData(Project $project): array
{
return [
'sourceTemplates' => ProjectTemplateResource::collection(
ProjectTemplate::query()
->published()
->with('sourceProject')
->orderBy('name')
->get(),
)->resolve(),
'templateState' => $this->resolveProjectTemplateState->handle($project),
];
}
/**
* @return array<string, mixed>
*/
private function accessData(Project $project): array
{
return [
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
'permissionOptions' => PermissionOptionResource::collection(
$this->permissionService->permissionOptions(User::class, Project::class),
)->resolve(),
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
'teams' => TeamResource::collection(
Team::query()
->with('users')
->orderBy('name')
->get(),
)->resolve(),
'users' => UserResource::collection(
User::query()
->orderBy('name')
->get(),
)->resolve(),
];
}
}
@@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource;
use App\Http\Resources\TicketReferenceResource;
use App\Http\Resources\TicketStatusResource;
use App\Http\Resources\TicketTypeResource;
use App\Models\Ticket;
use App\Http\Resources\UserResource;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
class BuildProjectTicketOptionsPayload
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Enums;
enum ProjectSettingsSection: string
{
case General = 'general';
case Board = 'board';
case Metadata = 'metadata';
case Repositories = 'repositories';
case Notifications = 'notifications';
case Access = 'access';
}
@@ -10,8 +10,8 @@ use App\Actions\ProjectBoard\StoreTicketStatus;
use App\Actions\ProjectBoard\UpdateBoardColumn;
use App\Actions\ProjectBoard\UpdateTicketStatus;
use App\Actions\ProjectBoard\UpdateTicketStatusTransitions;
use App\Actions\Projects\BuildProjectBoardSettingsPayload;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\StoreBoardColumnRequest;
use App\Http\Requests\StoreTicketStatusRequest;
use App\Http\Requests\UpdateBoardColumnRequest;
@@ -22,13 +22,10 @@ use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectBoardSettingsController extends Controller
{
public function __construct(
private readonly BuildProjectBoardSettingsPayload $buildProjectBoardSettingsPayload,
private readonly DeleteBoardColumn $deleteBoardColumn,
private readonly DeleteTicketStatus $deleteTicketStatus,
private readonly PermissionService $permissionService,
@@ -39,19 +36,6 @@ class ProjectBoardSettingsController extends Controller
private readonly UpdateTicketStatusTransitions $updateTicketStatusTransitions,
) {}
public function show(Request $request, Project $project): Response
{
abort_unless(
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
return Inertia::render(
'projects/settings/Board',
$this->buildProjectBoardSettingsPayload->handle($project, session('status')),
);
}
public function store(
StoreBoardColumnRequest $request,
Project $project,
@@ -62,7 +46,7 @@ class ProjectBoardSettingsController extends Controller
);
$boardColumn = $this->storeBoardColumn->handle($project, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$boardColumn->name} was added to the board workflow.");
}
@@ -77,7 +61,7 @@ class ProjectBoardSettingsController extends Controller
);
$boardColumn = $this->updateBoardColumn->handle($project, $boardColumn, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$boardColumn->fresh()->name} was updated.");
}
@@ -93,7 +77,7 @@ class ProjectBoardSettingsController extends Controller
$deletedName = $this->deleteBoardColumn->handle($project, $boardColumn);
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$deletedName} was removed from the board workflow.");
}
@@ -107,7 +91,7 @@ class ProjectBoardSettingsController extends Controller
);
$ticketStatus = $this->storeTicketStatus->handle($project, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketStatus->name} was added to {$this->statusContextLabel($ticketStatus)}.");
}
@@ -122,7 +106,7 @@ class ProjectBoardSettingsController extends Controller
);
$ticketStatus = $this->updateTicketStatus->handle($ticketStatus, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketStatus->fresh()->name} was updated.");
}
@@ -138,7 +122,7 @@ class ProjectBoardSettingsController extends Controller
$result = $this->deleteTicketStatus->handle($project, $ticketStatus);
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$result['deleted_name']} was removed from {$result['context_label']}.");
}
@@ -153,7 +137,7 @@ class ProjectBoardSettingsController extends Controller
);
$this->updateTicketStatusTransitions->handle($ticketStatus, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketStatus->name} move targets were updated.");
}
@@ -161,4 +145,12 @@ class ProjectBoardSettingsController extends Controller
{
return $ticketStatus->boardColumn?->name ?? 'Backlog';
}
private function settingsRedirect(Project $project): RedirectResponse
{
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Board->value,
]);
}
}
+22 -63
View File
@@ -8,19 +8,16 @@ use App\Actions\Projects\CreateProject;
use App\Actions\Projects\DeleteProject;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ListVisibleProjects;
use App\Actions\Projects\ResolveProjectTemplateState;
use App\Actions\Projects\UpdateProjectTemplateLink;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\DeleteProjectRequest;
use App\Http\Requests\ResetProjectTemplateRequest;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\SwitchProjectTemplateRequest;
use App\Http\Requests\SyncProjectPermissionsRequest;
use App\Http\Resources\PermissionOptionResource;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource;
use App\Http\Resources\TeamResource;
use App\Http\Resources\UserResource;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Team;
@@ -39,7 +36,6 @@ class ProjectController extends Controller
private readonly DeleteProject $deleteProject,
private readonly ListVisibleProjects $listVisibleProjects,
private readonly PermissionService $permissionService,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
) {}
@@ -83,10 +79,10 @@ class ProjectController extends Controller
{
$project = $this->createProject->handle($request->validated(), $request->user());
return to_route('projects.show', $project);
return to_route('projects.board.index', $project);
}
public function show(Request $request, Project $project): Response
public function show(Request $request, Project $project): RedirectResponse
{
$user = $request->user();
@@ -95,11 +91,7 @@ class ProjectController extends Controller
403,
);
return Inertia::render('projects/Show', [
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
'project' => ProjectResource::make($project)->resolve(),
'templateState' => $this->resolveProjectTemplateState->handle($project),
]);
return to_route('projects.board.index', $project);
}
public function backlog(Request $request, Project $project): Response
@@ -117,31 +109,19 @@ class ProjectController extends Controller
);
}
public function general(Request $request, Project $project): Response
{
return Inertia::render('projects/settings/General', [
'project' => ProjectResource::make($project)->resolve(),
'sourceTemplates' => ProjectTemplateResource::collection(
ProjectTemplate::query()
->published()
->with('sourceProject')
->orderBy('name')
->get(),
)->resolve(),
'status' => session('status'),
'templateState' => $this->resolveProjectTemplateState->handle($project),
]);
}
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
{
$validated = $request->validated();
$template = ProjectTemplate::query()
->published()
->findOrFail($request->integer('source_template_id'));
->findOrFail((int) Arr::get($validated, 'source_template_id'));
$this->updateProjectTemplateLink->switch($project, $template);
return to_route('projects.settings.general', $project)
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::General->value,
])
->with('status', "{$project->name} now uses the {$template->name} template.");
}
@@ -155,40 +135,13 @@ class ProjectController extends Controller
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
return to_route('projects.settings.general', $project)
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::General->value,
])
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
}
public function access(Request $request, Project $project): Response
{
$user = $request->user();
abort_unless(
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
return Inertia::render('projects/Access', [
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
'permissionOptions' => PermissionOptionResource::collection(
$this->permissionService->permissionOptions(User::class, Project::class),
)->resolve(),
'project' => ProjectResource::make($project)->resolve(),
'status' => session('status'),
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
'teams' => TeamResource::collection(
Team::query()
->with('users')
->orderBy('name')
->get(),
)->resolve(),
'users' => UserResource::collection(
User::query()
->orderBy('name')
->get(),
)->resolve(),
]);
}
public function updateUserPermissions(
SyncProjectPermissionsRequest $request,
Project $project,
@@ -205,7 +158,10 @@ class ProjectController extends Controller
$this->permissionService->sync($user, $project, $permissions);
return to_route('projects.access', $project)
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Access->value,
])
->with('status', "{$user->name} permissions were updated.");
}
@@ -224,7 +180,10 @@ class ProjectController extends Controller
$this->permissionService->sync($team, $project, $permissions);
return to_route('projects.access', $project)
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Access->value,
])
->with('status', "{$team->name} permissions were updated.");
}
@@ -2,41 +2,28 @@
namespace App\Http\Controllers;
use App\Actions\Notifications\BuildProjectNotificationSettingsPayload;
use App\Actions\Notifications\DeleteProjectNotificationAction;
use App\Actions\Notifications\StoreProjectNotificationAction;
use App\Actions\Notifications\UpdateProjectNotificationAction;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\StoreProjectNotificationActionRequest;
use App\Http\Requests\UpdateProjectNotificationActionRequest;
use App\Models\NotificationAction;
use App\Models\Project;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectNotificationSettingsController extends Controller
{
public function __construct(
private readonly BuildProjectNotificationSettingsPayload $buildProjectNotificationSettingsPayload,
private readonly DeleteProjectNotificationAction $deleteProjectNotificationAction,
private readonly PermissionService $permissionService,
private readonly StoreProjectNotificationAction $storeProjectNotificationAction,
private readonly UpdateProjectNotificationAction $updateProjectNotificationAction,
) {}
public function show(Request $request, Project $project): Response
{
$this->abortUnlessCanManage($request, $project);
return Inertia::render(
'projects/settings/Notifications',
$this->buildProjectNotificationSettingsPayload->handle($project, session('status')),
);
}
public function store(
StoreProjectNotificationActionRequest $request,
Project $project,
@@ -45,7 +32,7 @@ class ProjectNotificationSettingsController extends Controller
$notificationAction = $this->storeProjectNotificationAction->handle($project, $request->validated());
return to_route('projects.settings.notifications', $project)
return $this->settingsRedirect($project)
->with('status', "{$notificationAction->name} notification was added.");
}
@@ -62,7 +49,7 @@ class ProjectNotificationSettingsController extends Controller
$request->validated(),
);
return to_route('projects.settings.notifications', $project)
return $this->settingsRedirect($project)
->with('status', "{$notificationAction->name} notification was updated.");
}
@@ -80,7 +67,7 @@ class ProjectNotificationSettingsController extends Controller
$this->deleteProjectNotificationAction->handle($project, $notificationAction);
return to_route('projects.settings.notifications', $project)
return $this->settingsRedirect($project)
->with('status', "{$deletedName} notification was deleted.");
}
@@ -91,4 +78,12 @@ class ProjectNotificationSettingsController extends Controller
403,
);
}
private function settingsRedirect(Project $project): RedirectResponse
{
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Notifications->value,
]);
}
}
@@ -3,44 +3,27 @@
namespace App\Http\Controllers;
use App\Actions\Permissions\PermissionService;
use App\Actions\Repositories\BuildProjectRepositorySettingsPayload;
use App\Actions\Repositories\DeleteProjectRepositoryConnection;
use App\Actions\Repositories\StoreProjectRepositoryConnection;
use App\Actions\Repositories\UpdateProjectRepositoryConnection;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\StoreRepositoryConnectionRequest;
use App\Http\Requests\UpdateRepositoryConnectionRequest;
use App\Models\Project;
use App\Models\RepositoryConnection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectRepositorySettingsController extends Controller
{
public function __construct(
private readonly BuildProjectRepositorySettingsPayload $buildProjectRepositorySettingsPayload,
private readonly DeleteProjectRepositoryConnection $deleteProjectRepositoryConnection,
private readonly PermissionService $permissionService,
private readonly StoreProjectRepositoryConnection $storeProjectRepositoryConnection,
private readonly UpdateProjectRepositoryConnection $updateProjectRepositoryConnection,
) {}
public function show(Request $request, Project $project): Response
{
$user = $request->user();
abort_unless(
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
return Inertia::render(
'projects/settings/Repositories',
$this->buildProjectRepositorySettingsPayload->handle($project, $user, session('status')),
);
}
public function store(
StoreRepositoryConnectionRequest $request,
Project $project,
@@ -53,7 +36,7 @@ class ProjectRepositorySettingsController extends Controller
$repositoryConnection = $this->storeProjectRepositoryConnection->handle($project, $user, $request->validated());
return to_route('projects.settings.repositories', $project)
return $this->settingsRedirect($project)
->with('status', "{$repositoryConnection->repository_full_name} was linked to {$project->name}.");
}
@@ -76,7 +59,7 @@ class ProjectRepositorySettingsController extends Controller
$request->validated(),
);
return to_route('projects.settings.repositories', $project)
return $this->settingsRedirect($project)
->with('status', "{$repositoryConnection->fresh()->repository_full_name} connection was updated.");
}
@@ -92,7 +75,15 @@ class ProjectRepositorySettingsController extends Controller
$repositoryLabel = $this->deleteProjectRepositoryConnection->handle($repositoryConnection);
return to_route('projects.settings.repositories', $project)
return $this->settingsRedirect($project)
->with('status', "{$repositoryLabel} was removed from the project.");
}
private function settingsRedirect(Project $project): RedirectResponse
{
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Repositories->value,
]);
}
}
@@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Permissions\PermissionService;
use App\Actions\Projects\BuildProjectSettingsPayload;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\ProjectSettingsRequest;
use App\Models\Project;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Inertia\Inertia;
use Inertia\Response;
class ProjectSettingsController extends Controller
{
public function __construct(
private readonly BuildProjectSettingsPayload $buildProjectSettingsPayload,
private readonly PermissionService $permissionService,
) {}
public function index(ProjectSettingsRequest $request, Project $project): Response
{
$user = $request->user();
abort_unless(
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
$validated = $request->validated();
$section = ProjectSettingsSection::from(
(string) Arr::get($validated, 'section', ProjectSettingsSection::General->value),
);
return Inertia::render(
'projects/Settings',
$this->buildProjectSettingsPayload->handle(
$project,
$user,
$section,
session('status'),
session('error'),
),
);
}
public function general(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::General);
}
public function board(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Board);
}
public function metadata(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Metadata);
}
public function repositories(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Repositories);
}
public function notifications(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Notifications);
}
public function access(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Access);
}
private function redirectToSection(
Request $request,
Project $project,
ProjectSettingsSection $section,
): RedirectResponse {
abort_unless(
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
return to_route('projects.settings.index', [
'project' => $project,
'section' => $section->value,
]);
}
}
@@ -40,11 +40,18 @@ class ProjectTicketController extends Controller
return Inertia::render(
'projects/TicketCreate',
$this->buildProjectTicketOptionsPayload->handle(
$project,
Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog',
session('status'),
),
[
...$this->buildProjectTicketOptionsPayload->handle(
$project,
Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog',
session('status'),
),
'canManageProject' => $this->permissionService->allows(
$user,
Permission::ProjectAdmin,
$project,
),
],
);
}
@@ -3,7 +3,6 @@
namespace App\Http\Controllers;
use App\Actions\Permissions\PermissionService;
use App\Actions\Projects\BuildProjectTicketMetadataSettingsPayload;
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketPriority;
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketType;
use App\Actions\ProjectTicketMetadata\DeleteTicketFieldDefinition;
@@ -18,6 +17,7 @@ use App\Actions\ProjectTicketMetadata\UpdateProjectTicketType;
use App\Actions\ProjectTicketMetadata\UpdateTicketFieldDefinition;
use App\Actions\ProjectTicketMetadata\UpdateTicketLinkType;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\DestroyProjectTicketTypeRequest;
use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreProjectTicketTypeRequest;
@@ -35,13 +35,10 @@ use App\Models\TicketPriority;
use App\Models\TicketType;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectTicketMetadataSettingsController extends Controller
{
public function __construct(
private readonly BuildProjectTicketMetadataSettingsPayload $buildProjectTicketMetadataSettingsPayload,
private readonly DeleteProjectTicketPriority $deleteProjectTicketPriority,
private readonly DeleteProjectTicketType $deleteProjectTicketType,
private readonly DeleteTicketFieldDefinition $deleteTicketFieldDefinition,
@@ -58,23 +55,13 @@ class ProjectTicketMetadataSettingsController extends Controller
private readonly UpdateTicketLinkType $updateTicketLinkType,
) {}
public function show(Request $request, Project $project): Response
{
$this->abortUnlessCanManage($request, $project);
return Inertia::render(
'projects/settings/Metadata',
$this->buildProjectTicketMetadataSettingsPayload->handle($project, session('status'), session('error')),
);
}
public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse
{
$this->abortUnlessCanManage($request, $project);
$this->updateProjectTicketMetadataSettings->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', 'Settings have been updated.');
}
@@ -84,7 +71,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketType = $this->storeProjectTicketType->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketType->name} type was added.");
}
@@ -97,7 +84,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketType = $this->updateProjectTicketType->handle($project, $ticketType, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketType->fresh()->name} type was updated.");
}
@@ -116,12 +103,12 @@ class ProjectTicketMetadataSettingsController extends Controller
);
if (! $result['deleted']) {
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('error', true)
->with('status', $result['message']);
}
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', $result['message']);
}
@@ -131,7 +118,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketPriority = $this->storeProjectTicketPriority->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketPriority->name} priority was added.");
}
@@ -144,7 +131,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketPriority = $this->updateProjectTicketPriority->handle($project, $ticketPriority, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketPriority->fresh()->name} priority was updated.");
}
@@ -158,12 +145,12 @@ class ProjectTicketMetadataSettingsController extends Controller
$result = $this->deleteProjectTicketPriority->handle($project, $ticketPriority);
if (! $result['deleted']) {
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('error', true)
->with('status', $result['message']);
}
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', $result['message']);
}
@@ -175,7 +162,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketFieldDefinition = $this->storeTicketFieldDefinition->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketFieldDefinition->label} field was added.");
}
@@ -188,7 +175,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketFieldDefinition = $this->updateTicketFieldDefinition->handle($project, $ticketFieldDefinition, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketFieldDefinition->fresh()->label} field was updated.");
}
@@ -201,7 +188,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$deletedLabel = $this->deleteTicketFieldDefinition->handle($project, $ticketFieldDefinition);
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$deletedLabel} field was removed.");
}
@@ -211,7 +198,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketLinkType = $this->storeTicketLinkType->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketLinkType->label} link type was added.");
}
@@ -225,7 +212,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketLinkType = $this->updateTicketLinkType->handle($project, $ticketLinkType, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketLinkType->fresh()->label} link type was updated.");
}
@@ -238,7 +225,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$deletedLabel = $this->deleteTicketLinkType->handle($project, $ticketLinkType);
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$deletedLabel} link type was removed.");
}
@@ -249,4 +236,12 @@ class ProjectTicketMetadataSettingsController extends Controller
403,
);
}
private function settingsRedirect(Project $project): RedirectResponse
{
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Metadata->value,
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use App\Enums\ProjectSettingsSection;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
class ProjectSettingsRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'section' => ['required', Rule::enum(ProjectSettingsSection::class)],
];
}
protected function prepareForValidation(): void
{
$section = Arr::get($this->query(), 'section');
$this->merge([
'section' => is_string($section) && ProjectSettingsSection::tryFrom($section) !== null
? $section
: ProjectSettingsSection::General->value,
]);
}
}
-82
View File
@@ -2,17 +2,12 @@
import { Link, usePage } from '@inertiajs/vue3';
import {
ArrowLeft,
BellRing,
BookOpen,
CirclePlus,
Folder,
FolderGit2,
KanbanSquare,
LayoutGrid,
Layers3,
ListTodo,
SlidersHorizontal,
Tags,
Shield,
Users,
} from 'lucide-vue-next';
@@ -36,17 +31,7 @@ import { index as projectTemplatesIndex } from '@/routes/project-templates';
import {
create as projectsCreate,
index as projectsIndex,
show as projectShow,
} from '@/routes/projects';
import { index as projectBacklog } from '@/routes/projects/backlog';
import { index as projectBoard } from '@/routes/projects/board';
import {
board as projectSettingsBoard,
general as projectSettingsGeneral,
metadata as projectSettingsMetadata,
notifications as projectSettingsNotifications,
repositories as projectSettingsRepositories,
} from '@/routes/projects/settings';
import { index as teamsIndex } from '@/routes/teams';
import type { AppSidebarProps, NavItem } from '@/types';
@@ -54,67 +39,6 @@ const props = defineProps<AppSidebarProps>();
const page = usePage();
const isProjectSettings = computed(() => page.url.includes('/settings/'));
const projectNavItems = computed<NavItem[]>(() => {
if (!props.project) {
return [];
}
return [
{
title: 'Overview',
href: projectShow(props.project.id),
icon: LayoutGrid,
isActive: page.url === projectShow.url(props.project.id),
},
{
title: 'Board',
href: projectBoard(props.project.id),
icon: KanbanSquare,
},
{
title: 'Backlog',
href: projectBacklog(props.project.id),
icon: ListTodo,
},
];
});
const projectSettingsNavItems = computed<NavItem[]>(() => {
if (!props.project || !isProjectSettings.value) {
return [];
}
return [
{
title: 'General',
href: projectSettingsGeneral(props.project.id),
icon: SlidersHorizontal,
},
{
title: 'Board',
href: projectSettingsBoard(props.project.id),
icon: KanbanSquare,
},
{
title: 'Metadata',
href: projectSettingsMetadata(props.project.id),
icon: Tags,
},
{
title: 'Repositories',
href: projectSettingsRepositories(props.project.id),
icon: FolderGit2,
},
{
title: 'Notifications',
href: projectSettingsNotifications(props.project.id),
icon: BellRing,
},
];
});
const mainNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
@@ -198,12 +122,6 @@ const footerNavItems: NavItem[] = [
{{ project.name }}
</div>
</div>
<NavMain :items="projectNavItems" label="Project" />
<NavMain
v-if="projectSettingsNavItems.length > 0"
:items="projectSettingsNavItems"
label="Project settings"
/>
<div class="mt-auto px-3 pb-3">
<SidebarMenu>
<SidebarMenuItem>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { KanbanSquare, ListTodo, Settings2 } from 'lucide-vue-next';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index as settingsIndex } from '@/routes/projects/settings';
import type { ProjectTabsProps } from '@/types';
defineProps<ProjectTabsProps>();
</script>
<template>
<nav
aria-label="Project sections"
class="border-outline-variant/70 flex min-w-max items-center gap-1 border-b"
>
<Link
:href="boardIndex(project.id)"
prefetch
:class="[
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
active === 'board'
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
]"
>
<KanbanSquare class="size-4" />
Board
<span
v-if="active === 'board'"
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
/>
</Link>
<Link
:href="backlogIndex(project.id)"
prefetch
:class="[
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
active === 'backlog'
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
]"
>
<ListTodo class="size-4" />
Backlog
<span
v-if="active === 'backlog'"
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
/>
</Link>
<Link
v-if="canManageProject"
:href="settingsIndex(project.id)"
prefetch
:class="[
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
active === 'settings'
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
]"
>
<Settings2 class="size-4" />
Settings
<span
v-if="active === 'settings'"
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
/>
</Link>
</nav>
</template>
@@ -15,10 +15,8 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import {
index as projectsIndex,
show as projectShow,
} from '@/routes/projects/index';
import { index as projectsIndex } from '@/routes/projects/index';
import { index as projectBoard } from '@/routes/projects/board';
import {
create as projectTemplateCreate,
store as projectTemplateStore,
@@ -34,7 +32,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: projectShow(props.project.id),
href: projectBoard(props.project.id),
},
{
title: 'Save as new template',
@@ -161,7 +159,7 @@ const submit = (): void => {
</Button>
<Button as-child variant="outline">
<Link :href="projectShow(project.id)"
<Link :href="projectBoard(project.id)"
>Cancel</Link
>
</Button>
+15 -8
View File
@@ -12,7 +12,8 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects/index';
import { access, index } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import { general as generalSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
@@ -25,7 +26,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: 'Access',
@@ -41,11 +42,15 @@ const hasPermission = (
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} access`" />
<component
:is="embedded ? 'div' : AppLayout"
v-bind="embedded ? {} : { breadcrumbs, project }"
>
<Head v-if="!embedded" :title="`${project.name} access`" />
<div class="app-page">
<div :class="embedded ? 'space-y-6' : 'app-page'">
<div
v-if="!embedded"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
@@ -70,12 +75,14 @@ const hasPermission = (
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
<Link :href="boardIndex(project.id)"
>Back to project</Link
>
</Button>
</div>
</div>
<div v-if="status" class="status-success">
<div v-if="!embedded && status" class="status-success">
{{ status }}
</div>
@@ -301,5 +308,5 @@ const hasPermission = (
</CardContent>
</Card>
</div>
</AppLayout>
</component>
</template>
+17 -16
View File
@@ -2,6 +2,7 @@
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -12,9 +13,9 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index';
import { index } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import {
create as createTicket,
show as showTicket,
@@ -32,7 +33,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: 'Backlog',
@@ -70,17 +71,17 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} backlog`" />
<div class="app-page">
<div class="app-page max-w-none gap-5">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
<div class="space-y-2">
<Badge variant="outline" class="rounded px-2.5 py-1">
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} backlog`"
description="All newly created tickets start here before they move onto the board."
:title="project.name"
description="Plan and refine work before moving it onto the board."
/>
</div>
@@ -97,17 +98,17 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
Create ticket
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div class="overflow-x-auto">
<ProjectTabs
active="backlog"
:can-manage-project="canManageProject"
:project="project"
/>
</div>
<div v-if="status" class="status-success">
{{ status }}
</div>
+543 -266
View File
@@ -1,30 +1,29 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { ArrowRight, CircleUserRound } from 'lucide-vue-next';
import { Head, Link, router } from '@inertiajs/vue3';
import { CircleUserRound, GripVertical, Plus } from 'lucide-vue-next';
import { onBeforeUnmount, ref } from 'vue';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings';
import { index as projectIndex } from '@/routes/projects';
import { index as boardIndex } from '@/routes/projects/board';
import { index as settingsIndex } from '@/routes/projects/settings';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type {
BoardDragPointer,
BoardDragTicket,
BoardDropTarget,
} from '@/types/board-drag';
import type { BoardColumnModel } from '@/types/models/board-column-model';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
const props = defineProps<ProjectBoardPageProps>();
@@ -35,7 +34,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: 'Board',
@@ -43,208 +42,558 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
const boardScroller = ref<HTMLElement | null>(null);
const pendingDragTicket = ref<BoardDragTicket | null>(null);
const draggedTicket = ref<BoardDragTicket | null>(null);
const dragPointer = ref<BoardDragPointer | null>(null);
const dropTarget = ref<BoardDropTarget | null>(null);
const movingTicketId = ref<number | null>(null);
const moveError = ref<string | null>(null);
const pointerX = ref(0);
const pointerY = ref(0);
let autoScrollFrame: number | undefined;
let longPressTimer: number | undefined;
let suppressClickTicketId: number | null = null;
const allowedBoardTargetStatuses = (ticket: TicketModel): TicketStatusModel[] =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) =>
ticketStatus.id !== ticket.ticket_status_id &&
ticketStatus.board_column_id !== null,
);
const allowedStatusesForColumn = (
ticket: TicketModel,
boardColumnId: number,
): TicketStatusModel[] =>
allowedBoardTargetStatuses(ticket).filter(
(ticketStatus) => ticketStatus.board_column_id === boardColumnId,
);
const isAllowedColumn = (boardColumnId: number): boolean => {
if (draggedTicket.value === null) {
return false;
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
return (
allowedStatusesForColumn(draggedTicket.value.ticket, boardColumnId)
.length > 0
);
};
const isActiveDropTarget = (
boardColumnId: number,
ticketStatusId?: number,
): boolean => {
if (dropTarget.value?.boardColumnId !== boardColumnId) {
return false;
}
return (
ticketStatusId === undefined ||
dropTarget.value.ticketStatusId === ticketStatusId
);
};
const clearLongPressTimer = (): void => {
if (longPressTimer !== undefined) {
window.clearTimeout(longPressTimer);
longPressTimer = undefined;
}
};
const stopAutoScroll = (): void => {
if (autoScrollFrame !== undefined) {
window.cancelAnimationFrame(autoScrollFrame);
autoScrollFrame = undefined;
}
};
const runAutoScroll = (): void => {
const scroller = boardScroller.value;
if (draggedTicket.value === null || scroller === null) {
stopAutoScroll();
return;
}
const bounds = scroller.getBoundingClientRect();
const edgeSize = Math.min(88, bounds.width * 0.18);
const maximumStep = 20;
if (pointerX.value < bounds.left + edgeSize) {
const intensity =
1 - Math.max(0, pointerX.value - bounds.left) / edgeSize;
scroller.scrollLeft -= Math.max(5, maximumStep * intensity);
} else if (pointerX.value > bounds.right - edgeSize) {
const intensity =
1 - Math.max(0, bounds.right - pointerX.value) / edgeSize;
scroller.scrollLeft += Math.max(5, maximumStep * intensity);
}
autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
};
const startDragging = (): void => {
if (pendingDragTicket.value === null || draggedTicket.value !== null) {
return;
}
clearLongPressTimer();
draggedTicket.value = pendingDragTicket.value;
suppressClickTicketId = pendingDragTicket.value.ticket.id;
document.body.classList.add('select-none');
autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
};
const updateDropTarget = (clientX: number, clientY: number): void => {
const drag = draggedTicket.value;
const element = document.elementFromPoint(clientX, clientY);
const columnElement = element?.closest<HTMLElement>(
'[data-board-column-id]',
);
if (
drag === null ||
columnElement === undefined ||
columnElement === null
) {
dropTarget.value = null;
return;
}
const boardColumnId = Number(columnElement.dataset.boardColumnId);
const allowedStatuses = allowedStatusesForColumn(
drag.ticket,
boardColumnId,
);
const statusElement = element?.closest<HTMLElement>(
'[data-ticket-status-id]',
);
const statusId = Number(statusElement?.dataset.ticketStatusId);
if (
statusElement !== undefined &&
statusElement !== null &&
allowedStatuses.some((ticketStatus) => ticketStatus.id === statusId)
) {
dropTarget.value = {
boardColumnId,
ticketStatusId: statusId,
};
return;
}
dropTarget.value =
allowedStatuses.length === 1
? {
boardColumnId,
ticketStatusId: allowedStatuses[0].id,
}
: null;
};
const handlePointerDown = (
event: PointerEvent,
ticket: TicketModel,
boardColumn: BoardColumnModel,
): void => {
if (
!props.canEditTickets ||
movingTicketId.value !== null ||
event.button !== 0 ||
allowedBoardTargetStatuses(ticket).length === 0
) {
return;
}
pointerX.value = event.clientX;
pointerY.value = event.clientY;
pendingDragTicket.value = {
originColumnId: boardColumn.id,
ticket,
};
dragPointer.value = {
pointerId: event.pointerId,
pointerType: event.pointerType,
startX: event.clientX,
startY: event.clientY,
};
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
if (event.pointerType === 'touch') {
longPressTimer = window.setTimeout(startDragging, 240);
}
};
const handlePointerMove = (event: PointerEvent): void => {
const pointer = dragPointer.value;
if (pointer === null || pointer.pointerId !== event.pointerId) {
return;
}
pointerX.value = event.clientX;
pointerY.value = event.clientY;
const distance = Math.hypot(
event.clientX - pointer.startX,
event.clientY - pointer.startY,
);
if (draggedTicket.value === null) {
if (pointer.pointerType === 'touch') {
if (distance > 10) {
clearLongPressTimer();
}
return;
}
if (distance < 7) {
return;
}
startDragging();
}
if (draggedTicket.value !== null) {
event.preventDefault();
updateDropTarget(event.clientX, event.clientY);
}
};
const resetDragState = (): void => {
clearLongPressTimer();
stopAutoScroll();
pendingDragTicket.value = null;
draggedTicket.value = null;
dragPointer.value = null;
dropTarget.value = null;
document.body.classList.remove('select-none');
};
const moveTicket = (drag: BoardDragTicket, target: BoardDropTarget): void => {
moveError.value = null;
movingTicketId.value = drag.ticket.id;
router.patch(
ProjectBoardController.move.url({
project: props.project.id,
ticket: drag.ticket.id,
}),
{
redirect_to: 'board',
ticket_status_id: target.ticketStatusId,
},
{
onError: (errors) => {
moveError.value =
Object.values(errors)[0] ??
'The ticket could not be moved. Please try again.';
},
onFinish: () => {
movingTicketId.value = null;
},
preserveScroll: true,
},
);
};
const finishPointer = (event: PointerEvent): void => {
const pointer = dragPointer.value;
const drag = draggedTicket.value;
const target = dropTarget.value;
if (pointer === null || pointer.pointerId !== event.pointerId) {
return;
}
const targetElement = event.currentTarget as HTMLElement;
if (targetElement.hasPointerCapture(event.pointerId)) {
targetElement.releasePointerCapture(event.pointerId);
}
resetDragState();
if (drag !== null) {
window.setTimeout(() => {
suppressClickTicketId = null;
}, 300);
if (target !== null) {
moveTicket(drag, target);
}
}
};
const cancelPointer = (event: PointerEvent): void => {
if (dragPointer.value?.pointerId !== event.pointerId) {
return;
}
resetDragState();
suppressClickTicketId = null;
};
const handleTicketClick = (event: MouseEvent, ticketId: number): void => {
if (suppressClickTicketId !== ticketId) {
return;
}
event.preventDefault();
event.stopPropagation();
};
onBeforeUnmount(() => {
resetDragState();
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} board`" />
<div class="app-page max-w-none">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} board`"
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
/>
<div
class="app-page min-h-[calc(100dvh-3.5rem)] max-w-none gap-4 overflow-hidden"
>
<header class="flex items-end justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<Badge variant="outline" class="rounded px-2.5 py-1">
{{ project.key }}
</Badge>
<span class="text-muted-foreground text-xs font-medium">
Project board
</span>
</div>
<h1 class="mt-2 truncate text-2xl font-bold tracking-tight">
{{ project.name }}
</h1>
</div>
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'board' } },
)
"
>
Create ticket
</Link>
</Button>
<Button v-if="canCreateTickets" as-child size="sm">
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'board' } },
)
"
>
<Plus class="size-4" />
Create ticket
</Link>
</Button>
</header>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
>Open backlog</Link
>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
<div class="overflow-x-auto">
<ProjectTabs
active="board"
:can-manage-project="canManageProject"
:project="project"
/>
</div>
<div v-if="status" class="status-success">
{{ status }}
</div>
<Card v-if="boardColumns.length === 0">
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
The board has no workflow columns yet, so no tickets can
appear here.
</p>
<div v-if="canEditTickets">
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Open board settings
</Link>
</Button>
</div>
</CardContent>
</Card>
<div
v-if="moveError"
class="rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200"
>
{{ moveError }}
</div>
<div
v-if="boardColumns.length === 0"
class="empty-state flex min-h-[calc(100dvh-15rem)] flex-col items-center justify-center gap-4"
>
<p>The board has no workflow columns yet.</p>
<Button v-if="canManageProject" as-child variant="outline">
<Link
:href="
settingsIndex(project.id, {
query: { section: 'board' },
})
"
>
Configure board
</Link>
</Button>
</div>
<div
v-else
class="-mx-4 flex snap-x snap-proximity items-start gap-4 overflow-x-auto px-4 pb-5 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"
ref="boardScroller"
class="-mx-4 flex min-h-[max(28rem,calc(100dvh-14rem))] flex-1 snap-x snap-proximity items-stretch gap-3 overflow-x-auto px-4 pb-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"
>
<Card
<section
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
class="bg-surface-container-low w-[min(21rem,calc(100vw-3rem))] shrink-0 snap-start gap-0 overflow-hidden border-0 py-0 shadow-none hover:shadow-none sm:w-[21rem]"
:data-board-column-id="boardColumn.id"
:class="[
'bg-surface-container-lowest flex min-h-full w-[min(21rem,calc(100vw-2.5rem))] shrink-0 snap-start flex-col rounded border transition-[border-color,background-color,filter,opacity,box-shadow] duration-150 sm:w-[21rem]',
draggedTicket === null
? 'border-outline-variant/70'
: isAllowedColumn(boardColumn.id)
? isActiveDropTarget(boardColumn.id)
? 'border-primary bg-primary/8 ring-primary/25 ring-2'
: 'border-primary/70 bg-primary/5 ring-primary/10 ring-1'
: 'border-outline-variant/40 bg-muted/40 opacity-45 grayscale',
]"
>
<CardHeader
class="border-outline-variant/60 sticky top-0 z-10 border-b px-4 py-4"
<header
class="bg-surface-container-lowest/95 border-outline-variant/70 sticky top-0 z-10 rounded-t border-b px-3.5 py-3 backdrop-blur"
>
<div class="flex items-start justify-between gap-3">
<div>
<CardTitle>{{ boardColumn.name }}</CardTitle>
<CardDescription>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<h2 class="truncate text-sm font-bold">
{{ boardColumn.name }}
</h2>
<p class="text-muted-foreground mt-0.5 text-xs">
{{ boardColumn.tickets?.length ?? 0 }}
active ticket(s)
</CardDescription>
<div
v-if="
(boardColumn.ticket_statuses?.length ??
0) > 0
"
class="mt-3 flex flex-wrap gap-2"
>
<Badge
v-for="ticketStatus in boardColumn.ticket_statuses"
:key="ticketStatus.id"
variant="outline"
>
{{ ticketStatus.name }}
</Badge>
</div>
tickets
</p>
</div>
<Badge variant="secondary">
<Badge variant="secondary" class="rounded">
{{ boardColumn.tickets?.length ?? 0 }}
</Badge>
</div>
</CardHeader>
<CardContent
class="flex min-h-48 flex-1 flex-col gap-3 p-3"
>
<div
v-if="
draggedTicket !== null &&
allowedStatusesForColumn(
draggedTicket.ticket,
boardColumn.id,
).length > 1
"
class="mt-3 grid gap-1.5"
>
<div
v-for="ticketStatus in allowedStatusesForColumn(
draggedTicket.ticket,
boardColumn.id,
)"
:key="ticketStatus.id"
:data-ticket-status-id="ticketStatus.id"
:class="[
'rounded border border-dashed px-3 py-2 text-center text-xs font-semibold transition-colors',
isActiveDropTarget(
boardColumn.id,
ticketStatus.id,
)
? 'border-primary bg-primary text-primary-foreground'
: 'border-primary/60 bg-primary/8 text-primary',
]"
>
Drop as {{ ticketStatus.name }}
</div>
</div>
</header>
<div class="flex flex-1 flex-col gap-2.5 p-2.5">
<div
v-if="(boardColumn.tickets?.length ?? 0) === 0"
class="empty-state bg-transparent"
class="text-muted-foreground flex min-h-28 flex-1 items-center justify-center rounded border border-dashed p-4 text-center text-xs"
>
No tickets are currently in this column.
No tickets in this column.
</div>
<div v-else class="flex flex-col gap-3">
<Card
<template v-else>
<article
v-for="ticket in boardColumn.tickets"
:key="ticket.id"
:class="[
'shadow-xs gap-0 py-0 hover:-translate-y-0.5',
'bg-card shadow-xs hover:border-primary/40 group relative rounded border transition-[border-color,box-shadow,opacity,transform] hover:shadow-sm',
ticket.is_blocked
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
? 'border-amber-300 dark:border-amber-800'
: 'border-outline-variant/70',
canEditTickets &&
allowedBoardTargetStatuses(ticket).length >
0
? 'cursor-grab active:cursor-grabbing'
: '',
draggedTicket?.ticket.id === ticket.id
? 'border-primary scale-[0.99] opacity-30'
: '',
movingTicketId === ticket.id
? 'pointer-events-none animate-pulse opacity-55'
: '',
]"
@pointerdown="
handlePointerDown(
$event,
ticket,
boardColumn,
)
"
@pointermove="handlePointerMove"
@pointerup="finishPointer"
@pointercancel="cancelPointer"
>
<CardHeader class="gap-3 px-4 pb-3 pt-4">
<div class="flex flex-wrap gap-2">
<Badge
variant="outline"
class="text-primary"
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="block touch-pan-y select-none p-3.5"
draggable="false"
@click="
handleTicketClick($event, ticket.id)
"
@dragstart.prevent
>
<div
class="flex items-start justify-between gap-2"
>
<div
class="flex flex-wrap items-center gap-1.5"
>
{{ ticket.ticket_key }}
</Badge>
<Badge
v-if="ticket.is_blocked"
variant="destructive"
>
Blocked
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_type.name }}
</Badge>
</div>
<CardTitle class="text-base">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
<span
class="text-primary text-[0.68rem] font-bold uppercase tracking-wide"
>
{{ ticket.ticket_key }}
</span>
<Badge
v-if="ticket.is_blocked"
variant="destructive"
class="rounded text-[0.65rem]"
>
Blocked
</Badge>
</div>
<GripVertical
v-if="
canEditTickets &&
allowedBoardTargetStatuses(
ticket,
).length > 0
"
class="hover:text-primary transition"
>
{{ ticket.title }}
</Link>
</CardTitle>
<CardDescription
class="text-muted-foreground/50 size-4 shrink-0"
/>
</div>
<h3
class="group-hover:text-primary mt-2 text-sm font-semibold leading-snug transition-colors"
>
{{ ticket.title }}
</h3>
<p
v-if="ticket.description"
class="line-clamp-3"
class="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed"
>
{{ ticket.description }}
</CardDescription>
<CardDescription
v-if="
ticket.is_blocked &&
ticket.blocked_comment
"
class="mt-2 text-amber-900 dark:text-amber-200"
>
{{ ticket.blocked_comment }}
</CardDescription>
</p>
<div
class="border-outline-variant/60 flex items-center justify-between gap-3 border-t pt-3"
class="border-outline-variant/60 mt-3 flex items-center justify-between gap-3 border-t pt-3"
>
<div
class="flex min-w-0 items-center gap-2"
@@ -252,18 +601,18 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
<UserAvatar
v-if="ticket.assignee"
:user="ticket.assignee"
class="size-7"
class="size-6"
/>
<span
v-else
class="border-outline-variant text-muted-foreground flex size-7 items-center justify-center rounded-full border border-dashed"
class="border-outline-variant text-muted-foreground flex size-6 items-center justify-center rounded-full border border-dashed"
>
<CircleUserRound
class="size-4"
class="size-3.5"
/>
</span>
<span
class="text-muted-foreground truncate text-xs"
class="text-muted-foreground truncate text-[0.68rem]"
>
{{
ticket.assignee?.name ??
@@ -272,110 +621,38 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</span>
</div>
<div
class="flex shrink-0 items-center gap-1.5"
class="text-muted-foreground flex shrink-0 items-center gap-1.5 text-[0.68rem] font-medium"
>
<span
class="bg-primary size-1.5 rounded-full"
/>
<span
class="text-muted-foreground text-xs font-medium"
>
{{
ticket.ticket_priority.name
}}
</span>
{{ ticket.ticket_priority.name }}
</div>
</div>
</CardHeader>
<CardContent
v-if="canEditTickets"
class="border-outline-variant/60 border-t px-4 py-3"
>
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex flex-col gap-2"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="board"
/>
<div class="flex items-center gap-2">
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="min-w-0 flex-1 text-xs"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
type="submit"
size="icon-sm"
variant="ghost"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
<ArrowRight class="size-4" />
<span class="sr-only"
>Update status</span
>
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-muted-foreground text-xs"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</Link>
</article>
</template>
</div>
</section>
</div>
</div>
<Teleport to="body">
<div
v-if="draggedTicket"
class="bg-card border-primary pointer-events-none fixed z-[100] w-72 -translate-x-1/2 -translate-y-1/2 rounded border px-4 py-3 shadow-2xl"
:style="{
left: `${pointerX}px`,
top: `${pointerY}px`,
}"
>
<div class="text-primary text-[0.68rem] font-bold uppercase">
{{ draggedTicket.ticket.ticket_key }}
</div>
<div class="mt-1 line-clamp-2 text-sm font-semibold">
{{ draggedTicket.ticket.title }}
</div>
</div>
</Teleport>
</AppLayout>
</template>
+4 -3
View File
@@ -12,7 +12,8 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { create, index, show } from '@/routes/projects/index';
import { create, index } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
defineProps<ProjectsIndexPageProps>();
@@ -62,7 +63,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<Link
v-for="project in projects"
:key="project.id"
:href="show(project.id)"
:href="boardIndex(project.id)"
class="group block"
>
<Card class="group-hover:border-primary/40 h-full py-0">
@@ -85,7 +86,7 @@ const breadcrumbs: BreadcrumbItem[] = [
class="border-outline-variant/60 text-muted-foreground flex items-center gap-2 border-t px-5 py-4 text-sm"
>
<Folder class="size-4" />
Open board, backlog, and settings hub
Open project board
</CardContent>
</Card>
</Link>
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import {
BellRing,
GitBranch,
KanbanSquare,
LayoutTemplate,
Settings2,
ShieldCheck,
Tags,
} from 'lucide-vue-next';
import { computed } from 'vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as projectsIndex } from '@/routes/projects';
import { index as boardIndex } from '@/routes/projects/board';
import { index as settingsIndex } from '@/routes/projects/settings';
import { create as projectTemplateCreate } from '@/routes/projects/templates';
import type {
BreadcrumbItem,
EmbeddedProjectSettingsSectionProps,
ProjectAccessPageProps,
ProjectBoardSettingsPageProps,
ProjectGeneralSettingsPageProps,
ProjectNotificationSettingsPageProps,
ProjectRepositorySettingsPageProps,
ProjectSettingsPageProps,
ProjectSettingsSection,
ProjectTicketMetadataSettingsPageProps,
} from '@/types';
import AccessSection from './Access.vue';
import BoardSection from './settings/Board.vue';
import GeneralSection from './settings/General.vue';
import MetadataSection from './settings/Metadata.vue';
import NotificationsSection from './settings/Notifications.vue';
import RepositoriesSection from './settings/Repositories.vue';
const props = defineProps<ProjectSettingsPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: projectsIndex(),
},
{
title: props.project.name,
href: boardIndex(props.project.id),
},
{
title: 'Settings',
href: settingsIndex(props.project.id),
},
];
const settingsSections = [
{
description: 'Project identity, templates, and deletion',
icon: Settings2,
label: 'General',
value: 'general' as const,
},
{
description: 'Columns, statuses, and transitions',
icon: KanbanSquare,
label: 'Board',
value: 'board' as const,
},
{
description: 'Types, priorities, fields, and links',
icon: Tags,
label: 'Metadata',
value: 'metadata' as const,
},
{
description: 'Linked source repositories',
icon: GitBranch,
label: 'Repositories',
value: 'repositories' as const,
},
{
description: 'Webhook and Discord automation',
icon: BellRing,
label: 'Notifications',
value: 'notifications' as const,
},
{
description: 'User and team permissions',
icon: ShieldCheck,
label: 'Access',
value: 'access' as const,
},
];
const activeSectionDetails = computed(
() =>
settingsSections.find(
(section) => section.value === props.activeSection,
) ?? settingsSections[0],
);
const accessSectionData = computed(
() =>
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectAccessPageProps>,
);
const boardSectionData = computed(
() =>
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectBoardSettingsPageProps>,
);
const generalSectionData = computed(
() =>
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectGeneralSettingsPageProps>,
);
const metadataSectionData = computed(
() =>
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectTicketMetadataSettingsPageProps>,
);
const notificationsSectionData = computed(
() =>
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectNotificationSettingsPageProps>,
);
const repositoriesSectionData = computed(
() =>
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectRepositorySettingsPageProps>,
);
const sectionHref = (section: ProjectSettingsSection) =>
settingsIndex(props.project.id, { query: { section } });
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} settings`" />
<div class="app-page max-w-none gap-5">
<header
class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"
>
<div class="space-y-2">
<Badge variant="outline" class="rounded px-2.5 py-1">
{{ project.key }}
</Badge>
<div>
<h1 class="text-2xl font-bold tracking-tight">
{{ project.name }}
</h1>
<p class="text-muted-foreground mt-1 text-sm">
Project configuration and access controls.
</p>
</div>
</div>
<Button
v-if="
activeSection === 'general' &&
$page.props.auth.permissions.projectCreate
"
as-child
variant="outline"
>
<Link :href="projectTemplateCreate(project.id)">
<LayoutTemplate class="size-4" />
Save as template
</Link>
</Button>
</header>
<div class="overflow-x-auto">
<ProjectTabs
active="settings"
:can-manage-project="true"
:project="project"
/>
</div>
<div
v-if="status"
:class="
error
? 'rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200'
: 'status-success'
"
>
{{ status }}
</div>
<div class="grid min-w-0 gap-5 xl:grid-cols-[15rem_minmax(0,1fr)]">
<nav
aria-label="Project settings"
class="border-outline-variant/70 bg-surface-container-lowest flex gap-1 overflow-x-auto rounded-md border p-1.5 xl:sticky xl:top-20 xl:flex-col xl:self-start xl:overflow-visible"
>
<Link
v-for="section in settingsSections"
:key="section.value"
:href="sectionHref(section.value)"
preserve-scroll
:class="[
'flex min-w-max items-center gap-3 rounded px-3 py-2.5 text-left text-sm transition-colors xl:min-w-0',
activeSection === section.value
? 'bg-primary/10 text-primary font-semibold'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
]"
>
<component :is="section.icon" class="size-4 shrink-0" />
<span class="min-w-0">
<span class="block">{{ section.label }}</span>
<span
class="mt-0.5 hidden text-xs font-normal opacity-75 xl:block"
>
{{ section.description }}
</span>
</span>
</Link>
</nav>
<section class="min-w-0 space-y-5">
<div class="border-outline-variant/70 border-b pb-4">
<h2 class="text-lg font-bold tracking-tight">
{{ activeSectionDetails.label }}
</h2>
<p class="text-muted-foreground mt-1 text-sm">
{{ activeSectionDetails.description }}
</p>
</div>
<GeneralSection
v-if="activeSection === 'general'"
v-bind="generalSectionData"
:project="project"
:status="status"
embedded
/>
<BoardSection
v-else-if="activeSection === 'board'"
v-bind="boardSectionData"
:project="project"
:status="status"
embedded
/>
<MetadataSection
v-else-if="activeSection === 'metadata'"
v-bind="metadataSectionData"
:project="project"
:status="status"
:error="error"
embedded
/>
<RepositoriesSection
v-else-if="activeSection === 'repositories'"
v-bind="repositoriesSectionData"
:project="project"
:status="status"
embedded
/>
<NotificationsSection
v-else-if="activeSection === 'notifications'"
v-bind="notificationsSectionData"
:project="project"
:status="status"
embedded
/>
<AccessSection
v-else
v-bind="accessSectionData"
:project="project"
:status="status"
embedded
/>
</section>
</div>
</div>
</AppLayout>
</template>
-234
View File
@@ -1,234 +0,0 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowRight,
FolderGit2,
LayoutGrid,
ListTodo,
Settings2,
} from 'lucide-vue-next';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import {
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import { create as projectTemplateCreate } from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
];
const projectAreas = computed(() => {
const areas = [
{
title: 'Backlog',
description:
'Create tickets, review incoming work, and move ready items into the board workflow.',
icon: ListTodo,
href: backlogIndex(props.project.id),
},
{
title: 'Board',
description:
'Track active work across the project-defined Kanban columns and update ticket status.',
icon: LayoutGrid,
href: boardIndex(props.project.id),
},
];
if (props.canManageAccess) {
areas.push(
{
title: 'Project settings',
description:
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
icon: Settings2,
href: boardSettings(props.project.id),
},
{
title: 'Repositories',
description:
'Connect repositories and discover ticket branches and pull requests.',
icon: FolderGit2,
href: repositorySettings(props.project.id),
},
);
}
return areas;
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="project.name" />
<div class="app-page">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="project.name"
description="Your project workspace for tickets, workflow, integrations, and access."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)">
Open backlog
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="projectTemplateCreate(project.id)">
Save as new template
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="index()">Back to projects</Link>
</Button>
</div>
</div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Built from template {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project keeps its template link. Inherited areas
follow the template baseline; overridden areas were
customized locally.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
<Button as-child>
<Link :href="boardSettings(project.id)">
Board settings
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="access(project.id)">Manage access</Link>
</Button>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Link
v-for="area in projectAreas"
:key="area.title"
:href="area.href"
class="group"
>
<Card class="h-full py-0">
<CardHeader class="p-5">
<div
class="bg-primary/10 text-primary mb-3 flex size-10 items-center justify-center rounded-lg"
>
<component :is="area.icon" class="size-5" />
</div>
<CardTitle>{{ area.title }}</CardTitle>
<CardDescription>{{
area.description
}}</CardDescription>
</CardHeader>
<CardContent
class="text-primary flex items-center gap-2 px-5 pb-5 text-sm font-semibold"
>
Open area
<ArrowRight
class="size-4 transition-transform group-hover:translate-x-1"
/>
</CardContent>
</Card>
</Link>
</div>
</div>
</AppLayout>
</template>
+14 -19
View File
@@ -14,6 +14,7 @@ import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectT
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -36,9 +37,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index as projectIndex, show } from '@/routes/projects/index';
import { index as projectIndex } from '@/routes/projects/index';
import { show as showTicket } from '@/routes/projects/tickets';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
@@ -81,6 +81,9 @@ const canEditAuthor = computed(
const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject,
);
const activeProjectSection = computed(() =>
props.ticket.ticket_status?.board_column_id === null ? 'backlog' : 'board',
);
const currentParentTicket = computed(
() =>
@@ -108,7 +111,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: props.ticket.ticket_key,
@@ -505,7 +508,7 @@ const formatDateTime = (value: string): string => {
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="ticket.ticket_key" />
<div class="app-page">
<div class="app-page max-w-none gap-5">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -532,22 +535,14 @@ const formatDateTime = (value: string): string => {
description="Review ticket details, update tracked fields, and inspect the change timeline."
/>
</div>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
>Open backlog</Link
>
</Button>
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
<div class="overflow-x-auto">
<ProjectTabs
:active="activeProjectSection"
:can-manage-project="canManageProject"
:project="project"
/>
</div>
<div v-if="status" class="status-success">
+12 -6
View File
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -22,7 +23,7 @@ import {
} from '@/lib/acceptance-criteria';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index';
import { index } from '@/routes/projects/index';
import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
@@ -43,7 +44,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: props.source === 'board' ? 'Board' : 'Backlog',
@@ -124,7 +125,7 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`Create ${project.name} ticket`" />
<div class="app-page-narrow">
<div class="app-page-narrow max-w-5xl gap-5">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
@@ -142,12 +143,17 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Button as-child variant="outline">
<Link :href="returnHref">{{ returnLabel }}</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div class="overflow-x-auto">
<ProjectTabs
:active="source"
:can-manage-project="canManageProject"
:project="project"
/>
</div>
<div v-if="status" class="status-success">
{{ status }}
</div>
+15 -8
View File
@@ -15,7 +15,8 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects/index';
import { index } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import {
board as boardSettings,
general as generalSettings,
@@ -35,7 +36,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: 'Board settings',
@@ -79,11 +80,15 @@ const statusAllowsTarget = (
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} board settings`" />
<component
:is="embedded ? 'div' : AppLayout"
v-bind="embedded ? {} : { breadcrumbs, project }"
>
<Head v-if="!embedded" :title="`${project.name} board settings`" />
<div class="app-page">
<div :class="embedded ? 'space-y-6' : 'app-page'">
<div
v-if="!embedded"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
@@ -126,12 +131,14 @@ const statusAllowsTarget = (
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
<Link :href="boardIndex(project.id)"
>Back to project</Link
>
</Button>
</div>
</div>
<div v-if="status" class="status-success">
<div v-if="!embedded && status" class="status-success">
{{ status }}
</div>
@@ -684,5 +691,5 @@ const statusAllowsTarget = (
</Card>
</div>
</div>
</AppLayout>
</component>
</template>
@@ -15,7 +15,8 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects/index';
import { index } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import {
board as boardSettings,
general as generalSettings,
@@ -35,7 +36,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: 'General settings',
@@ -122,11 +123,15 @@ const submitDelete = (): void => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} general settings`" />
<component
:is="embedded ? 'div' : AppLayout"
v-bind="embedded ? {} : { breadcrumbs, project }"
>
<Head v-if="!embedded" :title="`${project.name} general settings`" />
<div class="app-page">
<div :class="embedded ? 'space-y-6' : 'app-page'">
<div
v-if="!embedded"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
@@ -165,12 +170,14 @@ const submitDelete = (): void => {
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
<Link :href="boardIndex(project.id)"
>Back to project</Link
>
</Button>
</div>
</div>
<div v-if="status" class="status-success">
<div v-if="!embedded && status" class="status-success">
{{ status }}
</div>
@@ -477,5 +484,5 @@ const submitDelete = (): void => {
</CardContent>
</Card>
</div>
</AppLayout>
</component>
</template>
@@ -15,7 +15,8 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects/index';
import { index } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import {
board as boardSettings,
general as generalSettings,
@@ -38,7 +39,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: 'Ticket settings',
@@ -51,11 +52,15 @@ const replacementTicketTypes = (ticketTypeId: number) =>
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} ticket settings`" />
<component
:is="embedded ? 'div' : AppLayout"
v-bind="embedded ? {} : { breadcrumbs, project }"
>
<Head v-if="!embedded" :title="`${project.name} ticket settings`" />
<div class="app-page">
<div :class="embedded ? 'space-y-6' : 'app-page'">
<div
v-if="!embedded"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
@@ -98,13 +103,15 @@ const replacementTicketTypes = (ticketTypeId: number) =>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
<Link :href="boardIndex(project.id)"
>Back to project</Link
>
</Button>
</div>
</div>
<div
v-if="status"
v-if="!embedded && status"
:class="[
'rounded-xl border px-4 py-3 text-sm',
error
@@ -812,5 +819,5 @@ const replacementTicketTypes = (ticketTypeId: number) =>
</Card>
</div>
</div>
</AppLayout>
</component>
</template>
@@ -16,7 +16,8 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects/index';
import { index } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import {
board as boardSettings,
general as generalSettings,
@@ -26,24 +27,13 @@ import {
} from '@/routes/projects/settings';
import type {
BreadcrumbItem,
NotificationFilterFormState,
NotificationFilterPayload,
ProjectNotificationSettingsPageProps,
} from '@/types';
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
import type { NotificationActionModel } from '@/types/models/notification-action-model';
type NotificationFilterFormState = {
attribute: string;
id: string;
operator: string;
value: string;
};
type NotificationFilterPayload = {
attribute: string;
operator: string;
value: string | string[] | null;
};
const props = defineProps<ProjectNotificationSettingsPageProps>();
const defaultCreateChannel =
@@ -79,7 +69,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: 'Notification settings',
@@ -252,11 +242,18 @@ const notificationActionTimestamp = (
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} notification settings`" />
<component
:is="embedded ? 'div' : AppLayout"
v-bind="embedded ? {} : { breadcrumbs, project }"
>
<Head
v-if="!embedded"
:title="`${project.name} notification settings`"
/>
<div class="app-page">
<div :class="embedded ? 'space-y-6' : 'app-page'">
<div
v-if="!embedded"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
@@ -299,12 +296,14 @@ const notificationActionTimestamp = (
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
<Link :href="boardIndex(project.id)"
>Back to project</Link
>
</Button>
</div>
</div>
<div v-if="status" class="status-success">
<div v-if="!embedded && status" class="status-success">
{{ status }}
</div>
@@ -1240,5 +1239,5 @@ const notificationActionTimestamp = (
</div>
</div>
</div>
</AppLayout>
</component>
</template>
@@ -16,7 +16,8 @@ import {
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import { index, show } from '@/routes/projects/index';
import { index } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import {
board as boardSettings,
general as generalSettings,
@@ -42,7 +43,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: props.project.name,
href: show(props.project.id),
href: boardIndex(props.project.id),
},
{
title: 'Repository settings',
@@ -218,11 +219,15 @@ const submitUpdate = (): void => {
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<Head :title="`${project.name} repository settings`" />
<component
:is="embedded ? 'div' : AppLayout"
v-bind="embedded ? {} : { breadcrumbs, project }"
>
<Head v-if="!embedded" :title="`${project.name} repository settings`" />
<div class="app-page">
<div :class="embedded ? 'space-y-6' : 'app-page'">
<div
v-if="!embedded"
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
@@ -265,12 +270,14 @@ const submitUpdate = (): void => {
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
<Link :href="boardIndex(project.id)"
>Back to project</Link
>
</Button>
</div>
</div>
<div v-if="status" class="status-success">
<div v-if="!embedded && status" class="status-success">
{{ status }}
</div>
@@ -724,5 +731,5 @@ const submitUpdate = (): void => {
</div>
</div>
</div>
</AppLayout>
</component>
</template>
+18
View File
@@ -0,0 +1,18 @@
import type { TicketModel } from '@/types/models/ticket-model';
export type BoardDragTicket = {
originColumnId: number;
ticket: TicketModel;
};
export type BoardDragPointer = {
pointerId: number;
pointerType: string;
startX: number;
startY: number;
};
export type BoardDropTarget = {
boardColumnId: number;
ticketStatusId: number;
};
+2
View File
@@ -1,5 +1,7 @@
export * from './auth';
export * from './layout';
export * from './navigation';
export * from './notification-settings';
export * from './page-props';
export * from './project-navigation';
export * from './ui';
@@ -0,0 +1,12 @@
export type NotificationFilterFormState = {
attribute: string;
id: string;
operator: string;
value: string;
};
export type NotificationFilterPayload = {
attribute: string;
operator: string;
value: string | string[] | null;
};
+30 -6
View File
@@ -21,6 +21,7 @@ import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { UserApplicationModel } from '@/types/models/user-application-model';
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
import type { UserModel } from '@/types/models/user-model';
import type { ProjectSettingsSection } from '@/types/project-navigation';
export type AdminUsersPageProps = {
directPermissions: Record<string, string[]>;
@@ -84,12 +85,6 @@ export type ProjectTemplateState = {
templateSlug: string;
};
export type ProjectShowPageProps = {
canManageAccess: boolean;
project: ProjectModel;
templateState?: ProjectTemplateState | null;
};
export type PermissionOption = {
label: string;
value: string;
@@ -103,6 +98,7 @@ export type ProjectAccessPageProps = {
teamPermissions: Record<string, string[]>;
teams: TeamModel[];
users: UserModel[];
embedded?: boolean;
};
export type TeamsIndexPageProps = {
@@ -121,6 +117,7 @@ export type ProjectBoardSettingsPageProps = {
status?: string | null;
templateState?: ProjectTemplateState | null;
ticketStatuses: TicketStatusModel[];
embedded?: boolean;
};
export type ProjectGeneralSettingsPageProps = {
@@ -128,12 +125,14 @@ export type ProjectGeneralSettingsPageProps = {
sourceTemplates: ProjectTemplateModel[];
status?: string | null;
templateState?: ProjectTemplateState | null;
embedded?: boolean;
};
export type ProjectBoardPageProps = {
boardColumns: BoardColumnModel[];
canCreateTickets: boolean;
canEditTickets: boolean;
canManageProject: boolean;
project: ProjectModel;
status?: string | null;
};
@@ -153,6 +152,7 @@ export type ProjectTicketMetadataSettingsPageProps = {
ticketLinkTypes: TicketLinkTypeModel[];
ticketPriorities: TicketPriorityModel[];
ticketTypes: TicketTypeModel[];
embedded?: boolean;
};
export type ProjectRepositorySettingsPageProps = {
@@ -161,6 +161,7 @@ export type ProjectRepositorySettingsPageProps = {
repositoryConnections: RepositoryConnectionModel[];
status?: string | null;
templateState?: ProjectTemplateState | null;
embedded?: boolean;
};
export type ProjectNotificationSettingsPageProps = {
@@ -174,12 +175,14 @@ export type ProjectNotificationSettingsPageProps = {
status?: string | null;
templateState?: ProjectTemplateState | null;
templateVariables: NotificationActionTemplateVariableModel[];
embedded?: boolean;
};
export type ProjectBacklogPageProps = {
backlogTicketStatuses: TicketStatusModel[];
canCreateTickets: boolean;
canEditTickets: boolean;
canManageProject: boolean;
project: ProjectModel;
status?: string | null;
ticketPriorities: TicketPriorityModel[];
@@ -190,6 +193,7 @@ export type ProjectBacklogPageProps = {
};
export type ProjectTicketCreatePageProps = {
canManageProject: boolean;
defaultTicketStatusId: number | null;
parentTicketOptions: TicketReferenceModel[];
project: ProjectModel;
@@ -201,6 +205,26 @@ export type ProjectTicketCreatePageProps = {
users: UserModel[];
};
export type ProjectSettingsSectionData = Partial<
Omit<
ProjectAccessPageProps &
ProjectBoardSettingsPageProps &
ProjectGeneralSettingsPageProps &
ProjectNotificationSettingsPageProps &
ProjectRepositorySettingsPageProps &
ProjectTicketMetadataSettingsPageProps,
'embedded' | 'project' | 'status'
>
>;
export type ProjectSettingsPageProps = {
activeSection: ProjectSettingsSection;
error?: boolean;
project: ProjectModel;
sectionData: ProjectSettingsSectionData;
status?: string | null;
};
export type ProjectTicketPageProps = {
canEditTicket: boolean;
canManageProject: boolean;
+22
View File
@@ -0,0 +1,22 @@
import type { ProjectModel } from '@/types/models/project-model';
export type ProjectPrimarySection = 'backlog' | 'board' | 'settings';
export type ProjectSettingsSection =
| 'access'
| 'board'
| 'general'
| 'metadata'
| 'notifications'
| 'repositories';
export type ProjectTabsProps = {
active?: ProjectPrimarySection | null;
canManageProject: boolean;
project: ProjectModel;
};
export type EmbeddedProjectSettingsSectionProps<T> = Omit<
T,
'embedded' | 'project' | 'status'
>;
+8 -5
View File
@@ -4,14 +4,17 @@ use App\Http\Controllers\ProjectBoardSettingsController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectNotificationSettingsController;
use App\Http\Controllers\ProjectRepositorySettingsController;
use App\Http\Controllers\ProjectSettingsController;
use App\Http\Controllers\ProjectTicketMetadataSettingsController;
use Illuminate\Support\Facades\Route;
Route::get('general', [ProjectController::class, 'general'])->name('general');
Route::get('/', [ProjectSettingsController::class, 'index'])->name('index');
Route::get('general', [ProjectSettingsController::class, 'general'])->name('general');
Route::post('general/switch-template', [ProjectController::class, 'switchTemplate'])->name('general.switch-template');
Route::post('general/reset-template', [ProjectController::class, 'resetTemplate'])->name('general.reset-template');
Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board');
Route::get('board', [ProjectSettingsController::class, 'board'])->name('board');
Route::prefix('board')
->name('board.')
@@ -20,7 +23,7 @@ Route::prefix('board')
require __DIR__.'/settings/board.php';
});
Route::get('metadata', [ProjectTicketMetadataSettingsController::class, 'show'])->name('metadata');
Route::get('metadata', [ProjectSettingsController::class, 'metadata'])->name('metadata');
Route::patch('metadata', [ProjectTicketMetadataSettingsController::class, 'update'])->name('metadata.update');
Route::prefix('metadata')
@@ -30,7 +33,7 @@ Route::prefix('metadata')
require __DIR__.'/settings/metadata.php';
});
Route::get('repositories', [ProjectRepositorySettingsController::class, 'show'])->name('repositories');
Route::get('repositories', [ProjectSettingsController::class, 'repositories'])->name('repositories');
Route::prefix('repositories')
->name('repositories.')
@@ -39,7 +42,7 @@ Route::prefix('repositories')
require __DIR__.'/settings/repositories.php';
});
Route::get('notifications', [ProjectNotificationSettingsController::class, 'show'])->name('notifications');
Route::get('notifications', [ProjectSettingsController::class, 'notifications'])->name('notifications');
Route::prefix('notifications')
->name('notifications.')
+2 -1
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\ProjectBoardController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectSettingsController;
use App\Http\Controllers\ProjectTemplateController;
use Illuminate\Support\Facades\Route;
@@ -31,7 +32,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
->name('destroy');
Route::middleware('can:admin-project,project')->group(function (): void {
Route::get('access', [ProjectController::class, 'access'])->name('access');
Route::get('access', [ProjectSettingsController::class, 'access'])->name('access');
Route::prefix('permissions')->name('permissions.')->group(function (): void {
Route::patch('users/{user}', [ProjectController::class, 'updateUserPermissions'])
@@ -57,6 +57,7 @@ class ProjectBacklogTest extends TestCase
->component('projects/Backlog')
->where('backlogTicketStatuses.0.name', 'Backlog')
->where('canEditTickets', false)
->where('canManageProject', false)
->where('project.id', $project->id)
->where('tickets.0.ticket_key', $ticket->ticket_key)
->where('canCreateTickets', false),
@@ -37,7 +37,7 @@ class ProjectBoardControllerTest extends TestCase
'name' => 'Doing',
'position' => 2,
]);
$this->createColumnStatus($todoColumn);
$todoStatus = $this->createColumnStatus($todoColumn);
$doingStatus = $this->createColumnStatus($doingColumn);
$backlogStatus = $this->backlogStatus($project);
@@ -72,6 +72,7 @@ class ProjectBoardControllerTest extends TestCase
]);
$this->syncProjectPermissions($project, $user, ['view']);
$this->allowMove($doingStatus, $todoStatus);
$this->actingAs($user)
->get(route('projects.board.index', $project))
@@ -88,8 +89,11 @@ class ProjectBoardControllerTest extends TestCase
->where('boardColumns.1.tickets.0.assignee.id', $assignee->id)
->where('boardColumns.1.tickets.0.assignee.name', $assignee->name)
->where('boardColumns.1.tickets.0.assignee.avatar', $assignee->avatar)
->where('boardColumns.1.tickets.0.ticket_status.allowed_target_statuses.0.id', $todoStatus->id)
->where('boardColumns.1.tickets.0.ticket_status.allowed_target_statuses.0.board_column.id', $todoColumn->id)
->where('canCreateTickets', false)
->where('canEditTickets', false),
->where('canEditTickets', false)
->where('canManageProject', false),
);
}
@@ -2,8 +2,8 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\ProjectTemplate;
@@ -45,16 +45,20 @@ class ProjectBoardSettingsControllerTest extends TestCase
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.board', $project))
->get(route('projects.settings.index', [
'project' => $project,
'section' => 'board',
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Board')
->component('projects/Settings')
->where('activeSection', 'board')
->where('project.id', $project->id)
->where('backlogTicketStatuses.0.name', 'Backlog')
->where('boardColumns.0.name', 'Todo')
->where('ticketStatuses.0.name', 'Backlog')
->where('templateState.templateName', 'Starter Workflow')
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW),
->where('sectionData.backlogTicketStatuses.0.name', 'Backlog')
->where('sectionData.boardColumns.0.name', 'Todo')
->where('sectionData.ticketStatuses.0.name', 'Backlog')
->where('sectionData.templateState.templateName', 'Starter Workflow')
->where('sectionData.templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW),
);
}
@@ -87,7 +91,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
->post(route('projects.settings.board.columns.store', $project), [
'name' => 'In Review',
])
->assertRedirect(route('projects.settings.board', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
->assertSessionHas('status', 'In Review was added to the board workflow.');
$this->assertDatabaseHas('board_columns', [
@@ -131,7 +135,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'name' => 'Ready for QA',
'position' => 1,
])
->assertRedirect(route('projects.settings.board', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
->assertSessionHas('status', 'Ready for QA was updated.');
$firstColumn->refresh();
@@ -168,7 +172,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
$this->actingAs($user)
->delete(route('projects.settings.board.columns.destroy', [$project, $secondColumn]))
->assertRedirect(route('projects.settings.board', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
->assertSessionHas('status', 'In Progress was removed from the board workflow.');
$firstColumn->refresh();
@@ -209,7 +213,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'board_column_id' => $boardColumn->id,
'name' => 'Ready for QA',
])
->assertRedirect(route('projects.settings.board', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
->assertSessionHas('status', 'Ready for QA was added to Todo.');
$newStatus = TicketStatus::query()
@@ -225,7 +229,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
->patch(route('projects.settings.board.statuses.update', [$project, $newStatus]), [
'name' => 'QA Ready',
])
->assertRedirect(route('projects.settings.board', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
->assertSessionHas('status', 'QA Ready was updated.');
$newStatus->refresh();
@@ -240,7 +244,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
$this->actingAs($user)
->delete(route('projects.settings.board.statuses.destroy', [$project, $newStatus]))
->assertRedirect(route('projects.settings.board', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
->assertSessionHas('status', 'QA Ready was removed from Todo.');
$this->assertDatabaseMissing('ticket_statuses', [
@@ -280,7 +284,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
'allowed_ticket_status_ids' => [$reviewStatus->id, $doneStatus->id],
])
->assertRedirect(route('projects.settings.board', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
->assertSessionHas('status', 'Progress move targets were updated.');
$this->assertEqualsCanonicalizing(
@@ -290,7 +294,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
$this->actingAs($user)
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [])
->assertRedirect(route('projects.settings.board', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
->assertSessionHas('status', 'Progress move targets were updated.');
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
@@ -174,7 +174,7 @@ class ProjectControllerTest extends TestCase
$project = Project::query()->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
$response->assertRedirect(route('projects.board.index', $project));
$this->assertDatabaseHas('projects', [
'name' => 'Kanban Foundation',
@@ -281,7 +281,7 @@ class ProjectControllerTest extends TestCase
$project = Project::query()->where('key', 'TMP1')->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
$response->assertRedirect(route('projects.board.index', $project));
$project->refresh();
@@ -412,7 +412,7 @@ class ProjectControllerTest extends TestCase
$project = Project::query()->where('key', 'DUPE')->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
$response->assertRedirect(route('projects.board.index', $project));
$this->assertSame('Duplicated Project', $project->name);
$this->assertFalse($project->show_author);
@@ -469,9 +469,9 @@ class ProjectControllerTest extends TestCase
}
/**
* Ensure authenticated users can view the project hub page.
* Ensure authenticated users enter a project through its board.
*/
public function test_authenticated_users_can_view_a_project_hub_page(): void
public function test_authenticated_users_are_redirected_to_the_project_board(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
@@ -482,13 +482,7 @@ class ProjectControllerTest extends TestCase
$this->actingAs($user)
->get(route('projects.show', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Show')
->where('canManageAccess', false)
->where('project.id', $project->id)
->where('project.key', 'CORE'),
);
->assertRedirect(route('projects.board.index', $project));
}
/**
@@ -507,11 +501,7 @@ class ProjectControllerTest extends TestCase
$this->actingAs($user)
->get(route('projects.show', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Show')
->where('project.id', $project->id),
);
->assertRedirect(route('projects.board.index', $project));
}
/**
@@ -544,14 +534,10 @@ class ProjectControllerTest extends TestCase
$this->actingAs($user)
->get(route('projects.access', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Access')
->where('project.id', $project->id)
->has('users', 1)
->has('teams', 1)
->where('permissionOptions.0.value', 'project:read'),
);
->assertRedirect(route('projects.settings.index', [
'project' => $project,
'section' => 'access',
]));
}
/**
@@ -566,12 +552,10 @@ class ProjectControllerTest extends TestCase
$this->actingAs($admin)
->get(route('projects.settings.general', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/General')
->where('project.id', $project->id)
->where('project.key', 'CORE'),
);
->assertRedirect(route('projects.settings.index', [
'project' => $project,
'section' => 'general',
]));
}
/**
@@ -588,11 +572,10 @@ class ProjectControllerTest extends TestCase
$this->actingAs($user)
->get(route('projects.settings.general', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/General')
->where('project.id', $project->id),
);
->assertRedirect(route('projects.settings.index', [
'project' => $project,
'section' => 'general',
]));
}
public function test_project_admins_can_switch_projects_to_another_template(): void
@@ -652,7 +635,10 @@ class ProjectControllerTest extends TestCase
'confirmation_key' => 'CORE',
'source_template_id' => $newTemplate->id,
])
->assertRedirect(route('projects.settings.general', $project))
->assertRedirect(route('projects.settings.index', [
'project' => $project,
'section' => 'general',
]))
->assertSessionHas('status', 'Core Platform now uses the Operations Template template.');
$project->refresh();
@@ -707,7 +693,10 @@ class ProjectControllerTest extends TestCase
->post(route('projects.settings.general.reset-template', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.settings.general', $project))
->assertRedirect(route('projects.settings.index', [
'project' => $project,
'section' => 'general',
]))
->assertSessionHas('status', 'Core Platform was reset to the Workflow Template template baseline.');
$project->refresh();
@@ -878,7 +867,10 @@ class ProjectControllerTest extends TestCase
->patch(route('projects.permissions.users.update', [$project, $member]), [
'permissions' => ['project:read', 'project:write'],
])
->assertRedirect(route('projects.access', $project))
->assertRedirect(route('projects.settings.index', [
'project' => $project,
'section' => 'access',
]))
->assertSessionHas('status', 'Regular User permissions were updated.');
$this->assertDatabaseHas('permission_assignments', [
@@ -917,7 +909,10 @@ class ProjectControllerTest extends TestCase
->patch(route('projects.permissions.teams.update', [$project, $team]), [
'permissions' => ['project:read', 'project:write'],
])
->assertRedirect(route('projects.access', $project))
->assertRedirect(route('projects.settings.index', [
'project' => $project,
'section' => 'access',
]))
->assertSessionHas('status', 'Delivery Team permissions were updated.');
$this->assertDatabaseHas('permission_assignments', [
@@ -55,37 +55,41 @@ class ProjectNotificationSettingsControllerTest extends TestCase
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.notifications', $project))
->get(route('projects.settings.index', [
'project' => $project,
'section' => 'notifications',
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Notifications')
->component('projects/Settings')
->where('activeSection', 'notifications')
->where('project.id', $project->id)
->where('notificationActions.0.name', 'Discord alerts')
->where('notificationActions.0.channel', NotificationChannel::Discord->value)
->where('notificationActions.0.channel_configuration.allowed_mentions.0', 'users')
->where('notificationActions.0.channel_configuration.allowed_mentions.1', 'roles')
->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value)
->where('notificationActions.0.enabled', true)
->where('notificationActions.0.has_delivery_url', true)
->where('notificationActions.0.filters.0.attribute', 'status.to.label')
->where('notificationActions.0.filters.0.operator', 'equals')
->where('notificationActions.0.filters.0.value', 'Doing')
->where('notificationActions.0.updated_at', $notificationAction->updated_at?->toISOString())
->where('channelOptions.0.value', NotificationChannel::Discord->value)
->where('eventTypeOptions.0.value', NotificationEventType::TicketStatusChanged->value)
->where('eventTypeOptions.1.value', NotificationEventType::TicketCreate->value)
->where('eventTypeOptions.2.value', NotificationEventType::TicketAssigneeChanged->value)
->where('discordAllowedMentionOptions.0.value', 'everyone')
->where('discordAllowedMentionOptions.2.value', 'users')
->where('filterAttributeOptions.0.value', 'project.id')
->where('filterOperatorOptions.0.value', 'equals')
->where('templateVariables.0.value', '$url')
->where('templateVariables.6.value', '$actor_name')
->where('templateVariables.7.value', '$assignee_from')
->where('templateVariables.8.value', '$assignee_to')
->where('templateState.templateName', 'Alert Template')
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS)
->missing('notificationActions.0.delivery_url'),
->where('sectionData.notificationActions.0.name', 'Discord alerts')
->where('sectionData.notificationActions.0.channel', NotificationChannel::Discord->value)
->where('sectionData.notificationActions.0.channel_configuration.allowed_mentions.0', 'users')
->where('sectionData.notificationActions.0.channel_configuration.allowed_mentions.1', 'roles')
->where('sectionData.notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value)
->where('sectionData.notificationActions.0.enabled', true)
->where('sectionData.notificationActions.0.has_delivery_url', true)
->where('sectionData.notificationActions.0.filters.0.attribute', 'status.to.label')
->where('sectionData.notificationActions.0.filters.0.operator', 'equals')
->where('sectionData.notificationActions.0.filters.0.value', 'Doing')
->where('sectionData.notificationActions.0.updated_at', $notificationAction->updated_at?->toISOString())
->where('sectionData.channelOptions.0.value', NotificationChannel::Discord->value)
->where('sectionData.eventTypeOptions.0.value', NotificationEventType::TicketStatusChanged->value)
->where('sectionData.eventTypeOptions.1.value', NotificationEventType::TicketCreate->value)
->where('sectionData.eventTypeOptions.2.value', NotificationEventType::TicketAssigneeChanged->value)
->where('sectionData.discordAllowedMentionOptions.0.value', 'everyone')
->where('sectionData.discordAllowedMentionOptions.2.value', 'users')
->where('sectionData.filterAttributeOptions.0.value', 'project.id')
->where('sectionData.filterOperatorOptions.0.value', 'equals')
->where('sectionData.templateVariables.0.value', '$url')
->where('sectionData.templateVariables.6.value', '$actor_name')
->where('sectionData.templateVariables.7.value', '$assignee_from')
->where('sectionData.templateVariables.8.value', '$assignee_to')
->where('sectionData.templateState.templateName', 'Alert Template')
->where('sectionData.templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS)
->missing('sectionData.notificationActions.0.delivery_url'),
);
}
@@ -133,7 +137,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase
], JSON_THROW_ON_ERROR),
'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url',
])
->assertRedirect(route('projects.settings.notifications', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'notifications']))
->assertSessionHas('status', 'Discord alerts notification was added.');
$notificationAction = NotificationAction::query()->firstOrFail();
@@ -195,7 +199,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase
], JSON_THROW_ON_ERROR),
'message_template' => '$ticket_key moved to $status_to: $url',
])
->assertRedirect(route('projects.settings.notifications', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'notifications']))
->assertSessionHas('status', 'Ops escalation alerts notification was updated.');
$notificationAction->refresh();
@@ -250,7 +254,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'delivery_url' => '',
'message_template' => '',
])
->assertRedirect(route('projects.settings.notifications', $project));
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'notifications']));
$this->assertFalse($notificationAction->fresh()->enabled);
@@ -263,7 +267,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'delivery_url' => '',
'message_template' => '',
])
->assertRedirect(route('projects.settings.notifications', $project));
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'notifications']));
$this->assertTrue($notificationAction->fresh()->enabled);
}
@@ -293,7 +297,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'delivery_url' => 'https://hooks.example.test/kanbangit/replaced-secret',
'message_template' => '',
])
->assertRedirect(route('projects.settings.notifications', $project));
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'notifications']));
$this->assertSame(
'https://hooks.example.test/kanbangit/replaced-secret',
@@ -318,7 +322,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase
$this->actingAs($user)
->delete(route('projects.settings.notifications.destroy', [$project, $notificationAction]))
->assertRedirect(route('projects.settings.notifications', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'notifications']))
->assertSessionHas('status', 'Webhook alerts notification was deleted.');
$this->assertModelMissing($notificationAction);
@@ -422,12 +426,15 @@ class ProjectNotificationSettingsControllerTest extends TestCase
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.notifications', $project))
->get(route('projects.settings.index', [
'project' => $project,
'section' => 'notifications',
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Notifications')
->where('notificationActions.0.has_delivery_url', true)
->missing('notificationActions.0.delivery_url'),
->component('projects/Settings')
->where('sectionData.notificationActions.0.has_delivery_url', true)
->missing('sectionData.notificationActions.0.delivery_url'),
);
}
}
@@ -50,16 +50,20 @@ class ProjectRepositorySettingsControllerTest extends TestCase
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.repositories', $project))
->get(route('projects.settings.index', [
'project' => $project,
'section' => 'repositories',
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Repositories')
->component('projects/Settings')
->where('activeSection', 'repositories')
->where('project.id', $project->id)
->where('repositoryConnections.0.repository_full_name', 'core-team/kanbangit')
->where('repositoryConnections.0.has_personal_access', false)
->where('availableRepositorySources', [])
->where('templateState.templateName', 'Connected Template')
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS),
->where('sectionData.repositoryConnections.0.repository_full_name', 'core-team/kanbangit')
->where('sectionData.repositoryConnections.0.has_personal_access', false)
->where('sectionData.availableRepositorySources', [])
->where('sectionData.templateState.templateName', 'Connected Template')
->where('sectionData.templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS),
);
}
@@ -111,7 +115,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'provider_repository_id' => '501',
'user_git_connection_id' => $userGitConnection->id,
])
->assertRedirect(route('projects.settings.repositories', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'repositories']))
->assertSessionHas('status', 'core-team/kanbangit was linked to '.$project->name.'.');
$this->assertDatabaseHas('repository_connections', [
@@ -192,7 +196,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'provider_repository_id' => '902',
'user_git_connection_id' => $userGitConnection->id,
])
->assertRedirect(route('projects.settings.repositories', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'repositories']))
->assertSessionHas('status', 'infra/platform connection was updated.');
$this->assertDatabaseHas('repository_connections', [
@@ -220,7 +224,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
$this->actingAs($user)
->delete(route('projects.settings.repositories.destroy', [$project, $repositoryConnection]))
->assertRedirect(route('projects.settings.repositories', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'repositories']))
->assertSessionHas('status', 'core-team/kanbangit was removed from the project.');
$this->assertDatabaseMissing('repository_connections', [
@@ -0,0 +1,148 @@
<?php
namespace Tests\Feature\Projects;
use App\Actions\Git\GitProviderKey;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Team;
use App\Models\TicketStatus;
use App\Models\User;
use App\Models\UserGitConnection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ProjectSettingsControllerTest extends TestCase
{
use RefreshDatabase;
public function test_project_admins_can_open_each_settings_section(): void
{
$user = User::factory()->create();
$project = Project::factory()->create(['key' => 'CORE']);
$team = Team::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'In progress',
]);
TicketStatus::factory()->for($project)->for($boardColumn)->create([
'name' => 'In progress',
]);
$this->syncProjectPermissions($project, $user, ['admin']);
$expectedSectionProps = [
'general' => 'sourceTemplates',
'board' => 'boardColumns',
'metadata' => 'ticketTypes',
'repositories' => 'repositoryConnections',
'notifications' => 'notificationActions',
'access' => 'teams',
];
foreach ($expectedSectionProps as $section => $expectedProp) {
$this->actingAs($user)
->get(route('projects.settings.index', [
'project' => $project,
'section' => $section,
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Settings')
->where('activeSection', $section)
->where('project.id', $project->id)
->has("sectionData.{$expectedProp}"),
);
}
}
public function test_missing_and_invalid_sections_fall_back_to_general(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
foreach ([null, 'unknown'] as $section) {
$parameters = ['project' => $project];
if ($section !== null) {
$parameters['section'] = $section;
}
$this->actingAs($user)
->get(route('projects.settings.index', $parameters))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Settings')
->where('activeSection', 'general')
->has('sectionData.sourceTemplates'),
);
}
}
public function test_users_without_project_admin_permission_cannot_open_settings(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->get(route('projects.settings.index', $project))
->assertForbidden();
}
public function test_repository_providers_are_not_queried_outside_the_repository_section(): void
{
Http::preventStrayRequests();
$user = User::factory()->create();
$project = Project::factory()->create();
UserGitConnection::factory()->for($user)->create([
'base_url' => 'https://git.example.test',
'provider' => GitProviderKey::Gitea,
]);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->get(route('projects.settings.index', [
'project' => $project,
'section' => 'general',
]))
->assertOk();
Http::assertNothingSent();
}
public function test_legacy_settings_urls_redirect_to_the_matching_section(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
$legacyRoutes = [
'projects.settings.general' => 'general',
'projects.settings.board' => 'board',
'projects.settings.metadata' => 'metadata',
'projects.settings.repositories' => 'repositories',
'projects.settings.notifications' => 'notifications',
'projects.access' => 'access',
];
foreach ($legacyRoutes as $legacyRoute => $section) {
$this->actingAs($user)
->get(route($legacyRoute, $project))
->assertRedirect(route('projects.settings.index', [
'project' => $project,
'section' => $section,
]));
}
}
}
@@ -403,6 +403,7 @@ class ProjectTicketControllerTest extends TestCase
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/TicketCreate')
->where('canManageProject', false)
->where('defaultTicketStatusId', $boardStatus->id)
->where('parentTicketOptions.0.ticket_key', $parentTicket->ticket_key)
->where('project.id', $project->id)
@@ -2,8 +2,8 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket;
@@ -60,19 +60,23 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.metadata', $project))
->get(route('projects.settings.index', [
'project' => $project,
'section' => 'metadata',
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Metadata')
->component('projects/Settings')
->where('activeSection', 'metadata')
->where('project.show_author', true)
->where('project.show_story_points', false)
->where('ticketTypes.0.name', 'Task')
->where('ticketTypes.1.name', 'Release')
->where('ticketTypes.1.is_parent', true)
->where('ticketTypes.2.name', 'Bug')
->where('ticketLinkTypes.0.label', 'Relates to')
->where('templateState.templateName', 'Issue Tracker Template')
->where('templateState.overriddenAreas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS),
->where('sectionData.ticketTypes.0.name', 'Task')
->where('sectionData.ticketTypes.1.name', 'Release')
->where('sectionData.ticketTypes.1.is_parent', true)
->where('sectionData.ticketTypes.2.name', 'Bug')
->where('sectionData.ticketLinkTypes.0.label', 'Relates to')
->where('sectionData.templateState.templateName', 'Issue Tracker Template')
->where('sectionData.templateState.overriddenAreas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS),
);
}
@@ -91,7 +95,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'is_parent' => '1',
'name' => 'Bug',
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Bug type was added.');
$this->assertDatabaseHas('ticket_types', [
@@ -124,7 +128,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'name' => 'Standard Release',
'position' => 2,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Standard Release type was updated.');
$this->assertDatabaseHas('ticket_types', [
@@ -152,7 +156,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'show_author' => false,
'show_story_points' => false,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Settings have been updated.');
$this->assertDatabaseHas('projects', [
@@ -196,7 +200,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->actingAs($user)
->delete(route('projects.settings.metadata.types.destroy', [$project, $ticketType]))
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'The last ticket type cannot be deleted.');
$this->assertDatabaseHas('ticket_types', [
@@ -248,7 +252,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
->delete(route('projects.settings.metadata.types.destroy', [$project, $taskType]), [
'replacement_ticket_type_id' => $releaseType->id,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Task type was removed.');
$this->assertDatabaseMissing('ticket_types', [
@@ -327,7 +331,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
->delete(route('projects.settings.metadata.types.destroy', [$project, $releaseType]), [
'replacement_ticket_type_id' => $epicType->id,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Release type was removed.');
$this->assertDatabaseHas('tickets', [
@@ -354,19 +358,19 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
->post(route('projects.settings.metadata.priorities.store', $project), [
'name' => 'Low',
])
->assertRedirect(route('projects.settings.metadata', $project));
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']));
$this->actingAs($user)
->patch(route('projects.settings.metadata.priorities.update', [$project, $priority]), [
'name' => 'Urgent',
'position' => 2,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Urgent priority was updated.');
$this->actingAs($user)
->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority]))
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Urgent priority was removed.');
$this->assertDatabaseMissing('ticket_priorities', [
@@ -394,7 +398,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'label' => 'Reviewer',
'field_type' => 'user',
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Reviewer field was added.');
$this->actingAs($user)
@@ -403,7 +407,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'field_type' => 'number',
'position' => 2,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Risk score field was updated.');
$this->assertDatabaseHas('ticket_field_definitions', [
@@ -453,7 +457,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'name' => 'Blocks',
'counterpart_name' => 'Blocked by',
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Blocks link type was added.');
$this->assertDatabaseHas('ticket_link_types', [
@@ -468,7 +472,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'counterpart_name' => 'Required by',
'position' => 2,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Depends on link type was updated.');
$this->assertDatabaseHas('ticket_link_types', [
@@ -479,7 +483,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->actingAs($user)
->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType]))
->assertRedirect(route('projects.settings.metadata', $project))
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata']))
->assertSessionHas('status', 'Depends on link type was removed.');
$this->assertDatabaseMissing('ticket_link_types', [