Compare commits

...
6 Commits
Author SHA1 Message Date
Twyco 393d074bb7 Merge pull request 'KAN-12: Support both ticket link directions' (#174) from feature/KAN-12-bidirectional-ticket-links into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 11s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #174
2026-10-01 15:15:32 +00:00
Twyco 3ec59ef367 feat: support both ticket link directions (KAN-12)
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Failing after 59s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 08:13:54 +02:00
Twyco 10f88f6029 Merge pull request 'Fix dependency CVEs and provide shared PR base' (#173) from fix/dependency-cves into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m8s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 35s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Reviewed-on: #173
2026-09-30 10:07:53 +00:00
Twyco 3b110f08ae fix
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 51s
CI/CD / test_and_check_app (pull_request) Successful in 5m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-30 11:57:19 +02:00
Twyco 1a022c257f fix
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 59s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m37s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:53:42 +02:00
Twyco ce36060ba1 fix: patch vulnerable framework and build dependencies
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m7s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:19:21 +02:00
14 changed files with 256 additions and 119 deletions
+12 -72
View File
@@ -1,78 +1,18 @@
name: "Lint and Test APP"
description: "Run Pint and the Pest test suite"
name: test_and_check_app
description: Build the test image and run all application checks
runs:
using: composite
steps:
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
with:
php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
- name: Verify Composer lockfile
- name: Test and check app
shell: bash
run: |
if [ ! -f composer.lock ]; then
echo "::error::composer.lock not found"
exit 1
fi
shell: bash
set -Eeuo pipefail
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Run independent application checks
run: |
pnpm exec concurrently \
--names frontend,backend \
--prefix-colors cyan,magenta \
--kill-others-on-fail \
"composer run ci:frontend" \
"composer run ci:backend"
shell: bash
- name: Run Laravel tests
run: composer run ci:test
shell: bash
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
+15 -12
View File
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
) {}
/**
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
*/
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
{
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
$linkedTicket = Ticket::query()
->where('project_id', $project->id)
->findOrFail($linkedTicketId);
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
Ticket::query()
->whereKey([$ticket->id, $linkedTicket->id])
->orderBy('id')
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
if ($ticketLink === null) {
$ticketLink = TicketLink::query()->create([
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $targetTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId,
]);
} else {
$ticketLink->update([
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $targetTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId,
]);
}
$ticketLink->load('ticketLinkType');
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
$this->createTicketWorklogEntry->handle(
$ticket,
$user,
TicketWorklogEntryType::LinkUpdated,
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
[
'linked_ticket_key' => $linkedTicket->ticket_key,
'ticket_link_type' => $sourceLabel,
'ticket_link_type' => $ticketLabel,
],
);
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
$linkedTicket,
$user,
TicketWorklogEntryType::LinkUpdated,
"Linked {$ticket->ticket_key} as {$targetLabel}.",
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
[
'linked_ticket_key' => $ticket->ticket_key,
'ticket_link_type' => $targetLabel,
'ticket_link_type' => $linkedTicketLabel,
],
);
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
->additional([
'meta' => [
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
'message' => "{$label} link was saved.",
],
])
->response()
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
);
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
return to_route('projects.tickets.show', [$project, $ticket])
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
->with('status', "{$label} link was saved.");
}
public function destroy(
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
fn ($query) => $query->where('project_id', $project->id),
),
],
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
];
}
Generated
+21 -17
View File
@@ -1451,20 +1451,20 @@
},
{
"name": "laravel/framework",
"version": "v13.26.1",
"version": "v13.34.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
"shasum": ""
},
"require": {
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
"composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4",
@@ -1576,18 +1576,20 @@
"league/flysystem-sftp-v3": "^3.25.1",
"mockery/mockery": "^1.6.10",
"opis/json-schema": "^2.4.1",
"orchestra/testbench-core": "^11.0.0",
"orchestra/testbench-core": "^11.5.0",
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
"php-http/discovery": "^1.15",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan": "2.2.15",
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
"predis/predis": "^2.3 || ^3.0",
"rector/rector": "^2.3",
"rector/rector": "2.6.3",
"resend/resend-php": "^1.0",
"symfony/cache": "^7.4.0 || ^8.0.0",
"symfony/http-client": "^7.4.0 || ^8.0.0",
"symfony/mercure": "^0.8.0",
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
"symfony/translation": "^7.4.0 || ^8.0.0"
"symfony/translation": "^7.4.0 || ^8.0.0",
"web-token/jwt-library": "^4.1"
},
"suggest": {
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
@@ -1623,8 +1625,10 @@
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).",
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
},
"type": "library",
"extra": {
@@ -1674,7 +1678,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2026-08-18T20:31:28+00:00"
"time": "2026-09-29T12:46:21+00:00"
},
{
"name": "laravel/mcp",
@@ -2192,16 +2196,16 @@
},
{
"name": "league/flysystem",
"version": "3.35.2",
"version": "3.36.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
"shasum": ""
},
"require": {
@@ -2269,9 +2273,9 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
},
"time": "2026-07-06T14:42:07+00:00"
"time": "2026-09-02T08:00:27+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
+12 -12
View File
@@ -1088,14 +1088,14 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@2.1.7:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
braces@3.0.3:
@@ -3303,16 +3303,16 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@1.1.18:
brace-expansion@1.1.21:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.1.4:
brace-expansion@2.1.7:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.9:
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -4206,15 +4206,15 @@ snapshots:
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.12
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.18
brace-expansion: 1.1.21
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.4
brace-expansion: 2.1.7
minimist@1.2.8: {}
+5
View File
@@ -1,4 +1,9 @@
minimumReleaseAge: 10080
# Security patches for GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p.
minimumReleaseAgeExclude:
- brace-expansion@1.1.21
- brace-expansion@2.1.7
- brace-expansion@5.0.12
allowBuilds:
unrs-resolver: true
vue-demi: true
@@ -35,6 +35,7 @@ const canAddLink = computed(
);
const form = useForm({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
link_direction: 'source',
linked_ticket_id: '',
});
const protection = useFormProtection(form, {
@@ -220,7 +221,7 @@ function submit(): void {
/>
<fieldset
:disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
class="grid min-w-0 gap-4 sm:grid-cols-3"
>
<TicketFormField
id="ticket_link_type_id"
@@ -230,13 +231,33 @@ function submit(): void {
:options="
ticketLinkTypes.map((type) => ({
value: type.id,
label: type.label,
label: `${type.label} / ${type.counterpart_label}`,
}))
"
:error="form.errors.ticket_link_type_id"
:disabled="form.processing"
required
/>
<TicketFormField
id="link_direction"
v-model="form.link_direction"
kind="select"
:label="t('ticketRelationships.direction')"
:options="[
{
value: 'source',
label: selectedType?.label ?? '',
},
{
value: 'counterpart',
label:
selectedType?.counterpart_label ?? '',
},
]"
:error="form.errors.link_direction"
:disabled="form.processing"
required
/>
<TicketFormField
id="linked_ticket_id"
v-model="form.linked_ticket_id"
@@ -267,7 +288,11 @@ function submit(): void {
<span class="font-mono text-xs">{{
ticket.ticket_key
}}</span
><span>{{ selectedType.label }}</span
><span>{{
form.link_direction === 'source'
? selectedType.label
: selectedType.counterpart_label
}}</span
><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span
+2 -1
View File
@@ -1200,8 +1200,9 @@ export const en = {
'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.direction': 'This ticket',
'ticketRelationships.directionHelp':
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
'Choose how this ticket relates to the linked ticket. The reverse label appears on the other ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}',
+43
View File
@@ -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');
}
/**
+7
View File
@@ -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();