Implement ticket links and blocker state
This commit is contained in:
@@ -18,9 +18,12 @@ class UpdateProjectTicket
|
||||
/**
|
||||
* @param array{
|
||||
* acceptance_criteria: string|null,
|
||||
* blocked_comment: string|null,
|
||||
* description: string|null,
|
||||
* is_blocked: bool,
|
||||
* ticket_priority_id: int,
|
||||
* ticket_type_id: int,
|
||||
* parent_ticket_id: int|null,
|
||||
* title: string
|
||||
* } $ticketAttributes
|
||||
* @param array<int, array{
|
||||
@@ -36,6 +39,10 @@ class UpdateProjectTicket
|
||||
array $fieldValueAttributes,
|
||||
): bool {
|
||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $ticket, $ticketAttributes): bool {
|
||||
if (! $ticketAttributes['is_blocked']) {
|
||||
$ticketAttributes['blocked_comment'] = null;
|
||||
}
|
||||
|
||||
$ticket->loadMissing([
|
||||
'fieldValues.ticketFieldDefinition',
|
||||
'fieldValues.userValue',
|
||||
@@ -88,6 +95,14 @@ class UpdateProjectTicket
|
||||
$ticket->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']) {
|
||||
$changes['ticket_type'] = [
|
||||
@@ -105,6 +120,14 @@ class UpdateProjectTicket
|
||||
$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']) {
|
||||
$newParentTicket = $ticketAttributes['parent_ticket_id'] === null
|
||||
? null
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Http\Requests\UpdateProjectTicketRequest;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketLink;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -32,6 +33,7 @@ class ProjectTicketController extends Controller
|
||||
return Inertia::render('projects/Ticket', [
|
||||
'canEditTicket' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
|
||||
'childTicketTree' => $this->buildChildTicketTree($ticket),
|
||||
'linkedTickets' => $this->linkedTickets($ticket),
|
||||
'parentTicketOptions' => Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->whereNotIn('id', $excludedParentIds)
|
||||
@@ -54,6 +56,17 @@ class ProjectTicketController extends Controller
|
||||
'worklogEntries.actor',
|
||||
]),
|
||||
'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(),
|
||||
'ticketTypes' => $project->ticketTypes()->get(),
|
||||
'users' => User::query()
|
||||
@@ -124,4 +137,50 @@ class ProjectTicketController extends Controller
|
||||
->values()
|
||||
->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\Models\Project;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -42,6 +43,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
'project' => $project,
|
||||
'status' => session('status'),
|
||||
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
|
||||
'ticketLinkTypes' => $project->ticketLinkTypes()->get(),
|
||||
'ticketPriorities' => $project->ticketPriorities()->get(),
|
||||
'ticketTypes' => $project->ticketTypes()->get(),
|
||||
]);
|
||||
@@ -249,6 +251,71 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
->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
|
||||
{
|
||||
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 [
|
||||
'acceptance_criteria' => ['nullable', 'string'],
|
||||
'blocked_comment' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'field_values' => ['array'],
|
||||
'field_values.*' => ['array'],
|
||||
@@ -43,6 +44,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
],
|
||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'is_blocked' => ['required', 'boolean'],
|
||||
'parent_ticket_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
@@ -116,6 +118,10 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
) {
|
||||
$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{
|
||||
* acceptance_criteria: string|null,
|
||||
* blocked_comment: string|null,
|
||||
* description: string|null,
|
||||
* is_blocked: bool,
|
||||
* parent_ticket_id: int|null,
|
||||
* ticket_priority_id: int,
|
||||
* ticket_type_id: int,
|
||||
@@ -134,7 +142,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
{
|
||||
/** @var array{
|
||||
* acceptance_criteria: string|null,
|
||||
* blocked_comment: string|null,
|
||||
* description: string|null,
|
||||
* is_blocked: bool,
|
||||
* parent_ticket_id: int|null,
|
||||
* ticket_priority_id: int,
|
||||
* ticket_type_id: int,
|
||||
@@ -144,12 +154,16 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
$attributes = array_merge(
|
||||
[
|
||||
'acceptance_criteria' => null,
|
||||
'blocked_comment' => null,
|
||||
'description' => null,
|
||||
'is_blocked' => false,
|
||||
'parent_ticket_id' => null,
|
||||
],
|
||||
Arr::only($this->validated(), [
|
||||
'acceptance_criteria',
|
||||
'blocked_comment',
|
||||
'description',
|
||||
'is_blocked',
|
||||
'parent_ticket_id',
|
||||
'ticket_priority_id',
|
||||
'ticket_type_id',
|
||||
|
||||
@@ -41,6 +41,12 @@ class Project extends Model
|
||||
->orderBy('position');
|
||||
}
|
||||
|
||||
public function ticketLinkTypes(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketLinkType::class)
|
||||
->orderBy('position');
|
||||
}
|
||||
|
||||
public function ticketTypes(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketType::class)
|
||||
|
||||
@@ -12,7 +12,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
#[Fillable([
|
||||
'acceptance_criteria',
|
||||
'board_column_id',
|
||||
'blocked_comment',
|
||||
'description',
|
||||
'is_blocked',
|
||||
'parent_ticket_id',
|
||||
'project_id',
|
||||
'sequence_number',
|
||||
@@ -26,6 +28,16 @@ class Ticket extends Model
|
||||
/** @use HasFactory<TicketFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_blocked' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function boardColumn(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BoardColumn::class);
|
||||
@@ -48,6 +60,18 @@ class Ticket extends Model
|
||||
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
|
||||
{
|
||||
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 [
|
||||
'acceptance_criteria' => fake()->paragraph(),
|
||||
'board_column_id' => null,
|
||||
'blocked_comment' => null,
|
||||
'description' => fake()->paragraphs(2, true),
|
||||
'is_blocked' => false,
|
||||
'parent_ticket_id' => null,
|
||||
'sequence_number' => 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,
|
||||
TicketSeeder::class,
|
||||
TicketFieldValueSeeder::class,
|
||||
TicketLinkTypeSeeder::class,
|
||||
TicketLinkSeeder::class,
|
||||
TicketWorklogEntrySeeder::class,
|
||||
TeamSeeder::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
|
||||
v-for="ticket in boardColumn.tickets"
|
||||
: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">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="ticket.is_blocked"
|
||||
variant="destructive"
|
||||
>
|
||||
Blocked
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ ticket.ticket_type.name }}
|
||||
</Badge>
|
||||
@@ -169,6 +180,12 @@ const statusOptions = [
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</CardDescription>
|
||||
<CardDescription
|
||||
v-if="ticket.is_blocked && ticket.blocked_comment"
|
||||
class="mt-2 text-amber-900 dark:text-amber-200"
|
||||
>
|
||||
{{ ticket.blocked_comment }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent v-if="canEditTickets" class="pt-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
@@ -44,6 +45,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
const form = useForm({
|
||||
acceptance_criteria: props.ticket.acceptance_criteria ?? '',
|
||||
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||
description: props.ticket.description ?? '',
|
||||
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
|
||||
const currentFieldValue = props.ticket.field_values?.find(
|
||||
@@ -58,6 +60,7 @@ const form = useForm({
|
||||
value_user_id: currentFieldValue?.value_user_id ?? '',
|
||||
};
|
||||
}),
|
||||
is_blocked: props.ticket.is_blocked,
|
||||
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
|
||||
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||
ticket_type_id: props.ticket.ticket_type_id,
|
||||
@@ -284,6 +287,42 @@ const formatDateTime = (value: string): string => {
|
||||
/>
|
||||
</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="space-y-1">
|
||||
<h3 class="text-sm font-semibold">
|
||||
@@ -388,6 +427,155 @@ const formatDateTime = (value: string): string => {
|
||||
</Card>
|
||||
|
||||
<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">
|
||||
<CardHeader>
|
||||
<CardTitle>Child tickets</CardTitle>
|
||||
|
||||
@@ -57,7 +57,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
</Badge>
|
||||
<Heading
|
||||
: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>
|
||||
|
||||
@@ -81,7 +81,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
{{ status }}
|
||||
</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">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Ticket types</CardTitle>
|
||||
@@ -497,6 +497,124 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
</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;
|
||||
acceptance_criteria: string | null;
|
||||
board_column_id: number | null;
|
||||
blocked_comment: string | null;
|
||||
description: string | null;
|
||||
field_values?: TicketFieldValueModel[];
|
||||
is_blocked: boolean;
|
||||
parent_ticket_id: number | null;
|
||||
project_id: 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 { TeamModel } from '@/types/models/team-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 { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||
@@ -66,6 +69,7 @@ export type ProjectTicketMetadataSettingsPageProps = {
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
||||
ticketLinkTypes: TicketLinkTypeModel[];
|
||||
ticketPriorities: TicketPriorityModel[];
|
||||
ticketTypes: TicketTypeModel[];
|
||||
};
|
||||
@@ -84,15 +88,14 @@ export type ProjectBacklogPageProps = {
|
||||
export type ProjectTicketPageProps = {
|
||||
canEditTicket: boolean;
|
||||
childTicketTree: TicketTreeNodeModel[];
|
||||
parentTicketOptions: Array<{
|
||||
id: number;
|
||||
ticket_key: string;
|
||||
title: string;
|
||||
}>;
|
||||
linkedTickets: TicketLinkModel[];
|
||||
parentTicketOptions: TicketReferenceModel[];
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
ticket: TicketModel;
|
||||
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
||||
ticketLinkOptions: TicketReferenceModel[];
|
||||
ticketLinkTypes: TicketLinkTypeModel[];
|
||||
ticketPriorities: TicketPriorityModel[];
|
||||
ticketTypes: TicketTypeModel[];
|
||||
users: UserModel[];
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\ProjectBoardController;
|
||||
use App\Http\Controllers\ProjectBoardSettingsController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\ProjectTicketController;
|
||||
use App\Http\Controllers\ProjectTicketLinkController;
|
||||
use App\Http\Controllers\ProjectTicketMetadataSettingsController;
|
||||
use App\Http\Controllers\TeamController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -39,6 +40,11 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
->name('projects.tickets.show');
|
||||
Route::patch('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, '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'])
|
||||
->name('projects.tickets.status.update');
|
||||
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');
|
||||
Route::delete('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'destroyFieldDefinition'])
|
||||
->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\TicketFieldDefinition;
|
||||
use App\Models\TicketFieldValue;
|
||||
use App\Models\TicketLink;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketWorklogEntry;
|
||||
use Database\Seeders\BoardColumnSeeder;
|
||||
@@ -165,4 +167,72 @@ class TicketTest extends TestCase
|
||||
$this->assertSame($parentTicket->id, $childTicket->fresh()->parentTicket?->id);
|
||||
$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\TicketFieldDefinition;
|
||||
use App\Models\TicketFieldValue;
|
||||
use App\Models\TicketLink;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketWorklogEntry;
|
||||
use App\Models\User;
|
||||
@@ -40,6 +42,10 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'label' => 'Story Points',
|
||||
'position' => 1,
|
||||
]);
|
||||
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Relates to',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$ticket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
@@ -49,6 +55,14 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
'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([
|
||||
'ticket_field_definition_id' => $fieldDefinition->id,
|
||||
@@ -56,6 +70,11 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'value_number' => 5,
|
||||
'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([
|
||||
'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.value_number', 5)
|
||||
->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),
|
||||
);
|
||||
}
|
||||
@@ -108,6 +129,9 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'title' => 'Updated title',
|
||||
'description' => 'Updated description',
|
||||
'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_priority_id' => $ticket->ticket_priority_id,
|
||||
'field_values' => [],
|
||||
@@ -186,6 +210,9 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'title' => 'Updated title',
|
||||
'description' => 'Updated description',
|
||||
'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_priority_id' => $highPriority->id,
|
||||
'field_values' => [
|
||||
@@ -276,6 +303,8 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'title' => $childTicket->title,
|
||||
'description' => $childTicket->description,
|
||||
'acceptance_criteria' => $childTicket->acceptance_criteria,
|
||||
'blocked_comment' => $childTicket->blocked_comment,
|
||||
'is_blocked' => $childTicket->is_blocked,
|
||||
'parent_ticket_id' => $candidateParent->id,
|
||||
'ticket_type_id' => $childTicket->ticket_type_id,
|
||||
'ticket_priority_id' => $childTicket->ticket_priority_id,
|
||||
@@ -330,6 +359,8 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'title' => $childTicket->title,
|
||||
'description' => $childTicket->description,
|
||||
'acceptance_criteria' => $childTicket->acceptance_criteria,
|
||||
'blocked_comment' => $childTicket->blocked_comment,
|
||||
'is_blocked' => $childTicket->is_blocked,
|
||||
'parent_ticket_id' => $parentTicket->id,
|
||||
'ticket_type_id' => $childTicket->ticket_type_id,
|
||||
'ticket_priority_id' => $childTicket->ticket_priority_id,
|
||||
@@ -422,4 +453,134 @@ class ProjectTicketControllerTest extends TestCase
|
||||
->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\Models\Project;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketType;
|
||||
use App\Models\User;
|
||||
@@ -27,6 +28,10 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'name' => 'Bug',
|
||||
'position' => 3,
|
||||
]);
|
||||
TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Relates to',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
|
||||
@@ -37,7 +42,8 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
->component('projects/settings/Metadata')
|
||||
->where('ticketTypes.0.name', 'Task')
|
||||
->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')
|
||||
->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