feat: support both ticket link directions (KAN-12)
This commit is contained in:
@@ -293,6 +293,49 @@ class TicketApiTest extends TestCase
|
||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
||||
}
|
||||
|
||||
public function test_counterpart_link_direction_is_reflected_on_both_tickets_in_the_api(): void
|
||||
{
|
||||
[$user, $project] = $this->projectContext();
|
||||
$status = $project->ticketStatuses()->firstOrFail();
|
||||
$blockingTicket = $this->ticket($project, $status);
|
||||
$blockedTicket = $this->ticket($project, $status, 2);
|
||||
$linkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Blocks',
|
||||
'counterpart_label' => 'Blocked by',
|
||||
]);
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||
'linked_ticket_id' => $blockingTicket->id,
|
||||
'ticket_link_type_id' => $linkType->id,
|
||||
'link_direction' => 'counterpart',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.source_ticket_id', $blockingTicket->id)
|
||||
->assertJsonPath('data.target_ticket_id', $blockedTicket->id)
|
||||
->assertJsonPath('data.ticket_link_type.label', 'Blocked by')
|
||||
->assertJsonPath('meta.message', 'Blocked by link was saved.');
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->getJson(route('api.projects.tickets.show', [$project, $blockingTicket]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocks');
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->getJson(route('api.projects.tickets.show', [$project, $blockedTicket]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocked by');
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||
'linked_ticket_id' => $blockingTicket->id,
|
||||
'ticket_link_type_id' => $linkType->id,
|
||||
'link_direction' => 'invalid',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('link_direction');
|
||||
}
|
||||
|
||||
public function test_ticket_links_and_branch_endpoints_are_available(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
|
||||
@@ -83,6 +83,74 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_counterpart_label_can_be_selected_from_the_current_ticket(): void
|
||||
{
|
||||
$user = 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)->firstOrFail();
|
||||
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Blocks',
|
||||
'counterpart_label' => 'Blocked by',
|
||||
]);
|
||||
$blockingTicket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'CORE-1',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
]);
|
||||
$blockedTicket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'CORE-2',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||
'linked_ticket_id' => $blockingTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
'link_direction' => 'counterpart',
|
||||
])
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $blockedTicket]))
|
||||
->assertSessionHas('status', 'Blocked by link was saved.');
|
||||
|
||||
$this->assertDatabaseHas('ticket_links', [
|
||||
'source_ticket_id' => $blockingTicket->id,
|
||||
'target_ticket_id' => $blockedTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
]);
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'ticket_id' => $blockedTicket->id,
|
||||
'summary' => 'Linked CORE-1 as Blocked by.',
|
||||
]);
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'ticket_id' => $blockingTicket->id,
|
||||
'summary' => 'Linked CORE-2 as Blocks.',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||
'linked_ticket_id' => $blockingTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
'link_direction' => 'source',
|
||||
])
|
||||
->assertSessionHas('status', 'Blocks link was saved.');
|
||||
|
||||
$this->assertSame(1, TicketLink::query()->count());
|
||||
$this->assertDatabaseHas('ticket_links', [
|
||||
'source_ticket_id' => $blockedTicket->id,
|
||||
'target_ticket_id' => $blockingTicket->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure ticket links cannot target the current ticket or a ticket from another project.
|
||||
*/
|
||||
@@ -144,6 +212,15 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
])
|
||||
->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,
|
||||
'link_direction' => 'sideways',
|
||||
])
|
||||
->assertSessionHasErrors('link_direction');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -398,6 +398,11 @@ if (serve) {
|
||||
} else {
|
||||
params.delete('noLinks');
|
||||
params.delete('linkRejected');
|
||||
if (data.link_direction === 'counterpart') {
|
||||
params.set('incoming', '1');
|
||||
} else {
|
||||
params.delete('incoming');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert.ok(
|
||||
@@ -486,6 +491,8 @@ if (serve) {
|
||||
return result.body.split('id="main-content"')[1];
|
||||
};
|
||||
const html = await render();
|
||||
assert.ok(html.includes('id="link_direction"'));
|
||||
assert.ok(html.includes('Blocked by'));
|
||||
for (const text of [
|
||||
ticket.title,
|
||||
'Keep every existing ticket action available.',
|
||||
|
||||
@@ -70,6 +70,15 @@ try {
|
||||
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||
await control('Link type').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await control('This ticket').evaluate(
|
||||
(el) =>
|
||||
el === document.activeElement &&
|
||||
el.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await control('Linked ticket').evaluate(
|
||||
(el) =>
|
||||
@@ -118,6 +127,7 @@ try {
|
||||
await save().click();
|
||||
assert.deepEqual((await request).postDataJSON(), {
|
||||
ticket_link_type_id: 1,
|
||||
link_direction: 'source',
|
||||
linked_ticket_id: 41,
|
||||
});
|
||||
await section().getByText('Outgoing', { exact: true }).waitFor();
|
||||
@@ -141,6 +151,25 @@ try {
|
||||
document.activeElement?.textContent.includes('Linked tickets'),
|
||||
);
|
||||
|
||||
await visit('?noLinks');
|
||||
await control('This ticket').selectOption('counterpart');
|
||||
await control('Linked ticket').selectOption('41');
|
||||
await section()
|
||||
.locator('form')
|
||||
.last()
|
||||
.getByText('Blocked by', { exact: true })
|
||||
.waitFor();
|
||||
request = page.waitForRequest(
|
||||
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
|
||||
);
|
||||
await save().click();
|
||||
assert.deepEqual((await request).postDataJSON(), {
|
||||
ticket_link_type_id: 1,
|
||||
link_direction: 'counterpart',
|
||||
linked_ticket_id: 41,
|
||||
});
|
||||
await section().getByText('Incoming', { exact: true }).waitFor();
|
||||
|
||||
await visit('?rejectLink');
|
||||
await control('Linked ticket').selectOption('41');
|
||||
await save().click();
|
||||
|
||||
Reference in New Issue
Block a user