Model ticket link counterparts explicitly
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreTicketLinkTypeRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'counterpart_name' => ['required', 'string', 'max:255'],
|
||||
'position' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTicketLinkTypeRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'counterpart_name' => ['required', 'string', 'max:255'],
|
||||
'position' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<TicketLinkTypeFactory> */
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?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('ticket_link_types', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -652,17 +652,30 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="link-type-name">
|
||||
New link type name
|
||||
Source label
|
||||
</Label>
|
||||
<Input
|
||||
id="link-type-name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="Relates to"
|
||||
placeholder="Blocks"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="link-type-counterpart-name">
|
||||
Counterpart label
|
||||
</Label>
|
||||
<Input
|
||||
id="link-type-counterpart-name"
|
||||
name="counterpart_name"
|
||||
required
|
||||
placeholder="Blocked by"
|
||||
/>
|
||||
<InputError :message="errors.counterpart_name" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Add link type
|
||||
</Button>
|
||||
@@ -686,7 +699,7 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
<Label
|
||||
:for="`link-type-name-${ticketLinkType.id}`"
|
||||
>
|
||||
Label
|
||||
Source label
|
||||
</Label>
|
||||
<Input
|
||||
:id="`link-type-name-${ticketLinkType.id}`"
|
||||
@@ -697,6 +710,23 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`link-type-counterpart-name-${ticketLinkType.id}`"
|
||||
>
|
||||
Counterpart label
|
||||
</Label>
|
||||
<Input
|
||||
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
|
||||
name="counterpart_name"
|
||||
:default-value="ticketLinkType.counterpart_label"
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
:message="errors.counterpart_name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`link-type-position-${ticketLinkType.id}`"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type TicketLinkTypeModel = {
|
||||
id: number;
|
||||
label: string;
|
||||
counterpart_label: string;
|
||||
position: number;
|
||||
project_id: number;
|
||||
created_at: string;
|
||||
|
||||
@@ -20,9 +20,9 @@ class TicketLinkTest extends TestCase
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure the ticket link factory normalizes the stored source and target ticket ids.
|
||||
* Ensure the ticket link factory preserves the provided source and target direction.
|
||||
*/
|
||||
public function test_ticket_link_factory_normalizes_ticket_order(): void
|
||||
public function test_ticket_link_factory_preserves_ticket_direction(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
@@ -61,8 +61,8 @@ class TicketLinkTest extends TestCase
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
]);
|
||||
|
||||
$this->assertSame($firstTicket->id, $ticketLink->source_ticket_id);
|
||||
$this->assertSame($secondTicket->id, $ticketLink->target_ticket_id);
|
||||
$this->assertSame($secondTicket->id, $ticketLink->source_ticket_id);
|
||||
$this->assertSame($firstTicket->id, $ticketLink->target_ticket_id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ class TicketLinkTypeTest extends TestCase
|
||||
|
||||
$this->assertNotNull($ticketLinkType->project_id);
|
||||
$this->assertIsString($ticketLinkType->label);
|
||||
$this->assertSame($ticketLinkType->label, $ticketLinkType->counterpart_label);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,12 +40,14 @@ class TicketLinkTypeTest extends TestCase
|
||||
$this->assertDatabaseHas('ticket_link_types', [
|
||||
'project_id' => $coreProject->id,
|
||||
'label' => 'Relates to',
|
||||
'counterpart_label' => 'Relates to',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('ticket_link_types', [
|
||||
'project_id' => $coreProject->id,
|
||||
'label' => 'Blocks',
|
||||
'counterpart_label' => 'Blocked by',
|
||||
'position' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
]);
|
||||
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Relates to',
|
||||
'counterpart_label' => 'Relates to',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
@@ -137,6 +138,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
->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('linkedTickets.0.ticket_link_type.counterpart_label', 'Relates to')
|
||||
->where('repositoryBranches', [])
|
||||
->where('repositoryPullRequests', [])
|
||||
->where('canEditTicket', false)
|
||||
@@ -144,6 +146,62 @@ class ProjectTicketControllerTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure linked tickets show the counterpart label from the other ticket perspective.
|
||||
*/
|
||||
public function test_ticket_detail_uses_counterpart_link_label_for_target_ticket(): void
|
||||
{
|
||||
$viewer = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
|
||||
$priority = $project->ticketPriorities()->firstOrFail();
|
||||
$taskType = $project->ticketTypes()
|
||||
->where('is_parent', false)
|
||||
->orderBy('position')
|
||||
->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,
|
||||
'title' => 'Source ticket',
|
||||
]);
|
||||
$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,
|
||||
'title' => 'Target ticket',
|
||||
]);
|
||||
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Blocks',
|
||||
'counterpart_label' => 'Blocked by',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
TicketLink::factory()->create([
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $viewer, ['view']);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->get(route('projects.tickets.show', [$project, $targetTicket]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->where('linkedTickets.0.linked_ticket.ticket_key', 'CORE-1')
|
||||
->where('linkedTickets.0.ticket_link_type.label', 'Blocked by')
|
||||
->where('linkedTickets.0.ticket_link_type.counterpart_label', 'Blocked by'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure project admins can still inspect the author when project author visibility is disabled.
|
||||
*/
|
||||
|
||||
@@ -33,7 +33,8 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
->orderBy('position')
|
||||
->firstOrFail();
|
||||
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Relates to',
|
||||
'label' => 'Blocks',
|
||||
'counterpart_label' => 'Blocked by',
|
||||
'position' => 1,
|
||||
]);
|
||||
$ticket = Ticket::factory()->create([
|
||||
@@ -59,11 +60,11 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
])
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||
->assertSessionHas('status', 'Relates to link was saved.');
|
||||
->assertSessionHas('status', 'Blocks link was saved.');
|
||||
|
||||
$this->assertDatabaseHas('ticket_links', [
|
||||
'source_ticket_id' => min($ticket->id, $linkedTicket->id),
|
||||
'target_ticket_id' => max($ticket->id, $linkedTicket->id),
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
]);
|
||||
|
||||
@@ -71,7 +72,14 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
'ticket_id' => $ticket->id,
|
||||
'actor_id' => $user->id,
|
||||
'entry_type' => 'link_updated',
|
||||
'summary' => 'Linked CORE-2 as Relates to.',
|
||||
'summary' => 'Linked CORE-2 as Blocks.',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'ticket_id' => $linkedTicket->id,
|
||||
'actor_id' => $user->id,
|
||||
'entry_type' => 'link_updated',
|
||||
'summary' => 'Linked CORE-1 as Blocked by.',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -99,6 +107,7 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
->firstOrFail();
|
||||
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Relates to',
|
||||
'counterpart_label' => 'Relates to',
|
||||
'position' => 1,
|
||||
]);
|
||||
$ticket = Ticket::factory()->create([
|
||||
@@ -155,7 +164,8 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
->orderBy('position')
|
||||
->firstOrFail();
|
||||
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Relates to',
|
||||
'label' => 'Blocks',
|
||||
'counterpart_label' => 'Blocked by',
|
||||
'position' => 1,
|
||||
]);
|
||||
$ticket = Ticket::factory()->create([
|
||||
@@ -173,8 +183,8 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
'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),
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
]);
|
||||
|
||||
@@ -183,7 +193,7 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
$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.');
|
||||
->assertSessionHas('status', 'Blocks link was removed.');
|
||||
|
||||
$this->assertDatabaseMissing('ticket_links', [
|
||||
'id' => $ticketLink->id,
|
||||
@@ -193,7 +203,14 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
'ticket_id' => $ticket->id,
|
||||
'actor_id' => $user->id,
|
||||
'entry_type' => 'link_removed',
|
||||
'summary' => 'Removed Relates to link to CORE-2.',
|
||||
'summary' => 'Removed Blocks link to CORE-2.',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'ticket_id' => $linkedTicket->id,
|
||||
'actor_id' => $user->id,
|
||||
'entry_type' => 'link_removed',
|
||||
'summary' => 'Removed Blocked by link to CORE-1.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +420,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
$project = Project::factory()->create();
|
||||
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Relates to',
|
||||
'counterpart_label' => 'Relates to',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
@@ -428,18 +429,32 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.metadata.link-types.store', $project), [
|
||||
'name' => 'Blocks',
|
||||
'counterpart_name' => 'Blocked by',
|
||||
])
|
||||
->assertRedirect(route('projects.settings.metadata', $project))
|
||||
->assertSessionHas('status', 'Blocks link type was added.');
|
||||
|
||||
$this->assertDatabaseHas('ticket_link_types', [
|
||||
'project_id' => $project->id,
|
||||
'label' => 'Blocks',
|
||||
'counterpart_label' => 'Blocked by',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.metadata.link-types.update', [$project, $ticketLinkType]), [
|
||||
'name' => 'Depends on',
|
||||
'counterpart_name' => 'Required by',
|
||||
'position' => 2,
|
||||
])
|
||||
->assertRedirect(route('projects.settings.metadata', $project))
|
||||
->assertSessionHas('status', 'Depends on link type was updated.');
|
||||
|
||||
$this->assertDatabaseHas('ticket_link_types', [
|
||||
'id' => $ticketLinkType->id,
|
||||
'label' => 'Depends on',
|
||||
'counterpart_label' => 'Required by',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType]))
|
||||
->assertRedirect(route('projects.settings.metadata', $project))
|
||||
|
||||
Reference in New Issue
Block a user