Implement ticket links and blocker state

This commit is contained in:
2026-03-25 09:12:17 +01:00
parent 3b31e6bb7d
commit 70b90f32d4
34 changed files with 1722 additions and 10 deletions
@@ -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',
+6
View File
@@ -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)
+24
View File
@@ -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);
+31
View File
@@ -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);
}
}
+28
View File
@@ -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');
}
}
+2
View File
@@ -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',
+75
View File
@@ -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');
}
};
+2
View File
@@ -19,6 +19,8 @@ class DatabaseSeeder extends Seeder
ProjectTicketMetadataSeeder::class,
TicketSeeder::class,
TicketFieldValueSeeder::class,
TicketLinkTypeSeeder::class,
TicketLinkSeeder::class,
TicketWorklogEntrySeeder::class,
TeamSeeder::class,
ProjectAccessSeeder::class,
+41
View File
@@ -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,
]);
});
}
}
+30
View File
@@ -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();
});
}
}
+18 -1
View File
@@ -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">
+189 -1
View File
@@ -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;
};
+8 -5
View File
@@ -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[];
+12
View File
@@ -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');
});
});
+93
View File
@@ -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,
]);
}
}
+70
View File
@@ -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,
]);
}
}