diff --git a/app/Actions/ProjectTicketMetadata/StoreTicketLinkType.php b/app/Actions/ProjectTicketMetadata/StoreTicketLinkType.php index 24a4e52..a73c17e 100644 --- a/app/Actions/ProjectTicketMetadata/StoreTicketLinkType.php +++ b/app/Actions/ProjectTicketMetadata/StoreTicketLinkType.php @@ -12,16 +12,18 @@ class StoreTicketLinkType use ReordersProjectMetadata; /** - * @param array{name: string, position?: int|null} $validated + * @param array{name: string, counterpart_name: string, position?: int|null} $validated */ public function handle(Project $project, array $validated): TicketLinkType { $name = Arr::get($validated, 'name'); + $counterpartName = Arr::get($validated, 'counterpart_name'); $position = Arr::get($validated, 'position'); - return DB::transaction(function () use ($project, $position, $name): TicketLinkType { + return DB::transaction(function () use ($project, $position, $name, $counterpartName): TicketLinkType { $ticketLinkType = $project->ticketLinkTypes()->create([ 'label' => $name, + 'counterpart_label' => $counterpartName, 'position' => $project->ticketLinkTypes()->count() + 1, ]); diff --git a/app/Actions/ProjectTicketMetadata/UpdateTicketLinkType.php b/app/Actions/ProjectTicketMetadata/UpdateTicketLinkType.php index 2ff245c..4302634 100644 --- a/app/Actions/ProjectTicketMetadata/UpdateTicketLinkType.php +++ b/app/Actions/ProjectTicketMetadata/UpdateTicketLinkType.php @@ -12,16 +12,18 @@ class UpdateTicketLinkType use ReordersProjectMetadata; /** - * @param array{name: string, position: int} $validated + * @param array{name: string, counterpart_name: string, position: int} $validated */ public function handle(Project $project, TicketLinkType $ticketLinkType, array $validated): TicketLinkType { $name = Arr::get($validated, 'name'); + $counterpartName = Arr::get($validated, 'counterpart_name'); $position = Arr::get($validated, 'position'); - DB::transaction(function () use ($project, $name, $position, $ticketLinkType): void { + DB::transaction(function () use ($project, $name, $counterpartName, $position, $ticketLinkType): void { $ticketLinkType->update([ 'label' => $name, + 'counterpart_label' => $counterpartName, ]); $this->reorderModels( diff --git a/app/Actions/Tickets/DeleteProjectTicketLink.php b/app/Actions/Tickets/DeleteProjectTicketLink.php index 12837f5..03773f2 100644 --- a/app/Actions/Tickets/DeleteProjectTicketLink.php +++ b/app/Actions/Tickets/DeleteProjectTicketLink.php @@ -23,13 +23,15 @@ class DeleteProjectTicketLink 404, ); - $linkedTicket = $ticketLink->source_ticket_id === $ticket->id + $isSourceTicket = $ticketLink->source_ticket_id === $ticket->id; + $linkedTicket = $isSourceTicket ? $ticketLink->targetTicket : $ticketLink->sourceTicket; - $label = $ticketLink->ticketLinkType->label; + $label = $ticketLink->ticketLinkType->labelForSource($isSourceTicket); + $counterpartLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket); - DB::transaction(function () use ($label, $linkedTicket, $user, $ticket, $ticketLink): void { + DB::transaction(function () use ($label, $counterpartLabel, $linkedTicket, $user, $ticket, $ticketLink): void { $ticketLink->delete(); $this->createTicketWorklogEntry->handle( @@ -47,10 +49,10 @@ class DeleteProjectTicketLink $linkedTicket, $user, TicketWorklogEntryType::LinkRemoved, - "Removed {$label} link to {$ticket->ticket_key}.", + "Removed {$counterpartLabel} link to {$ticket->ticket_key}.", [ 'linked_ticket_key' => $ticket->ticket_key, - 'ticket_link_type' => $label, + 'ticket_link_type' => $counterpartLabel, ], ); }); diff --git a/app/Actions/Tickets/StoreProjectTicketLink.php b/app/Actions/Tickets/StoreProjectTicketLink.php index 5130dbb..c2df3dc 100644 --- a/app/Actions/Tickets/StoreProjectTicketLink.php +++ b/app/Actions/Tickets/StoreProjectTicketLink.php @@ -28,29 +28,47 @@ class StoreProjectTicketLink ->where('project_id', $project->id) ->findOrFail($linkedTicketId); - [$sourceTicketId, $targetTicketId] = $this->normalizeTicketPair($ticket->id, $linkedTicket->id); + return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink { + $ticketLink = TicketLink::query() + ->where(function ($query) use ($ticket, $linkedTicket): void { + $query + ->where('source_ticket_id', $ticket->id) + ->where('target_ticket_id', $linkedTicket->id); + }) + ->orWhere(function ($query) use ($ticket, $linkedTicket): void { + $query + ->where('source_ticket_id', $linkedTicket->id) + ->where('target_ticket_id', $ticket->id); + }) + ->first(); - return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $sourceTicketId, $targetTicketId, $ticket): TicketLink { - $ticketLink = TicketLink::query()->updateOrCreate( - [ - 'source_ticket_id' => $sourceTicketId, - 'target_ticket_id' => $targetTicketId, - ], - [ + if ($ticketLink === null) { + $ticketLink = TicketLink::query()->create([ + 'source_ticket_id' => $ticket->id, + 'target_ticket_id' => $linkedTicket->id, 'ticket_link_type_id' => $ticketLinkTypeId, - ], - ); + ]); + } else { + $ticketLink->update([ + 'source_ticket_id' => $ticket->id, + 'target_ticket_id' => $linkedTicket->id, + 'ticket_link_type_id' => $ticketLinkTypeId, + ]); + } $ticketLink->load('ticketLinkType'); + $sourceLabel = $ticketLink->ticketLinkType->labelForSource(true); + $targetLabel = $ticketLink->ticketLinkType->labelForSource(false); + $this->createTicketWorklogEntry->handle( $ticket, $user, TicketWorklogEntryType::LinkUpdated, - "Linked {$linkedTicket->ticket_key} as {$ticketLink->ticketLinkType->label}.", + "Linked {$linkedTicket->ticket_key} as {$sourceLabel}.", [ 'linked_ticket_key' => $linkedTicket->ticket_key, - 'ticket_link_type' => $ticketLink->ticketLinkType->label, + 'ticket_link_type' => $sourceLabel, ], ); @@ -58,25 +76,14 @@ class StoreProjectTicketLink $linkedTicket, $user, TicketWorklogEntryType::LinkUpdated, - "Linked {$ticket->ticket_key} as {$ticketLink->ticketLinkType->label}.", + "Linked {$ticket->ticket_key} as {$targetLabel}.", [ 'linked_ticket_key' => $ticket->ticket_key, - 'ticket_link_type' => $ticketLink->ticketLinkType->label, + 'ticket_link_type' => $targetLabel, ], ); return $ticketLink; }); } - - /** - * @return array{0: int, 1: int} - */ - private function normalizeTicketPair(int $firstTicketId, int $secondTicketId): array - { - return [ - min($firstTicketId, $secondTicketId), - max($firstTicketId, $secondTicketId), - ]; - } } diff --git a/app/Http/Controllers/Api/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/Api/ProjectTicketMetadataSettingsController.php index 174d939..6f41c64 100644 --- a/app/Http/Controllers/Api/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/Api/ProjectTicketMetadataSettingsController.php @@ -22,10 +22,12 @@ use App\Http\Requests\DestroyProjectTicketTypeRequest; use App\Http\Requests\StoreProjectNamedSettingRequest; use App\Http\Requests\StoreProjectTicketTypeRequest; use App\Http\Requests\StoreTicketFieldDefinitionRequest; +use App\Http\Requests\StoreTicketLinkTypeRequest; use App\Http\Requests\UpdateProjectNamedSettingRequest; use App\Http\Requests\UpdateProjectTicketMetadataSettings as UpdateProjectTicketMetadataSettingsRequest; use App\Http\Requests\UpdateProjectTicketTypeRequest; use App\Http\Requests\UpdateTicketFieldDefinitionRequest; +use App\Http\Requests\UpdateTicketLinkTypeRequest; use App\Http\Resources\ProjectResource; use App\Http\Resources\TicketFieldDefinitionResource; use App\Http\Resources\TicketLinkTypeResource; @@ -209,7 +211,7 @@ class ProjectTicketMetadataSettingsController extends ApiController return response()->json(null, 204); } - public function storeLinkType(StoreProjectNamedSettingRequest $request, Project $project): JsonResponse + public function storeLinkType(StoreTicketLinkTypeRequest $request, Project $project): JsonResponse { $this->abortUnlessCanManage($request, $project); @@ -222,7 +224,7 @@ class ProjectTicketMetadataSettingsController extends ApiController } public function updateLinkType( - UpdateProjectNamedSettingRequest $request, + UpdateTicketLinkTypeRequest $request, Project $project, TicketLinkType $ticketLinkType, ): JsonResponse { diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index 8afe652..fce99ed 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -22,10 +22,12 @@ use App\Http\Requests\DestroyProjectTicketTypeRequest; use App\Http\Requests\StoreProjectNamedSettingRequest; use App\Http\Requests\StoreProjectTicketTypeRequest; use App\Http\Requests\StoreTicketFieldDefinitionRequest; +use App\Http\Requests\StoreTicketLinkTypeRequest; use App\Http\Requests\UpdateProjectNamedSettingRequest; use App\Http\Requests\UpdateProjectTicketMetadataSettings; use App\Http\Requests\UpdateProjectTicketTypeRequest; use App\Http\Requests\UpdateTicketFieldDefinitionRequest; +use App\Http\Requests\UpdateTicketLinkTypeRequest; use App\Models\Project; use App\Models\TicketFieldDefinition; use App\Models\TicketLinkType; @@ -203,7 +205,7 @@ class ProjectTicketMetadataSettingsController extends Controller ->with('status', "{$deletedLabel} field was removed."); } - public function storeLinkType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse + public function storeLinkType(StoreTicketLinkTypeRequest $request, Project $project): RedirectResponse { $this->abortUnlessCanManage($request, $project); @@ -214,7 +216,7 @@ class ProjectTicketMetadataSettingsController extends Controller } public function updateLinkType( - UpdateProjectNamedSettingRequest $request, + UpdateTicketLinkTypeRequest $request, Project $project, TicketLinkType $ticketLinkType, ): RedirectResponse { diff --git a/app/Http/Requests/StoreTicketLinkTypeRequest.php b/app/Http/Requests/StoreTicketLinkTypeRequest.php new file mode 100644 index 0000000..d369163 --- /dev/null +++ b/app/Http/Requests/StoreTicketLinkTypeRequest.php @@ -0,0 +1,31 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'counterpart_name' => ['required', 'string', 'max:255'], + 'position' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTicketLinkTypeRequest.php b/app/Http/Requests/UpdateTicketLinkTypeRequest.php new file mode 100644 index 0000000..38b99ea --- /dev/null +++ b/app/Http/Requests/UpdateTicketLinkTypeRequest.php @@ -0,0 +1,31 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'counterpart_name' => ['required', 'string', 'max:255'], + 'position' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Resources/TicketLinkResource.php b/app/Http/Resources/TicketLinkResource.php index 15c2630..02010ea 100644 --- a/app/Http/Resources/TicketLinkResource.php +++ b/app/Http/Resources/TicketLinkResource.php @@ -18,16 +18,19 @@ class TicketLinkResource extends JsonResource { if ($this->resource instanceof TicketLink) { $currentTicketId = (int) ($this->resource->getAttribute('current_ticket_id') ?? 0); - $linkedTicket = $this->source_ticket_id === $currentTicketId + $isSourceTicket = $this->source_ticket_id === $currentTicketId; + $linkedTicket = $isSourceTicket ? $this->targetTicket : $this->sourceTicket; + $ticketLinkType = TicketLinkTypeResource::make($this->ticketLinkType)->resolve(); + $ticketLinkType['label'] = $this->ticketLinkType->labelForSource($isSourceTicket); return [ 'id' => $this->id, 'linked_ticket' => TicketReferenceResource::make($linkedTicket)->resolve(), 'source_ticket_id' => $this->source_ticket_id, 'target_ticket_id' => $this->target_ticket_id, - 'ticket_link_type' => TicketLinkTypeResource::make($this->ticketLinkType)->resolve(), + 'ticket_link_type' => $ticketLinkType, 'ticket_link_type_id' => $this->ticket_link_type_id, 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), diff --git a/app/Http/Resources/TicketLinkTypeResource.php b/app/Http/Resources/TicketLinkTypeResource.php index 4411b36..55bca63 100644 --- a/app/Http/Resources/TicketLinkTypeResource.php +++ b/app/Http/Resources/TicketLinkTypeResource.php @@ -17,6 +17,7 @@ class TicketLinkTypeResource extends JsonResource return [ 'id' => $this->id, 'label' => $this->label, + 'counterpart_label' => $this->counterpart_label ?: $this->label, 'position' => $this->position, 'project_id' => $this->project_id, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Models/TicketLinkType.php b/app/Models/TicketLinkType.php index 2799a0e..f66f50e 100644 --- a/app/Models/TicketLinkType.php +++ b/app/Models/TicketLinkType.php @@ -9,12 +9,22 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['label', 'position', 'project_id'])] +#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])] class TicketLinkType extends Model { /** @use HasFactory */ use HasFactory; + public function counterpartLabel(): string + { + return $this->counterpart_label ?: $this->label; + } + + public function labelForSource(bool $isSourceTicket): string + { + return $isSourceTicket ? $this->label : $this->counterpartLabel(); + } + public function project(): BelongsTo { return $this->belongsTo(Project::class); diff --git a/database/factories/TicketLinkFactory.php b/database/factories/TicketLinkFactory.php index 43997c1..17f63f8 100644 --- a/database/factories/TicketLinkFactory.php +++ b/database/factories/TicketLinkFactory.php @@ -60,15 +60,13 @@ class TicketLinkFactory extends Factory ->first() ?? TicketLinkType::factory()->for($sourceTicket->project)->create([ 'label' => 'Relates to', + 'counterpart_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->source_ticket_id = $sourceTicket->id; + $ticketLink->target_ticket_id = $targetTicket->id; $ticketLink->ticket_link_type_id = $ticketLinkType->id; }); } diff --git a/database/factories/TicketLinkTypeFactory.php b/database/factories/TicketLinkTypeFactory.php index 67fab9b..08204b4 100644 --- a/database/factories/TicketLinkTypeFactory.php +++ b/database/factories/TicketLinkTypeFactory.php @@ -18,8 +18,11 @@ class TicketLinkTypeFactory extends Factory */ public function definition(): array { + $label = fake()->unique()->words(2, true); + return [ - 'label' => fake()->unique()->words(2, true), + 'label' => $label, + 'counterpart_label' => $label, 'position' => 1, 'project_id' => Project::factory(), ]; diff --git a/database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php b/database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php new file mode 100644 index 0000000..f4c7aef --- /dev/null +++ b/database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php @@ -0,0 +1,28 @@ +string('counterpart_label')->nullable()->after('label'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ticket_link_types', function (Blueprint $table) { + $table->dropColumn('counterpart_label'); + }); + } +}; diff --git a/database/seeders/TicketLinkTypeSeeder.php b/database/seeders/TicketLinkTypeSeeder.php index b83724f..d8cb895 100644 --- a/database/seeders/TicketLinkTypeSeeder.php +++ b/database/seeders/TicketLinkTypeSeeder.php @@ -20,8 +20,8 @@ class TicketLinkTypeSeeder extends Seeder TicketLinkType::factory() ->count(2) ->sequence( - ['label' => 'Relates to', 'position' => 1], - ['label' => 'Blocks', 'position' => 2], + ['label' => 'Relates to', 'counterpart_label' => 'Relates to', 'position' => 1], + ['label' => 'Blocks', 'counterpart_label' => 'Blocked by', 'position' => 2], ) ->for($project) ->create(); diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue index 5b0274c..82eb655 100644 --- a/resources/js/pages/projects/settings/Metadata.vue +++ b/resources/js/pages/projects/settings/Metadata.vue @@ -652,17 +652,30 @@ const replacementTicketTypes = (ticketTypeId: number) => >
+
+ + + +
+ @@ -686,7 +699,7 @@ const replacementTicketTypes = (ticketTypeId: number) => +
+ + + +
+