Implement ticket links and blocker state
This commit is contained in:
@@ -18,9 +18,12 @@ class UpdateProjectTicket
|
|||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* acceptance_criteria: string|null,
|
* acceptance_criteria: string|null,
|
||||||
|
* blocked_comment: string|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
|
* is_blocked: bool,
|
||||||
* ticket_priority_id: int,
|
* ticket_priority_id: int,
|
||||||
* ticket_type_id: int,
|
* ticket_type_id: int,
|
||||||
|
* parent_ticket_id: int|null,
|
||||||
* title: string
|
* title: string
|
||||||
* } $ticketAttributes
|
* } $ticketAttributes
|
||||||
* @param array<int, array{
|
* @param array<int, array{
|
||||||
@@ -36,6 +39,10 @@ class UpdateProjectTicket
|
|||||||
array $fieldValueAttributes,
|
array $fieldValueAttributes,
|
||||||
): bool {
|
): bool {
|
||||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $ticket, $ticketAttributes): bool {
|
return DB::transaction(function () use ($actor, $fieldValueAttributes, $ticket, $ticketAttributes): bool {
|
||||||
|
if (! $ticketAttributes['is_blocked']) {
|
||||||
|
$ticketAttributes['blocked_comment'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
$ticket->loadMissing([
|
$ticket->loadMissing([
|
||||||
'fieldValues.ticketFieldDefinition',
|
'fieldValues.ticketFieldDefinition',
|
||||||
'fieldValues.userValue',
|
'fieldValues.userValue',
|
||||||
@@ -88,6 +95,14 @@ class UpdateProjectTicket
|
|||||||
$ticket->acceptance_criteria,
|
$ticket->acceptance_criteria,
|
||||||
$ticketAttributes['acceptance_criteria'],
|
$ticketAttributes['acceptance_criteria'],
|
||||||
);
|
);
|
||||||
|
$this->trackScalarChange(
|
||||||
|
$changes,
|
||||||
|
$summaryLabels,
|
||||||
|
'blocked_comment',
|
||||||
|
'blocker comment',
|
||||||
|
$ticket->blocked_comment,
|
||||||
|
$ticketAttributes['blocked_comment'],
|
||||||
|
);
|
||||||
|
|
||||||
if ($ticket->ticket_type_id !== $ticketAttributes['ticket_type_id']) {
|
if ($ticket->ticket_type_id !== $ticketAttributes['ticket_type_id']) {
|
||||||
$changes['ticket_type'] = [
|
$changes['ticket_type'] = [
|
||||||
@@ -105,6 +120,14 @@ class UpdateProjectTicket
|
|||||||
$summaryLabels[] = 'priority';
|
$summaryLabels[] = 'priority';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($ticket->is_blocked !== $ticketAttributes['is_blocked']) {
|
||||||
|
$changes['is_blocked'] = [
|
||||||
|
'from' => $ticket->is_blocked,
|
||||||
|
'to' => $ticketAttributes['is_blocked'],
|
||||||
|
];
|
||||||
|
$summaryLabels[] = 'blocked state';
|
||||||
|
}
|
||||||
|
|
||||||
if ($ticket->parent_ticket_id !== $ticketAttributes['parent_ticket_id']) {
|
if ($ticket->parent_ticket_id !== $ticketAttributes['parent_ticket_id']) {
|
||||||
$newParentTicket = $ticketAttributes['parent_ticket_id'] === null
|
$newParentTicket = $ticketAttributes['parent_ticket_id'] === null
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Actions\Tickets\UpdateProjectTicket;
|
|||||||
use App\Http\Requests\UpdateProjectTicketRequest;
|
use App\Http\Requests\UpdateProjectTicketRequest;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketLink;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -32,6 +33,7 @@ class ProjectTicketController extends Controller
|
|||||||
return Inertia::render('projects/Ticket', [
|
return Inertia::render('projects/Ticket', [
|
||||||
'canEditTicket' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
|
'canEditTicket' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
|
||||||
'childTicketTree' => $this->buildChildTicketTree($ticket),
|
'childTicketTree' => $this->buildChildTicketTree($ticket),
|
||||||
|
'linkedTickets' => $this->linkedTickets($ticket),
|
||||||
'parentTicketOptions' => Ticket::query()
|
'parentTicketOptions' => Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->whereNotIn('id', $excludedParentIds)
|
->whereNotIn('id', $excludedParentIds)
|
||||||
@@ -54,6 +56,17 @@ class ProjectTicketController extends Controller
|
|||||||
'worklogEntries.actor',
|
'worklogEntries.actor',
|
||||||
]),
|
]),
|
||||||
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
|
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
|
||||||
|
'ticketLinkOptions' => Ticket::query()
|
||||||
|
->where('project_id', $project->id)
|
||||||
|
->whereKeyNot($ticket->id)
|
||||||
|
->orderByDesc('sequence_number')
|
||||||
|
->get()
|
||||||
|
->map(fn (Ticket $linkedTicket): array => [
|
||||||
|
'id' => $linkedTicket->id,
|
||||||
|
'ticket_key' => $linkedTicket->ticket_key,
|
||||||
|
'title' => $linkedTicket->title,
|
||||||
|
]),
|
||||||
|
'ticketLinkTypes' => $project->ticketLinkTypes()->get(),
|
||||||
'ticketPriorities' => $project->ticketPriorities()->get(),
|
'ticketPriorities' => $project->ticketPriorities()->get(),
|
||||||
'ticketTypes' => $project->ticketTypes()->get(),
|
'ticketTypes' => $project->ticketTypes()->get(),
|
||||||
'users' => User::query()
|
'users' => User::query()
|
||||||
@@ -124,4 +137,50 @@ class ProjectTicketController extends Controller
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{
|
||||||
|
* created_at: string|null,
|
||||||
|
* id: int,
|
||||||
|
* linked_ticket: array{id: int, ticket_key: string, title: string},
|
||||||
|
* source_ticket_id: int,
|
||||||
|
* target_ticket_id: int,
|
||||||
|
* ticket_link_type: mixed,
|
||||||
|
* ticket_link_type_id: int,
|
||||||
|
* updated_at: string|null
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function linkedTickets(Ticket $ticket): array
|
||||||
|
{
|
||||||
|
return TicketLink::query()
|
||||||
|
->with(['sourceTicket', 'targetTicket', 'ticketLinkType'])
|
||||||
|
->where(function ($query) use ($ticket): void {
|
||||||
|
$query
|
||||||
|
->where('source_ticket_id', $ticket->id)
|
||||||
|
->orWhere('target_ticket_id', $ticket->id);
|
||||||
|
})
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->map(function (TicketLink $ticketLink) use ($ticket): array {
|
||||||
|
$linkedTicket = $ticketLink->source_ticket_id === $ticket->id
|
||||||
|
? $ticketLink->targetTicket
|
||||||
|
: $ticketLink->sourceTicket;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'created_at' => $ticketLink->created_at?->toISOString(),
|
||||||
|
'id' => $ticketLink->id,
|
||||||
|
'linked_ticket' => [
|
||||||
|
'id' => $linkedTicket->id,
|
||||||
|
'ticket_key' => $linkedTicket->ticket_key,
|
||||||
|
'title' => $linkedTicket->title,
|
||||||
|
],
|
||||||
|
'source_ticket_id' => $ticketLink->source_ticket_id,
|
||||||
|
'target_ticket_id' => $ticketLink->target_ticket_id,
|
||||||
|
'ticket_link_type' => $ticketLink->ticketLinkType,
|
||||||
|
'ticket_link_type_id' => $ticketLink->ticket_link_type_id,
|
||||||
|
'updated_at' => $ticketLink->updated_at?->toISOString(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||||
|
use App\Actions\Tickets\CreateTicketWorklogEntry;
|
||||||
|
use App\Http\Requests\StoreTicketLinkRequest;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketLink;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ProjectTicketLinkController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||||
|
private readonly ProjectPermissionService $projectPermissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function store(
|
||||||
|
StoreTicketLinkRequest $request,
|
||||||
|
Project $project,
|
||||||
|
Ticket $ticket,
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless(
|
||||||
|
$this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
$linkedTicket = Ticket::query()
|
||||||
|
->where('project_id', $project->id)
|
||||||
|
->findOrFail($request->linkedTicketId());
|
||||||
|
|
||||||
|
[$sourceTicketId, $targetTicketId] = $this->normalizeTicketPair($ticket->id, $linkedTicket->id);
|
||||||
|
|
||||||
|
$ticketLink = DB::transaction(function () use ($linkedTicket, $request, $sourceTicketId, $targetTicketId, $ticket): TicketLink {
|
||||||
|
$ticketLink = TicketLink::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'source_ticket_id' => $sourceTicketId,
|
||||||
|
'target_ticket_id' => $targetTicketId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ticket_link_type_id' => $request->ticketLinkTypeId(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$ticketLink->load('ticketLinkType');
|
||||||
|
|
||||||
|
$this->createTicketWorklogEntry->handle(
|
||||||
|
$ticket,
|
||||||
|
$request->user(),
|
||||||
|
'link_updated',
|
||||||
|
"Linked {$linkedTicket->ticket_key} as {$ticketLink->ticketLinkType->label}.",
|
||||||
|
[
|
||||||
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
|
'ticket_link_type' => $ticketLink->ticketLinkType->label,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $ticketLink;
|
||||||
|
});
|
||||||
|
|
||||||
|
return to_route('projects.tickets.show', [$project, $ticket])
|
||||||
|
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(
|
||||||
|
Request $request,
|
||||||
|
Project $project,
|
||||||
|
Ticket $ticket,
|
||||||
|
TicketLink $ticketLink,
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless(
|
||||||
|
$this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
$ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']);
|
||||||
|
|
||||||
|
abort_unless(
|
||||||
|
$ticketLink->source_ticket_id === $ticket->id || $ticketLink->target_ticket_id === $ticket->id,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
|
||||||
|
$linkedTicket = $ticketLink->source_ticket_id === $ticket->id
|
||||||
|
? $ticketLink->targetTicket
|
||||||
|
: $ticketLink->sourceTicket;
|
||||||
|
|
||||||
|
$label = $ticketLink->ticketLinkType->label;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($label, $linkedTicket, $request, $ticket, $ticketLink): void {
|
||||||
|
$ticketLink->delete();
|
||||||
|
|
||||||
|
$this->createTicketWorklogEntry->handle(
|
||||||
|
$ticket,
|
||||||
|
$request->user(),
|
||||||
|
'link_removed',
|
||||||
|
"Removed {$label} link to {$linkedTicket->ticket_key}.",
|
||||||
|
[
|
||||||
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
|
'ticket_link_type' => $label,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return to_route('projects.tickets.show', [$project, $ticket])
|
||||||
|
->with('status', "{$label} link was removed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: int, 1: int}
|
||||||
|
*/
|
||||||
|
private function normalizeTicketPair(int $firstTicketId, int $secondTicketId): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
min($firstTicketId, $secondTicketId),
|
||||||
|
max($firstTicketId, $secondTicketId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use App\Http\Requests\UpdateProjectNamedSettingRequest;
|
|||||||
use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
|
use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketFieldDefinition;
|
use App\Models\TicketFieldDefinition;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
use App\Models\TicketPriority;
|
use App\Models\TicketPriority;
|
||||||
use App\Models\TicketType;
|
use App\Models\TicketType;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -42,6 +43,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
'project' => $project,
|
'project' => $project,
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
|
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
|
||||||
|
'ticketLinkTypes' => $project->ticketLinkTypes()->get(),
|
||||||
'ticketPriorities' => $project->ticketPriorities()->get(),
|
'ticketPriorities' => $project->ticketPriorities()->get(),
|
||||||
'ticketTypes' => $project->ticketTypes()->get(),
|
'ticketTypes' => $project->ticketTypes()->get(),
|
||||||
]);
|
]);
|
||||||
@@ -249,6 +251,71 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
->with('status', "{$deletedLabel} field was removed.");
|
->with('status', "{$deletedLabel} field was removed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function storeLinkType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->abortUnlessCanManage($request, $project);
|
||||||
|
|
||||||
|
$ticketLinkType = DB::transaction(function () use ($project, $request): TicketLinkType {
|
||||||
|
$ticketLinkType = $project->ticketLinkTypes()->create([
|
||||||
|
'label' => $request->validated('name'),
|
||||||
|
'position' => $project->ticketLinkTypes()->count() + 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reorderModels(
|
||||||
|
$project->ticketLinkTypes()->whereKeyNot($ticketLinkType->id)->get()->values(),
|
||||||
|
$ticketLinkType,
|
||||||
|
$request->position() ?? $project->ticketLinkTypes()->count(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $ticketLinkType;
|
||||||
|
});
|
||||||
|
|
||||||
|
return to_route('projects.settings.metadata', $project)
|
||||||
|
->with('status', "{$ticketLinkType->label} link type was added.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLinkType(
|
||||||
|
UpdateProjectNamedSettingRequest $request,
|
||||||
|
Project $project,
|
||||||
|
TicketLinkType $ticketLinkType,
|
||||||
|
): RedirectResponse {
|
||||||
|
$this->abortUnlessCanManage($request, $project);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($project, $request, $ticketLinkType): void {
|
||||||
|
$ticketLinkType->update([
|
||||||
|
'label' => $request->validated('name'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reorderModels(
|
||||||
|
$project->ticketLinkTypes()->whereKeyNot($ticketLinkType->id)->get()->values(),
|
||||||
|
$ticketLinkType,
|
||||||
|
$request->position(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return to_route('projects.settings.metadata', $project)
|
||||||
|
->with('status', "{$ticketLinkType->fresh()->label} link type was updated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyLinkType(
|
||||||
|
Request $request,
|
||||||
|
Project $project,
|
||||||
|
TicketLinkType $ticketLinkType,
|
||||||
|
): RedirectResponse {
|
||||||
|
$this->abortUnlessCanManage($request, $project);
|
||||||
|
|
||||||
|
$deletedLabel = $ticketLinkType->label;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($project, $ticketLinkType): void {
|
||||||
|
$ticketLinkType->delete();
|
||||||
|
|
||||||
|
$this->persistOrderedModels($project->ticketLinkTypes()->get()->values());
|
||||||
|
});
|
||||||
|
|
||||||
|
return to_route('projects.settings.metadata', $project)
|
||||||
|
->with('status', "{$deletedLabel} link type was removed.");
|
||||||
|
}
|
||||||
|
|
||||||
private function abortUnlessCanManage(Request $request, Project $project): void
|
private function abortUnlessCanManage(Request $request, Project $project): void
|
||||||
{
|
{
|
||||||
abort_unless(
|
abort_unless(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class StoreTicketLinkRequest 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
|
||||||
|
{
|
||||||
|
/** @var Project $project */
|
||||||
|
$project = $this->route('project');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'linked_ticket_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('tickets', 'id')->where('project_id', $project->id),
|
||||||
|
],
|
||||||
|
'ticket_link_type_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, callable(Validator): void>
|
||||||
|
*/
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator): void {
|
||||||
|
/** @var Ticket $ticket */
|
||||||
|
$ticket = $this->route('ticket');
|
||||||
|
|
||||||
|
$linkedTicketId = $this->input('linked_ticket_id');
|
||||||
|
|
||||||
|
if (is_numeric($linkedTicketId) && (int) $linkedTicketId === $ticket->id) {
|
||||||
|
$validator->errors()->add('linked_ticket_id', 'A ticket cannot be linked to itself.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function linkedTicketId(): int
|
||||||
|
{
|
||||||
|
return (int) $this->validated('linked_ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketLinkTypeId(): int
|
||||||
|
{
|
||||||
|
return (int) $this->validated('ticket_link_type_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => ['nullable', 'string'],
|
'acceptance_criteria' => ['nullable', 'string'],
|
||||||
|
'blocked_comment' => ['nullable', 'string'],
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string'],
|
||||||
'field_values' => ['array'],
|
'field_values' => ['array'],
|
||||||
'field_values.*' => ['array'],
|
'field_values.*' => ['array'],
|
||||||
@@ -43,6 +44,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
],
|
],
|
||||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
|
'is_blocked' => ['required', 'boolean'],
|
||||||
'parent_ticket_id' => [
|
'parent_ticket_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
@@ -116,6 +118,10 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
) {
|
) {
|
||||||
$validator->errors()->add('ticket_type_id', 'Tickets with child tickets must keep a Parent type.');
|
$validator->errors()->add('ticket_type_id', 'Tickets with child tickets must keep a Parent type.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->boolean('is_blocked') && blank($this->input('blocked_comment'))) {
|
||||||
|
$validator->errors()->add('blocked_comment', 'Blocked tickets require a blocker comment.');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -123,7 +129,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* acceptance_criteria: string|null,
|
* acceptance_criteria: string|null,
|
||||||
|
* blocked_comment: string|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
|
* is_blocked: bool,
|
||||||
* parent_ticket_id: int|null,
|
* parent_ticket_id: int|null,
|
||||||
* ticket_priority_id: int,
|
* ticket_priority_id: int,
|
||||||
* ticket_type_id: int,
|
* ticket_type_id: int,
|
||||||
@@ -134,7 +142,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var array{
|
/** @var array{
|
||||||
* acceptance_criteria: string|null,
|
* acceptance_criteria: string|null,
|
||||||
|
* blocked_comment: string|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
|
* is_blocked: bool,
|
||||||
* parent_ticket_id: int|null,
|
* parent_ticket_id: int|null,
|
||||||
* ticket_priority_id: int,
|
* ticket_priority_id: int,
|
||||||
* ticket_type_id: int,
|
* ticket_type_id: int,
|
||||||
@@ -144,12 +154,16 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
$attributes = array_merge(
|
$attributes = array_merge(
|
||||||
[
|
[
|
||||||
'acceptance_criteria' => null,
|
'acceptance_criteria' => null,
|
||||||
|
'blocked_comment' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
|
'is_blocked' => false,
|
||||||
'parent_ticket_id' => null,
|
'parent_ticket_id' => null,
|
||||||
],
|
],
|
||||||
Arr::only($this->validated(), [
|
Arr::only($this->validated(), [
|
||||||
'acceptance_criteria',
|
'acceptance_criteria',
|
||||||
|
'blocked_comment',
|
||||||
'description',
|
'description',
|
||||||
|
'is_blocked',
|
||||||
'parent_ticket_id',
|
'parent_ticket_id',
|
||||||
'ticket_priority_id',
|
'ticket_priority_id',
|
||||||
'ticket_type_id',
|
'ticket_type_id',
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ class Project extends Model
|
|||||||
->orderBy('position');
|
->orderBy('position');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ticketLinkTypes(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketLinkType::class)
|
||||||
|
->orderBy('position');
|
||||||
|
}
|
||||||
|
|
||||||
public function ticketTypes(): HasMany
|
public function ticketTypes(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TicketType::class)
|
return $this->hasMany(TicketType::class)
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'acceptance_criteria',
|
'acceptance_criteria',
|
||||||
'board_column_id',
|
'board_column_id',
|
||||||
|
'blocked_comment',
|
||||||
'description',
|
'description',
|
||||||
|
'is_blocked',
|
||||||
'parent_ticket_id',
|
'parent_ticket_id',
|
||||||
'project_id',
|
'project_id',
|
||||||
'sequence_number',
|
'sequence_number',
|
||||||
@@ -26,6 +28,16 @@ class Ticket extends Model
|
|||||||
/** @use HasFactory<TicketFactory> */
|
/** @use HasFactory<TicketFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_blocked' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function boardColumn(): BelongsTo
|
public function boardColumn(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(BoardColumn::class);
|
return $this->belongsTo(BoardColumn::class);
|
||||||
@@ -48,6 +60,18 @@ class Ticket extends Model
|
|||||||
return $this->belongsTo(Ticket::class, 'parent_ticket_id');
|
return $this->belongsTo(Ticket::class, 'parent_ticket_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ticketLinksAsSource(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketLink::class, 'source_ticket_id')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketLinksAsTarget(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketLink::class, 'target_ticket_id')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class);
|
return $this->belongsTo(Project::class);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\TicketLinkFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable(['source_ticket_id', 'target_ticket_id', 'ticket_link_type_id'])]
|
||||||
|
class TicketLink extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<TicketLinkFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public function sourceTicket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class, 'source_ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetTicket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class, 'target_ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketLinkType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TicketLinkType::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\TicketLinkTypeFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
#[Fillable(['label', 'position', 'project_id'])]
|
||||||
|
class TicketLinkType extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public function project(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketLinks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketLink::class)
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,9 @@ class TicketFactory extends Factory
|
|||||||
return [
|
return [
|
||||||
'acceptance_criteria' => fake()->paragraph(),
|
'acceptance_criteria' => fake()->paragraph(),
|
||||||
'board_column_id' => null,
|
'board_column_id' => null,
|
||||||
|
'blocked_comment' => null,
|
||||||
'description' => fake()->paragraphs(2, true),
|
'description' => fake()->paragraphs(2, true),
|
||||||
|
'is_blocked' => false,
|
||||||
'parent_ticket_id' => null,
|
'parent_ticket_id' => null,
|
||||||
'sequence_number' => 1,
|
'sequence_number' => 1,
|
||||||
'ticket_key' => 'TEST-1',
|
'ticket_key' => 'TEST-1',
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketLink;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<TicketLink>
|
||||||
|
*/
|
||||||
|
class TicketLinkFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'source_ticket_id' => null,
|
||||||
|
'target_ticket_id' => null,
|
||||||
|
'ticket_link_type_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configure(): static
|
||||||
|
{
|
||||||
|
return $this->afterMaking(function (TicketLink $ticketLink): void {
|
||||||
|
$sourceTicket = $ticketLink->source_ticket_id === null
|
||||||
|
? Ticket::factory()->create()
|
||||||
|
: Ticket::query()->findOrFail($ticketLink->source_ticket_id);
|
||||||
|
|
||||||
|
$targetTicket = $ticketLink->target_ticket_id === null
|
||||||
|
? Ticket::factory()->create([
|
||||||
|
'project_id' => $sourceTicket->project_id,
|
||||||
|
'ticket_priority_id' => $sourceTicket->ticket_priority_id,
|
||||||
|
'ticket_type_id' => $sourceTicket->ticket_type_id,
|
||||||
|
'sequence_number' => $sourceTicket->sequence_number + 1,
|
||||||
|
'ticket_key' => "{$sourceTicket->project->key}-".($sourceTicket->sequence_number + 1),
|
||||||
|
])
|
||||||
|
: Ticket::query()->findOrFail($ticketLink->target_ticket_id);
|
||||||
|
|
||||||
|
if ($sourceTicket->id === $targetTicket->id) {
|
||||||
|
$targetTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $sourceTicket->project_id,
|
||||||
|
'ticket_priority_id' => $sourceTicket->ticket_priority_id,
|
||||||
|
'ticket_type_id' => $sourceTicket->ticket_type_id,
|
||||||
|
'sequence_number' => $sourceTicket->sequence_number + 1,
|
||||||
|
'ticket_key' => "{$sourceTicket->project->key}-".($sourceTicket->sequence_number + 1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketLinkType = $ticketLink->ticket_link_type_id === null
|
||||||
|
? TicketLinkType::query()
|
||||||
|
->where('project_id', $sourceTicket->project_id)
|
||||||
|
->orderBy('position')
|
||||||
|
->first()
|
||||||
|
?? TicketLinkType::factory()->for($sourceTicket->project)->create([
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
])
|
||||||
|
: TicketLinkType::query()->findOrFail($ticketLink->ticket_link_type_id);
|
||||||
|
|
||||||
|
$sortedIds = [$sourceTicket->id, $targetTicket->id];
|
||||||
|
sort($sortedIds);
|
||||||
|
|
||||||
|
$ticketLink->source_ticket_id = $sortedIds[0];
|
||||||
|
$ticketLink->target_ticket_id = $sortedIds[1];
|
||||||
|
$ticketLink->ticket_link_type_id = $ticketLinkType->id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<TicketLinkType>
|
||||||
|
*/
|
||||||
|
class TicketLinkTypeFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => fake()->unique()->words(2, true),
|
||||||
|
'position' => 1,
|
||||||
|
'project_id' => Project::factory(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->boolean('is_blocked')
|
||||||
|
->default(false)
|
||||||
|
->after('parent_ticket_id');
|
||||||
|
$table->text('blocked_comment')
|
||||||
|
->nullable()
|
||||||
|
->after('is_blocked');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['blocked_comment', 'is_blocked']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('ticket_link_types', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('label');
|
||||||
|
$table->unsignedInteger('position');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['project_id', 'label']);
|
||||||
|
$table->unique(['project_id', 'position']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ticket_link_types');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('ticket_links', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('ticket_link_type_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('source_ticket_id')->constrained('tickets')->cascadeOnDelete();
|
||||||
|
$table->foreignId('target_ticket_id')->constrained('tickets')->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['source_ticket_id', 'target_ticket_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ticket_links');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -19,6 +19,8 @@ class DatabaseSeeder extends Seeder
|
|||||||
ProjectTicketMetadataSeeder::class,
|
ProjectTicketMetadataSeeder::class,
|
||||||
TicketSeeder::class,
|
TicketSeeder::class,
|
||||||
TicketFieldValueSeeder::class,
|
TicketFieldValueSeeder::class,
|
||||||
|
TicketLinkTypeSeeder::class,
|
||||||
|
TicketLinkSeeder::class,
|
||||||
TicketWorklogEntrySeeder::class,
|
TicketWorklogEntrySeeder::class,
|
||||||
TeamSeeder::class,
|
TeamSeeder::class,
|
||||||
ProjectAccessSeeder::class,
|
ProjectAccessSeeder::class,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketLink;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class TicketLinkSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
Project::query()
|
||||||
|
->with(['tickets', 'ticketLinkTypes'])
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(function (Project $project): void {
|
||||||
|
/** @var Ticket|null $firstTicket */
|
||||||
|
$firstTicket = $project->tickets->sortBy('sequence_number')->values()->get(0);
|
||||||
|
/** @var Ticket|null $secondTicket */
|
||||||
|
$secondTicket = $project->tickets->sortBy('sequence_number')->values()->get(1);
|
||||||
|
/** @var TicketLinkType|null $ticketLinkType */
|
||||||
|
$ticketLinkType = $project->ticketLinkTypes->sortBy('position')->first();
|
||||||
|
|
||||||
|
if ($firstTicket === null || $secondTicket === null || $ticketLinkType === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TicketLink::factory()->create([
|
||||||
|
'source_ticket_id' => min($firstTicket->id, $secondTicket->id),
|
||||||
|
'target_ticket_id' => max($firstTicket->id, $secondTicket->id),
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class TicketLinkTypeSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
Project::query()
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(function (Project $project): void {
|
||||||
|
TicketLinkType::factory()
|
||||||
|
->count(2)
|
||||||
|
->sequence(
|
||||||
|
['label' => 'Relates to', 'position' => 1],
|
||||||
|
['label' => 'Blocks', 'position' => 2],
|
||||||
|
)
|
||||||
|
->for($project)
|
||||||
|
->create();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,13 +136,24 @@ const statusOptions = [
|
|||||||
<Card
|
<Card
|
||||||
v-for="ticket in boardColumn.tickets"
|
v-for="ticket in boardColumn.tickets"
|
||||||
:key="ticket.id"
|
:key="ticket.id"
|
||||||
class="gap-0"
|
:class="[
|
||||||
|
'gap-0',
|
||||||
|
ticket.is_blocked
|
||||||
|
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-4">
|
<CardHeader class="pb-4">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{{ ticket.ticket_key }}
|
{{ ticket.ticket_key }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="ticket.is_blocked"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Blocked
|
||||||
|
</Badge>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{{ ticket.ticket_type.name }}
|
{{ ticket.ticket_type.name }}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -169,6 +180,12 @@ const statusOptions = [
|
|||||||
>
|
>
|
||||||
{{ ticket.description }}
|
{{ ticket.description }}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
<CardDescription
|
||||||
|
v-if="ticket.is_blocked && ticket.blocked_comment"
|
||||||
|
class="mt-2 text-amber-900 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{{ ticket.blocked_comment }}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent v-if="canEditTickets" class="pt-0">
|
<CardContent v-if="canEditTickets" class="pt-0">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm, Head, Link } from '@inertiajs/vue3';
|
import { Form, useForm, Head, Link } from '@inertiajs/vue3';
|
||||||
|
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
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';
|
||||||
@@ -44,6 +45,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
acceptance_criteria: props.ticket.acceptance_criteria ?? '',
|
acceptance_criteria: props.ticket.acceptance_criteria ?? '',
|
||||||
|
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||||
description: props.ticket.description ?? '',
|
description: props.ticket.description ?? '',
|
||||||
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
|
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
|
||||||
const currentFieldValue = props.ticket.field_values?.find(
|
const currentFieldValue = props.ticket.field_values?.find(
|
||||||
@@ -58,6 +60,7 @@ const form = useForm({
|
|||||||
value_user_id: currentFieldValue?.value_user_id ?? '',
|
value_user_id: currentFieldValue?.value_user_id ?? '',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
is_blocked: props.ticket.is_blocked,
|
||||||
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
|
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
|
||||||
ticket_priority_id: props.ticket.ticket_priority_id,
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||||
ticket_type_id: props.ticket.ticket_type_id,
|
ticket_type_id: props.ticket.ticket_type_id,
|
||||||
@@ -284,6 +287,42 @@ const formatDateTime = (value: string): string => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 rounded-xl border border-border p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="is_blocked"
|
||||||
|
v-model="form.is_blocked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="
|
||||||
|
!canEditTicket || form.processing
|
||||||
|
"
|
||||||
|
class="size-4 rounded border-input text-primary focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<Label for="is_blocked">
|
||||||
|
Mark ticket as blocked
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="blocked_comment">
|
||||||
|
Blocker comment
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="blocked_comment"
|
||||||
|
v-model="form.blocked_comment"
|
||||||
|
rows="3"
|
||||||
|
:disabled="
|
||||||
|
!canEditTicket || form.processing
|
||||||
|
"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
placeholder="Describe what currently blocks progress on this ticket."
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="form.errors.blocked_comment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
@@ -388,6 +427,155 @@ const formatDateTime = (value: string): string => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<Card class="h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Linked tickets</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Track cross-ticket relationships with
|
||||||
|
project-defined link labels.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<Form
|
||||||
|
v-if="
|
||||||
|
canEditTicket &&
|
||||||
|
ticketLinkTypes.length > 0 &&
|
||||||
|
ticketLinkOptions.length > 0
|
||||||
|
"
|
||||||
|
v-bind="
|
||||||
|
ProjectTicketLinkController.store.form({
|
||||||
|
project: project.id,
|
||||||
|
ticket: ticket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="grid gap-4 rounded-xl border border-dashed border-border p-4"
|
||||||
|
v-slot="{ errors, processing }"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="ticket_link_type_id">
|
||||||
|
Link type
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="ticket_link_type_id"
|
||||||
|
name="ticket_link_type_id"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="ticketLinkType in ticketLinkTypes"
|
||||||
|
:key="ticketLinkType.id"
|
||||||
|
:value="ticketLinkType.id"
|
||||||
|
>
|
||||||
|
{{ ticketLinkType.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="errors.ticket_link_type_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="linked_ticket_id">
|
||||||
|
Linked ticket
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="linked_ticket_id"
|
||||||
|
name="linked_ticket_id"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="ticketLinkOption in ticketLinkOptions"
|
||||||
|
:key="ticketLinkOption.id"
|
||||||
|
:value="ticketLinkOption.id"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="errors.linked_ticket_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Save link
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="linkedTickets.length === 0"
|
||||||
|
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No ticket links are configured for this ticket
|
||||||
|
yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="linkedTicket in linkedTickets"
|
||||||
|
:key="linkedTicket.id"
|
||||||
|
class="rounded-xl border border-border px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{
|
||||||
|
linkedTicket.ticket_link_type
|
||||||
|
.label
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
showTicket({
|
||||||
|
project: project.id,
|
||||||
|
ticket: linkedTicket
|
||||||
|
.linked_ticket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="font-medium text-foreground transition hover:text-primary"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
linkedTicket.linked_ticket
|
||||||
|
.ticket_key
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
{{ linkedTicket.linked_ticket.title }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
v-if="canEditTicket"
|
||||||
|
v-bind="
|
||||||
|
ProjectTicketLinkController.destroy.form(
|
||||||
|
{
|
||||||
|
project: project.id,
|
||||||
|
ticket: ticket.id,
|
||||||
|
ticketLink:
|
||||||
|
linkedTicket.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="mt-3 flex justify-end"
|
||||||
|
v-slot="{ processing: deleting }"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
:disabled="deleting"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card class="h-fit">
|
<Card class="h-fit">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Child tickets</CardTitle>
|
<CardTitle>Child tickets</CardTitle>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
</Badge>
|
</Badge>
|
||||||
<Heading
|
<Heading
|
||||||
:title="`${project.name} ticket settings`"
|
:title="`${project.name} ticket settings`"
|
||||||
description="Configure project-specific ticket types, priorities, and custom field definitions."
|
description="Configure project-specific ticket types, priorities, custom field definitions, and ticket link labels."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-3">
|
<div class="grid gap-6 xl:grid-cols-2">
|
||||||
<Card class="gap-0 overflow-hidden">
|
<Card class="gap-0 overflow-hidden">
|
||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
<CardTitle>Ticket types</CardTitle>
|
<CardTitle>Ticket types</CardTitle>
|
||||||
@@ -497,6 +497,124 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card class="gap-0 overflow-hidden">
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<CardTitle>Link types</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Define project-specific labels for ticket-to-ticket
|
||||||
|
relationships.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4 p-6">
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
ProjectTicketMetadataSettingsController.storeLinkType.form(
|
||||||
|
project.id,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="space-y-4 rounded-xl border border-dashed border-border p-4"
|
||||||
|
v-slot="{ errors, processing }"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="link-type-name">
|
||||||
|
New link type name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="link-type-name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
placeholder="Relates to"
|
||||||
|
/>
|
||||||
|
<InputError :message="errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" :disabled="processing">
|
||||||
|
Add link type
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="ticketLinkType in ticketLinkTypes"
|
||||||
|
:key="ticketLinkType.id"
|
||||||
|
class="rounded-xl border border-border p-4"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
ProjectTicketMetadataSettingsController.updateLinkType.form(
|
||||||
|
[project.id, ticketLinkType.id],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="grid gap-4"
|
||||||
|
v-slot="{ errors, processing }"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`link-type-name-${ticketLinkType.id}`"
|
||||||
|
>
|
||||||
|
Label
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
:id="`link-type-name-${ticketLinkType.id}`"
|
||||||
|
name="name"
|
||||||
|
:value="ticketLinkType.label"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<InputError :message="errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`link-type-position-${ticketLinkType.id}`"
|
||||||
|
>
|
||||||
|
Position
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
:id="`link-type-position-${ticketLinkType.id}`"
|
||||||
|
name="position"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="ticketLinkTypes.length"
|
||||||
|
:value="ticketLinkType.position"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<InputError :message="errors.position" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap justify-between gap-3"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
ProjectTicketMetadataSettingsController.destroyLinkType.form(
|
||||||
|
[project.id, ticketLinkType.id],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="mt-3 flex justify-end"
|
||||||
|
v-slot="{ processing: deleting }"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
:disabled="deleting"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||||
|
import type { TicketReferenceModel } from '@/types/models/ticket-reference-model';
|
||||||
|
|
||||||
|
export type TicketLinkModel = {
|
||||||
|
id: number;
|
||||||
|
linked_ticket: TicketReferenceModel;
|
||||||
|
source_ticket_id: number;
|
||||||
|
target_ticket_id: number;
|
||||||
|
ticket_link_type: TicketLinkTypeModel;
|
||||||
|
ticket_link_type_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type TicketLinkTypeModel = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
position: number;
|
||||||
|
project_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
@@ -7,8 +7,10 @@ export type TicketModel = {
|
|||||||
id: number;
|
id: number;
|
||||||
acceptance_criteria: string | null;
|
acceptance_criteria: string | null;
|
||||||
board_column_id: number | null;
|
board_column_id: number | null;
|
||||||
|
blocked_comment: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
field_values?: TicketFieldValueModel[];
|
field_values?: TicketFieldValueModel[];
|
||||||
|
is_blocked: boolean;
|
||||||
parent_ticket_id: number | null;
|
parent_ticket_id: number | null;
|
||||||
project_id: number;
|
project_id: number;
|
||||||
sequence_number: number;
|
sequence_number: number;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type TicketReferenceModel = {
|
||||||
|
id: number;
|
||||||
|
ticket_key: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
@@ -2,6 +2,9 @@ import type { BoardColumnModel } from '@/types/models/board-column-model';
|
|||||||
import type { ProjectModel } from '@/types/models/project-model';
|
import type { ProjectModel } from '@/types/models/project-model';
|
||||||
import type { TeamModel } from '@/types/models/team-model';
|
import type { TeamModel } from '@/types/models/team-model';
|
||||||
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||||
|
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
|
||||||
|
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||||
|
import type { TicketReferenceModel } from '@/types/models/ticket-reference-model';
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||||
@@ -66,6 +69,7 @@ export type ProjectTicketMetadataSettingsPageProps = {
|
|||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
||||||
|
ticketLinkTypes: TicketLinkTypeModel[];
|
||||||
ticketPriorities: TicketPriorityModel[];
|
ticketPriorities: TicketPriorityModel[];
|
||||||
ticketTypes: TicketTypeModel[];
|
ticketTypes: TicketTypeModel[];
|
||||||
};
|
};
|
||||||
@@ -84,15 +88,14 @@ export type ProjectBacklogPageProps = {
|
|||||||
export type ProjectTicketPageProps = {
|
export type ProjectTicketPageProps = {
|
||||||
canEditTicket: boolean;
|
canEditTicket: boolean;
|
||||||
childTicketTree: TicketTreeNodeModel[];
|
childTicketTree: TicketTreeNodeModel[];
|
||||||
parentTicketOptions: Array<{
|
linkedTickets: TicketLinkModel[];
|
||||||
id: number;
|
parentTicketOptions: TicketReferenceModel[];
|
||||||
ticket_key: string;
|
|
||||||
title: string;
|
|
||||||
}>;
|
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
ticket: TicketModel;
|
ticket: TicketModel;
|
||||||
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
||||||
|
ticketLinkOptions: TicketReferenceModel[];
|
||||||
|
ticketLinkTypes: TicketLinkTypeModel[];
|
||||||
ticketPriorities: TicketPriorityModel[];
|
ticketPriorities: TicketPriorityModel[];
|
||||||
ticketTypes: TicketTypeModel[];
|
ticketTypes: TicketTypeModel[];
|
||||||
users: UserModel[];
|
users: UserModel[];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\ProjectBoardController;
|
|||||||
use App\Http\Controllers\ProjectBoardSettingsController;
|
use App\Http\Controllers\ProjectBoardSettingsController;
|
||||||
use App\Http\Controllers\ProjectController;
|
use App\Http\Controllers\ProjectController;
|
||||||
use App\Http\Controllers\ProjectTicketController;
|
use App\Http\Controllers\ProjectTicketController;
|
||||||
|
use App\Http\Controllers\ProjectTicketLinkController;
|
||||||
use App\Http\Controllers\ProjectTicketMetadataSettingsController;
|
use App\Http\Controllers\ProjectTicketMetadataSettingsController;
|
||||||
use App\Http\Controllers\TeamController;
|
use App\Http\Controllers\TeamController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -39,6 +40,11 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
->name('projects.tickets.show');
|
->name('projects.tickets.show');
|
||||||
Route::patch('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'update'])
|
Route::patch('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'update'])
|
||||||
->name('projects.tickets.update');
|
->name('projects.tickets.update');
|
||||||
|
Route::post('projects/{project}/tickets/{ticket}/links', [ProjectTicketLinkController::class, 'store'])
|
||||||
|
->name('projects.tickets.links.store');
|
||||||
|
Route::delete('projects/{project}/tickets/{ticket}/links/{ticketLink}', [ProjectTicketLinkController::class, 'destroy'])
|
||||||
|
->withoutScopedBindings()
|
||||||
|
->name('projects.tickets.links.destroy');
|
||||||
Route::patch('projects/{project}/tickets/{ticket}/status', [ProjectBoardController::class, 'move'])
|
Route::patch('projects/{project}/tickets/{ticket}/status', [ProjectBoardController::class, 'move'])
|
||||||
->name('projects.tickets.status.update');
|
->name('projects.tickets.status.update');
|
||||||
Route::get('projects/{project}/settings/board', [ProjectBoardSettingsController::class, 'show'])
|
Route::get('projects/{project}/settings/board', [ProjectBoardSettingsController::class, 'show'])
|
||||||
@@ -69,6 +75,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
->name('projects.settings.metadata.fields.update');
|
->name('projects.settings.metadata.fields.update');
|
||||||
Route::delete('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'destroyFieldDefinition'])
|
Route::delete('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'destroyFieldDefinition'])
|
||||||
->name('projects.settings.metadata.fields.destroy');
|
->name('projects.settings.metadata.fields.destroy');
|
||||||
|
Route::post('projects/{project}/settings/metadata/link-types', [ProjectTicketMetadataSettingsController::class, 'storeLinkType'])
|
||||||
|
->name('projects.settings.metadata.link-types.store');
|
||||||
|
Route::patch('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'updateLinkType'])
|
||||||
|
->name('projects.settings.metadata.link-types.update');
|
||||||
|
Route::delete('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'destroyLinkType'])
|
||||||
|
->name('projects.settings.metadata.link-types.destroy');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Models;
|
||||||
|
|
||||||
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketLink;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use App\Models\TicketPriority;
|
||||||
|
use Database\Seeders\ProjectSeeder;
|
||||||
|
use Database\Seeders\ProjectTicketMetadataSeeder;
|
||||||
|
use Database\Seeders\TicketLinkSeeder;
|
||||||
|
use Database\Seeders\TicketLinkTypeSeeder;
|
||||||
|
use Database\Seeders\TicketSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TicketLinkTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the ticket link factory normalizes the stored source and target ticket ids.
|
||||||
|
*/
|
||||||
|
public function test_ticket_link_factory_normalizes_ticket_order(): void
|
||||||
|
{
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
|
'name' => 'Medium',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||||
|
|
||||||
|
$firstTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
$secondTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'CORE-2',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticketLink = TicketLink::factory()->create([
|
||||||
|
'source_ticket_id' => $secondTicket->id,
|
||||||
|
'target_ticket_id' => $firstTicket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame($firstTicket->id, $ticketLink->source_ticket_id);
|
||||||
|
$this->assertSame($secondTicket->id, $ticketLink->target_ticket_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the ticket link seeder creates dummy links for seeded projects.
|
||||||
|
*/
|
||||||
|
public function test_ticket_link_seeder_creates_dummy_links_for_seeded_projects(): void
|
||||||
|
{
|
||||||
|
$this->seed([
|
||||||
|
ProjectSeeder::class,
|
||||||
|
ProjectTicketMetadataSeeder::class,
|
||||||
|
TicketSeeder::class,
|
||||||
|
TicketLinkTypeSeeder::class,
|
||||||
|
TicketLinkSeeder::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||||
|
$seededTickets = Ticket::query()
|
||||||
|
->where('project_id', $coreProject->id)
|
||||||
|
->orderBy('sequence_number')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_links', [
|
||||||
|
'source_ticket_id' => $seededTickets[0]->id,
|
||||||
|
'target_ticket_id' => $seededTickets[1]->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Models;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use Database\Seeders\ProjectSeeder;
|
||||||
|
use Database\Seeders\TicketLinkTypeSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TicketLinkTypeTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the ticket link type factory creates project-scoped labels.
|
||||||
|
*/
|
||||||
|
public function test_ticket_link_type_factory_creates_project_scoped_labels(): void
|
||||||
|
{
|
||||||
|
$ticketLinkType = TicketLinkType::factory()->create();
|
||||||
|
|
||||||
|
$this->assertNotNull($ticketLinkType->project_id);
|
||||||
|
$this->assertIsString($ticketLinkType->label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the ticket link type seeder creates default labels for seeded projects.
|
||||||
|
*/
|
||||||
|
public function test_ticket_link_type_seeder_creates_default_labels_for_projects(): void
|
||||||
|
{
|
||||||
|
$this->seed([
|
||||||
|
ProjectSeeder::class,
|
||||||
|
TicketLinkTypeSeeder::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_link_types', [
|
||||||
|
'project_id' => $coreProject->id,
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_link_types', [
|
||||||
|
'project_id' => $coreProject->id,
|
||||||
|
'label' => 'Blocks',
|
||||||
|
'position' => 2,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ use App\Models\Project;
|
|||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketFieldDefinition;
|
use App\Models\TicketFieldDefinition;
|
||||||
use App\Models\TicketFieldValue;
|
use App\Models\TicketFieldValue;
|
||||||
|
use App\Models\TicketLink;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
use App\Models\TicketPriority;
|
use App\Models\TicketPriority;
|
||||||
use App\Models\TicketWorklogEntry;
|
use App\Models\TicketWorklogEntry;
|
||||||
use Database\Seeders\BoardColumnSeeder;
|
use Database\Seeders\BoardColumnSeeder;
|
||||||
@@ -165,4 +167,72 @@ class TicketTest extends TestCase
|
|||||||
$this->assertSame($parentTicket->id, $childTicket->fresh()->parentTicket?->id);
|
$this->assertSame($parentTicket->id, $childTicket->fresh()->parentTicket?->id);
|
||||||
$this->assertSame([$childTicket->id], $parentTicket->fresh()->childTickets->modelKeys());
|
$this->assertSame([$childTicket->id], $parentTicket->fresh()->childTickets->modelKeys());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the blocked flag is cast to a boolean for board and detail rendering.
|
||||||
|
*/
|
||||||
|
public function test_ticket_blocked_state_is_cast_to_boolean(): void
|
||||||
|
{
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'blocked_comment' => 'Waiting for environment access.',
|
||||||
|
'is_blocked' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$freshTicket = $ticket->fresh();
|
||||||
|
|
||||||
|
$this->assertTrue($freshTicket->is_blocked);
|
||||||
|
$this->assertSame('Waiting for environment access.', $freshTicket->blocked_comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure tickets expose incoming and outgoing link relations.
|
||||||
|
*/
|
||||||
|
public function test_ticket_link_relations_are_available(): void
|
||||||
|
{
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
|
'name' => 'Medium',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||||
|
|
||||||
|
$sourceTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
$targetTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'CORE-2',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticketLink = TicketLink::factory()->create([
|
||||||
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
|
'target_ticket_id' => $targetTicket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
[$ticketLink->id],
|
||||||
|
$sourceTicket->fresh()->ticketLinksAsSource->modelKeys(),
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
[$ticketLink->id],
|
||||||
|
$targetTicket->fresh()->ticketLinksAsTarget->modelKeys(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use App\Models\Project;
|
|||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketFieldDefinition;
|
use App\Models\TicketFieldDefinition;
|
||||||
use App\Models\TicketFieldValue;
|
use App\Models\TicketFieldValue;
|
||||||
|
use App\Models\TicketLink;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
use App\Models\TicketPriority;
|
use App\Models\TicketPriority;
|
||||||
use App\Models\TicketWorklogEntry;
|
use App\Models\TicketWorklogEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -40,6 +42,10 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'label' => 'Story Points',
|
'label' => 'Story Points',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
$ticket = Ticket::factory()->create([
|
$ticket = Ticket::factory()->create([
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
@@ -49,6 +55,14 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'title' => 'Ticket detail test',
|
'title' => 'Ticket detail test',
|
||||||
]);
|
]);
|
||||||
|
$linkedTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'CORE-2',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
|
'title' => 'Linked ticket',
|
||||||
|
]);
|
||||||
|
|
||||||
TicketFieldValue::factory()->create([
|
TicketFieldValue::factory()->create([
|
||||||
'ticket_field_definition_id' => $fieldDefinition->id,
|
'ticket_field_definition_id' => $fieldDefinition->id,
|
||||||
@@ -56,6 +70,11 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'value_number' => 5,
|
'value_number' => 5,
|
||||||
'value_user_id' => null,
|
'value_user_id' => null,
|
||||||
]);
|
]);
|
||||||
|
TicketLink::factory()->create([
|
||||||
|
'source_ticket_id' => $ticket->id,
|
||||||
|
'target_ticket_id' => $linkedTicket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
]);
|
||||||
|
|
||||||
TicketWorklogEntry::factory()->create([
|
TicketWorklogEntry::factory()->create([
|
||||||
'actor_id' => $viewer->id,
|
'actor_id' => $viewer->id,
|
||||||
@@ -76,6 +95,8 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
->where('ticket.field_values.0.ticket_field_definition.label', 'Story Points')
|
->where('ticket.field_values.0.ticket_field_definition.label', 'Story Points')
|
||||||
->where('ticket.field_values.0.value_number', 5)
|
->where('ticket.field_values.0.value_number', 5)
|
||||||
->where('ticket.worklog_entries.0.actor.name', $viewer->name)
|
->where('ticket.worklog_entries.0.actor.name', $viewer->name)
|
||||||
|
->where('linkedTickets.0.linked_ticket.ticket_key', 'CORE-2')
|
||||||
|
->where('linkedTickets.0.ticket_link_type.label', 'Relates to')
|
||||||
->where('canEditTicket', false),
|
->where('canEditTicket', false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,6 +129,9 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'title' => 'Updated title',
|
'title' => 'Updated title',
|
||||||
'description' => 'Updated description',
|
'description' => 'Updated description',
|
||||||
'acceptance_criteria' => 'Updated criteria',
|
'acceptance_criteria' => 'Updated criteria',
|
||||||
|
'blocked_comment' => $ticket->blocked_comment,
|
||||||
|
'is_blocked' => $ticket->is_blocked,
|
||||||
|
'parent_ticket_id' => $ticket->parent_ticket_id,
|
||||||
'ticket_type_id' => $ticket->ticket_type_id,
|
'ticket_type_id' => $ticket->ticket_type_id,
|
||||||
'ticket_priority_id' => $ticket->ticket_priority_id,
|
'ticket_priority_id' => $ticket->ticket_priority_id,
|
||||||
'field_values' => [],
|
'field_values' => [],
|
||||||
@@ -186,6 +210,9 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'title' => 'Updated title',
|
'title' => 'Updated title',
|
||||||
'description' => 'Updated description',
|
'description' => 'Updated description',
|
||||||
'acceptance_criteria' => 'Updated acceptance criteria',
|
'acceptance_criteria' => 'Updated acceptance criteria',
|
||||||
|
'blocked_comment' => $ticket->blocked_comment,
|
||||||
|
'is_blocked' => $ticket->is_blocked,
|
||||||
|
'parent_ticket_id' => $ticket->parent_ticket_id,
|
||||||
'ticket_type_id' => $parentType->id,
|
'ticket_type_id' => $parentType->id,
|
||||||
'ticket_priority_id' => $highPriority->id,
|
'ticket_priority_id' => $highPriority->id,
|
||||||
'field_values' => [
|
'field_values' => [
|
||||||
@@ -276,6 +303,8 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'title' => $childTicket->title,
|
'title' => $childTicket->title,
|
||||||
'description' => $childTicket->description,
|
'description' => $childTicket->description,
|
||||||
'acceptance_criteria' => $childTicket->acceptance_criteria,
|
'acceptance_criteria' => $childTicket->acceptance_criteria,
|
||||||
|
'blocked_comment' => $childTicket->blocked_comment,
|
||||||
|
'is_blocked' => $childTicket->is_blocked,
|
||||||
'parent_ticket_id' => $candidateParent->id,
|
'parent_ticket_id' => $candidateParent->id,
|
||||||
'ticket_type_id' => $childTicket->ticket_type_id,
|
'ticket_type_id' => $childTicket->ticket_type_id,
|
||||||
'ticket_priority_id' => $childTicket->ticket_priority_id,
|
'ticket_priority_id' => $childTicket->ticket_priority_id,
|
||||||
@@ -330,6 +359,8 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'title' => $childTicket->title,
|
'title' => $childTicket->title,
|
||||||
'description' => $childTicket->description,
|
'description' => $childTicket->description,
|
||||||
'acceptance_criteria' => $childTicket->acceptance_criteria,
|
'acceptance_criteria' => $childTicket->acceptance_criteria,
|
||||||
|
'blocked_comment' => $childTicket->blocked_comment,
|
||||||
|
'is_blocked' => $childTicket->is_blocked,
|
||||||
'parent_ticket_id' => $parentTicket->id,
|
'parent_ticket_id' => $parentTicket->id,
|
||||||
'ticket_type_id' => $childTicket->ticket_type_id,
|
'ticket_type_id' => $childTicket->ticket_type_id,
|
||||||
'ticket_priority_id' => $childTicket->ticket_priority_id,
|
'ticket_priority_id' => $childTicket->ticket_priority_id,
|
||||||
@@ -422,4 +453,134 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
->where('childTicketTree.1.ticket_key', $directTask->ticket_key),
|
->where('childTicketTree.1.ticket_key', $directTask->ticket_key),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure blocked tickets require a blocker comment.
|
||||||
|
*/
|
||||||
|
public function test_blocked_tickets_require_a_blocker_comment_when_updating(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
|
'name' => 'Medium',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($editor)
|
||||||
|
->from(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
'title' => $ticket->title,
|
||||||
|
'description' => $ticket->description,
|
||||||
|
'acceptance_criteria' => $ticket->acceptance_criteria,
|
||||||
|
'ticket_type_id' => $ticket->ticket_type_id,
|
||||||
|
'ticket_priority_id' => $ticket->ticket_priority_id,
|
||||||
|
'parent_ticket_id' => $ticket->parent_ticket_id,
|
||||||
|
'is_blocked' => true,
|
||||||
|
'blocked_comment' => '',
|
||||||
|
'field_values' => [],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->assertSessionHasErrors('blocked_comment');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'id' => $ticket->id,
|
||||||
|
'is_blocked' => false,
|
||||||
|
'blocked_comment' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure blocking and unblocking a ticket persists the state and logs both changes.
|
||||||
|
*/
|
||||||
|
public function test_users_with_edit_permission_can_block_and_unblock_tickets(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
|
'name' => 'Medium',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($editor)
|
||||||
|
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
'title' => $ticket->title,
|
||||||
|
'description' => $ticket->description,
|
||||||
|
'acceptance_criteria' => $ticket->acceptance_criteria,
|
||||||
|
'ticket_type_id' => $ticket->ticket_type_id,
|
||||||
|
'ticket_priority_id' => $ticket->ticket_priority_id,
|
||||||
|
'parent_ticket_id' => $ticket->parent_ticket_id,
|
||||||
|
'is_blocked' => true,
|
||||||
|
'blocked_comment' => 'Waiting for dependency approval.',
|
||||||
|
'field_values' => [],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->assertSessionHas('status', 'CORE-1 was updated.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'id' => $ticket->id,
|
||||||
|
'is_blocked' => true,
|
||||||
|
'blocked_comment' => 'Waiting for dependency approval.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'actor_id' => $editor->id,
|
||||||
|
'entry_type' => 'updated',
|
||||||
|
'summary' => 'Updated blocker comment and blocked state.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($editor)
|
||||||
|
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
'title' => $ticket->title,
|
||||||
|
'description' => $ticket->description,
|
||||||
|
'acceptance_criteria' => $ticket->acceptance_criteria,
|
||||||
|
'ticket_type_id' => $ticket->ticket_type_id,
|
||||||
|
'ticket_priority_id' => $ticket->ticket_priority_id,
|
||||||
|
'parent_ticket_id' => $ticket->parent_ticket_id,
|
||||||
|
'is_blocked' => false,
|
||||||
|
'blocked_comment' => '',
|
||||||
|
'field_values' => [],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->assertSessionHas('status', 'CORE-1 was updated.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'id' => $ticket->id,
|
||||||
|
'is_blocked' => false,
|
||||||
|
'blocked_comment' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
2,
|
||||||
|
TicketWorklogEntry::query()
|
||||||
|
->where('ticket_id', $ticket->id)
|
||||||
|
->where('entry_type', 'updated')
|
||||||
|
->where('summary', 'Updated blocker comment and blocked state.')
|
||||||
|
->count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||||
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketLink;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use App\Models\TicketPriority;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectTicketLinkControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure users with edit permission can link tickets and create a worklog entry.
|
||||||
|
*/
|
||||||
|
public function test_users_with_edit_permission_can_link_tickets(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
|
'name' => 'Medium',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||||
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
$linkedTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'CORE-2',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
||||||
|
'linked_ticket_id' => $linkedTicket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->assertSessionHas('status', 'Relates to link was saved.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_links', [
|
||||||
|
'source_ticket_id' => min($ticket->id, $linkedTicket->id),
|
||||||
|
'target_ticket_id' => max($ticket->id, $linkedTicket->id),
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'actor_id' => $user->id,
|
||||||
|
'entry_type' => 'link_updated',
|
||||||
|
'summary' => 'Linked CORE-2 as Relates to.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure ticket links cannot target the current ticket or a ticket from another project.
|
||||||
|
*/
|
||||||
|
public function test_ticket_links_must_target_other_tickets_within_the_same_project(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$otherProject = Project::factory()->create();
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
||||||
|
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
|
'name' => 'Medium',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$otherPriority = TicketPriority::factory()->for($otherProject)->create([
|
||||||
|
'name' => 'Medium',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||||
|
$otherTaskType = $otherProject->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||||
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'APP-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
$foreignTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $otherProject->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'OPS-1',
|
||||||
|
'ticket_priority_id' => $otherPriority->id,
|
||||||
|
'ticket_type_id' => $otherTaskType->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
||||||
|
'linked_ticket_id' => $ticket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->assertSessionHasErrors('linked_ticket_id');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
||||||
|
'linked_ticket_id' => $foreignTicket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->assertSessionHasErrors('linked_ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure users with edit permission can remove ticket links.
|
||||||
|
*/
|
||||||
|
public function test_users_with_edit_permission_can_remove_ticket_links(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create([
|
||||||
|
'name' => 'Medium',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
|
||||||
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
$linkedTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'CORE-2',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
$ticketLink = TicketLink::factory()->create([
|
||||||
|
'source_ticket_id' => min($ticket->id, $linkedTicket->id),
|
||||||
|
'target_ticket_id' => max($ticket->id, $linkedTicket->id),
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->delete(route('projects.tickets.links.destroy', [$project, $ticket, $ticketLink]))
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->assertSessionHas('status', 'Relates to link was removed.');
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('ticket_links', [
|
||||||
|
'id' => $ticketLink->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'actor_id' => $user->id,
|
||||||
|
'entry_type' => 'link_removed',
|
||||||
|
'summary' => 'Removed Relates to link to CORE-2.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Projects;
|
|||||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketFieldDefinition;
|
use App\Models\TicketFieldDefinition;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
use App\Models\TicketPriority;
|
use App\Models\TicketPriority;
|
||||||
use App\Models\TicketType;
|
use App\Models\TicketType;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -27,6 +28,10 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
'name' => 'Bug',
|
'name' => 'Bug',
|
||||||
'position' => 3,
|
'position' => 3,
|
||||||
]);
|
]);
|
||||||
|
TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
@@ -37,7 +42,8 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
->component('projects/settings/Metadata')
|
->component('projects/settings/Metadata')
|
||||||
->where('ticketTypes.0.name', 'Task')
|
->where('ticketTypes.0.name', 'Task')
|
||||||
->where('ticketTypes.1.name', 'Parent')
|
->where('ticketTypes.1.name', 'Parent')
|
||||||
->where('ticketTypes.2.name', 'Bug'),
|
->where('ticketTypes.2.name', 'Bug')
|
||||||
|
->where('ticketLinkTypes.0.label', 'Relates to'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,4 +214,43 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
->assertSessionHasErrors('field_type')
|
->assertSessionHasErrors('field_type')
|
||||||
->assertRedirect(route('projects.settings.metadata', $project));
|
->assertRedirect(route('projects.settings.metadata', $project));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure users with edit permission can manage ticket link types.
|
||||||
|
*/
|
||||||
|
public function test_users_with_edit_permission_can_manage_ticket_link_types(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Relates to',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('projects.settings.metadata.link-types.store', $project), [
|
||||||
|
'name' => 'Blocks',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.settings.metadata', $project))
|
||||||
|
->assertSessionHas('status', 'Blocks link type was added.');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('projects.settings.metadata.link-types.update', [$project, $ticketLinkType]), [
|
||||||
|
'name' => 'Depends on',
|
||||||
|
'position' => 2,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.settings.metadata', $project))
|
||||||
|
->assertSessionHas('status', 'Depends on link type was updated.');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType]))
|
||||||
|
->assertRedirect(route('projects.settings.metadata', $project))
|
||||||
|
->assertSessionHas('status', 'Depends on link type was removed.');
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('ticket_link_types', [
|
||||||
|
'id' => $ticketLinkType->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user