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