diff --git a/app/Actions/Projects/BuildProjectBacklogPayload.php b/app/Actions/Projects/BuildProjectBacklogPayload.php index bff6315..2d2a422 100644 --- a/app/Actions/Projects/BuildProjectBacklogPayload.php +++ b/app/Actions/Projects/BuildProjectBacklogPayload.php @@ -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( diff --git a/app/Actions/Projects/BuildProjectBoardPayload.php b/app/Actions/Projects/BuildProjectBoardPayload.php index 4c08b65..db72f5a 100644 --- a/app/Actions/Projects/BuildProjectBoardPayload.php +++ b/app/Actions/Projects/BuildProjectBoardPayload.php @@ -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, ]; diff --git a/app/Actions/Projects/BuildProjectSettingsPayload.php b/app/Actions/Projects/BuildProjectSettingsPayload.php new file mode 100644 index 0000000..8bc7f11 --- /dev/null +++ b/app/Actions/Projects/BuildProjectSettingsPayload.php @@ -0,0 +1,111 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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(), + ]; + } +} diff --git a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php index bd4bfc1..ca1b928 100644 --- a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php +++ b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php @@ -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 diff --git a/app/Enums/ProjectSettingsSection.php b/app/Enums/ProjectSettingsSection.php new file mode 100644 index 0000000..e8d0b0f --- /dev/null +++ b/app/Enums/ProjectSettingsSection.php @@ -0,0 +1,13 @@ +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, + ]); + } } diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 898a9df..d6b17e3 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -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."); } diff --git a/app/Http/Controllers/ProjectNotificationSettingsController.php b/app/Http/Controllers/ProjectNotificationSettingsController.php index 8abb088..e78665d 100644 --- a/app/Http/Controllers/ProjectNotificationSettingsController.php +++ b/app/Http/Controllers/ProjectNotificationSettingsController.php @@ -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, + ]); + } } diff --git a/app/Http/Controllers/ProjectRepositorySettingsController.php b/app/Http/Controllers/ProjectRepositorySettingsController.php index c95537c..e58c177 100644 --- a/app/Http/Controllers/ProjectRepositorySettingsController.php +++ b/app/Http/Controllers/ProjectRepositorySettingsController.php @@ -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, + ]); + } } diff --git a/app/Http/Controllers/ProjectSettingsController.php b/app/Http/Controllers/ProjectSettingsController.php new file mode 100644 index 0000000..ca7aa54 --- /dev/null +++ b/app/Http/Controllers/ProjectSettingsController.php @@ -0,0 +1,95 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 42cfbb9..4e54698 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -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, + ), + ], ); } diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index fce99ed..c2b4067 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -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, + ]); + } } diff --git a/app/Http/Requests/ProjectSettingsRequest.php b/app/Http/Requests/ProjectSettingsRequest.php new file mode 100644 index 0000000..96233de --- /dev/null +++ b/app/Http/Requests/ProjectSettingsRequest.php @@ -0,0 +1,43 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|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, + ]); + } +} diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index f4c94ad..823c0b8 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -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(); const page = usePage(); -const isProjectSettings = computed(() => page.url.includes('/settings/')); - -const projectNavItems = computed(() => { - 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(() => { - 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(() => { const items: NavItem[] = [ { @@ -198,12 +122,6 @@ const footerNavItems: NavItem[] = [ {{ project.name }} - -
diff --git a/resources/js/components/projects/ProjectTabs.vue b/resources/js/components/projects/ProjectTabs.vue new file mode 100644 index 0000000..a5fd6b6 --- /dev/null +++ b/resources/js/components/projects/ProjectTabs.vue @@ -0,0 +1,72 @@ + + + diff --git a/resources/js/pages/project-templates/Create.vue b/resources/js/pages/project-templates/Create.vue index 088502f..bdc8cc8 100644 --- a/resources/js/pages/project-templates/Create.vue +++ b/resources/js/pages/project-templates/Create.vue @@ -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 => { diff --git a/resources/js/pages/projects/Access.vue b/resources/js/pages/projects/Access.vue index 631cfa1..792d74a 100644 --- a/resources/js/pages/projects/Access.vue +++ b/resources/js/pages/projects/Access.vue @@ -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 = ( diff --git a/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue index 32e9a7c..15d3d01 100644 --- a/resources/js/pages/projects/Backlog.vue +++ b/resources/js/pages/projects/Backlog.vue @@ -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[] => { -
+
-
- +
+ {{ project.key }}
@@ -97,17 +98,17 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => { Create ticket - - - -
+
+ +
+
{{ status }}
diff --git a/resources/js/pages/projects/Board.vue b/resources/js/pages/projects/Board.vue index 71a4f13..964597a 100644 --- a/resources/js/pages/projects/Board.vue +++ b/resources/js/pages/projects/Board.vue @@ -1,30 +1,29 @@ +
+
+ + +
+
+ {{ draggedTicket.ticket.ticket_key }} +
+
+ {{ draggedTicket.ticket.title }} +
+
+
diff --git a/resources/js/pages/projects/Index.vue b/resources/js/pages/projects/Index.vue index 1be1129..3e951b7 100644 --- a/resources/js/pages/projects/Index.vue +++ b/resources/js/pages/projects/Index.vue @@ -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(); @@ -62,7 +63,7 @@ const breadcrumbs: BreadcrumbItem[] = [ @@ -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" > - Open board, backlog, and settings hub + Open project board diff --git a/resources/js/pages/projects/Settings.vue b/resources/js/pages/projects/Settings.vue new file mode 100644 index 0000000..46e54e7 --- /dev/null +++ b/resources/js/pages/projects/Settings.vue @@ -0,0 +1,274 @@ + + + diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue deleted file mode 100644 index add91fa..0000000 --- a/resources/js/pages/projects/Show.vue +++ /dev/null @@ -1,234 +0,0 @@ - - - diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue index 39c6c1b..5fba327 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -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 => { -
+
@@ -532,22 +535,14 @@ const formatDateTime = (value: string): string => { description="Review ticket details, update tracked fields, and inspect the change timeline." />
+
-
- - - - - -
+
+
diff --git a/resources/js/pages/projects/TicketCreate.vue b/resources/js/pages/projects/TicketCreate.vue index 9b54c0a..eb1d024 100644 --- a/resources/js/pages/projects/TicketCreate.vue +++ b/resources/js/pages/projects/TicketCreate.vue @@ -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 => { -
+
@@ -142,12 +143,17 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => { -
+
+ +
+
{{ status }}
diff --git a/resources/js/pages/projects/settings/Board.vue b/resources/js/pages/projects/settings/Board.vue index 26ab0fe..b0d73da 100644 --- a/resources/js/pages/projects/settings/Board.vue +++ b/resources/js/pages/projects/settings/Board.vue @@ -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 = ( diff --git a/resources/js/pages/projects/settings/General.vue b/resources/js/pages/projects/settings/General.vue index 0bde05e..1123eda 100644 --- a/resources/js/pages/projects/settings/General.vue +++ b/resources/js/pages/projects/settings/General.vue @@ -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 => { diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue index 885375e..1fece1b 100644 --- a/resources/js/pages/projects/settings/Metadata.vue +++ b/resources/js/pages/projects/settings/Metadata.vue @@ -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) => diff --git a/resources/js/pages/projects/settings/Repositories.vue b/resources/js/pages/projects/settings/Repositories.vue index 6212476..36383f0 100644 --- a/resources/js/pages/projects/settings/Repositories.vue +++ b/resources/js/pages/projects/settings/Repositories.vue @@ -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 => { diff --git a/resources/js/types/board-drag.ts b/resources/js/types/board-drag.ts new file mode 100644 index 0000000..dc4baaf --- /dev/null +++ b/resources/js/types/board-drag.ts @@ -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; +}; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 8dccc6d..0620dac 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -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'; diff --git a/resources/js/types/notification-settings.ts b/resources/js/types/notification-settings.ts new file mode 100644 index 0000000..e331b43 --- /dev/null +++ b/resources/js/types/notification-settings.ts @@ -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; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index 77b9f6b..36de7d8 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -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; @@ -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; 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; diff --git a/resources/js/types/project-navigation.ts b/resources/js/types/project-navigation.ts new file mode 100644 index 0000000..bcedd82 --- /dev/null +++ b/resources/js/types/project-navigation.ts @@ -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 = Omit< + T, + 'embedded' | 'project' | 'status' +>; diff --git a/routes/web/project/settings.php b/routes/web/project/settings.php index ab2891f..31da1f1 100644 --- a/routes/web/project/settings.php +++ b/routes/web/project/settings.php @@ -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.') diff --git a/routes/web/projects.php b/routes/web/projects.php index 60a5553..e4f8637 100644 --- a/routes/web/projects.php +++ b/routes/web/projects.php @@ -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']) diff --git a/tests/Feature/Projects/ProjectBacklogTest.php b/tests/Feature/Projects/ProjectBacklogTest.php index 1d27476..ae84e62 100644 --- a/tests/Feature/Projects/ProjectBacklogTest.php +++ b/tests/Feature/Projects/ProjectBacklogTest.php @@ -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), diff --git a/tests/Feature/Projects/ProjectBoardControllerTest.php b/tests/Feature/Projects/ProjectBoardControllerTest.php index 77e8d3e..851120d 100644 --- a/tests/Feature/Projects/ProjectBoardControllerTest.php +++ b/tests/Feature/Projects/ProjectBoardControllerTest.php @@ -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), ); } diff --git a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php index 964e5b8..e9d000e 100644 --- a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php @@ -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()); diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php index d502959..143a6d2 100644 --- a/tests/Feature/Projects/ProjectControllerTest.php +++ b/tests/Feature/Projects/ProjectControllerTest.php @@ -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', [ diff --git a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php index f48b384..8a8e5fc 100644 --- a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php @@ -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'), ); } } diff --git a/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php b/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php index 2da038c..dbae42d 100644 --- a/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php @@ -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', [ diff --git a/tests/Feature/Projects/ProjectSettingsControllerTest.php b/tests/Feature/Projects/ProjectSettingsControllerTest.php new file mode 100644 index 0000000..97af81f --- /dev/null +++ b/tests/Feature/Projects/ProjectSettingsControllerTest.php @@ -0,0 +1,148 @@ +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, + ])); + } + } +} diff --git a/tests/Feature/Projects/ProjectTicketControllerTest.php b/tests/Feature/Projects/ProjectTicketControllerTest.php index b586ccf..395f846 100644 --- a/tests/Feature/Projects/ProjectTicketControllerTest.php +++ b/tests/Feature/Projects/ProjectTicketControllerTest.php @@ -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) diff --git a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php index aafae3e..854c7cd 100644 --- a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php @@ -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', [