Compare commits
16
Commits
693f61659e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba12e65e81 | ||
|
|
85b9409e3c | ||
|
|
f862a6a226 | ||
|
|
c8f494fe7c | ||
|
|
f7c3873c19 | ||
|
|
d8e1659f4b | ||
|
|
704fe5d241 | ||
|
|
73f6ab6918 | ||
|
|
6d4a823fe5 | ||
|
|
6054690607 | ||
|
|
723285f820 | ||
|
|
393d074bb7 | ||
|
|
3ec59ef367 | ||
|
|
10f88f6029 | ||
|
|
3b110f08ae | ||
|
|
1a022c257f |
@@ -1,78 +1,18 @@
|
|||||||
name: "Lint and Test APP"
|
name: test_and_check_app
|
||||||
description: "Run Pint and the Pest test suite"
|
description: Build the test image and run all application checks
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Setup PHP
|
- name: Test and check app
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
shell: bash
|
||||||
with:
|
|
||||||
php-version: "8.5"
|
|
||||||
extensions: mbstring, pdo_sqlite
|
|
||||||
coverage: none
|
|
||||||
tools: composer:v2
|
|
||||||
|
|
||||||
- name: Verify Composer lockfile
|
|
||||||
run: |
|
run: |
|
||||||
if [ ! -f composer.lock ]; then
|
set -Eeuo pipefail
|
||||||
echo "::error::composer.lock not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get Composer cache directory
|
docker buildx build \
|
||||||
id: composer_cache
|
--file Dockerfile \
|
||||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
--target test \
|
||||||
shell: bash
|
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||||
|
--progress plain \
|
||||||
- name: Cache Composer packages
|
--load \
|
||||||
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
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- fix/dependency-cves
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class DeleteProject
|
class DeleteProject
|
||||||
{
|
{
|
||||||
@@ -10,7 +11,13 @@ class DeleteProject
|
|||||||
{
|
{
|
||||||
$projectName = $project->name;
|
$projectName = $project->name;
|
||||||
|
|
||||||
$project->delete();
|
DB::transaction(function () use ($project): void {
|
||||||
|
$baseTemplate = $project->baseTemplate()->first();
|
||||||
|
|
||||||
|
$project->ticketStatuses()->delete();
|
||||||
|
$project->delete();
|
||||||
|
$baseTemplate?->delete();
|
||||||
|
});
|
||||||
|
|
||||||
return $projectName;
|
return $projectName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||||
{
|
{
|
||||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||||
|
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||||
|
|
||||||
$linkedTicket = Ticket::query()
|
$linkedTicket = Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->findOrFail($linkedTicketId);
|
->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()
|
Ticket::query()
|
||||||
->whereKey([$ticket->id, $linkedTicket->id])
|
->whereKey([$ticket->id, $linkedTicket->id])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
|||||||
|
|
||||||
if ($ticketLink === null) {
|
if ($ticketLink === null) {
|
||||||
$ticketLink = TicketLink::query()->create([
|
$ticketLink = TicketLink::query()->create([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$ticketLink->update([
|
$ticketLink->update([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketLink->load('ticketLinkType');
|
$ticketLink->load('ticketLinkType');
|
||||||
|
|
||||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||||
|
|
||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
'ticket_link_type' => $sourceLabel,
|
'ticket_link_type' => $ticketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
|||||||
$linkedTicket,
|
$linkedTicket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $ticket->ticket_key,
|
'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 = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
$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']))
|
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||||
->additional([
|
->additional([
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
'message' => "{$label} link was saved.",
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->response()
|
->response()
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$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])
|
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(
|
public function destroy(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()?->can('access-admin') ?? false;
|
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
fn ($query) => $query->where('project_id', $project->id),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+5
-5
@@ -2007,16 +2007,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.10.0",
|
"version": "2.10.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2110,7 +2110,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-11T16:06:25+00:00"
|
"time": "2026-09-21T13:07:34+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
"lint:check": "eslint .",
|
"lint:check": "eslint .",
|
||||||
"types:check": "vue-tsc --noEmit"
|
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
Generated
+13
-5
@@ -203,6 +203,14 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -1244,8 +1252,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
axios@1.19.0:
|
axios@1.20.0:
|
||||||
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
|
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
@@ -2896,7 +2904,7 @@ snapshots:
|
|||||||
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
|
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/lodash-es': 4.17.12
|
'@types/lodash-es': 4.17.12
|
||||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
lodash-es: 4.18.1
|
lodash-es: 4.18.1
|
||||||
qs: 6.16.0
|
qs: 6.16.0
|
||||||
@@ -3684,7 +3692,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
|
|
||||||
axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
|
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
|
||||||
form-data: 4.0.6
|
form-data: 4.0.6
|
||||||
@@ -4452,7 +4460,7 @@ snapshots:
|
|||||||
|
|
||||||
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
lodash-es: 4.18.1
|
lodash-es: 4.18.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
|||||||
@@ -7,3 +7,10 @@ minimumReleaseAgeExclude:
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
vue-demi: true
|
vue-demi: true
|
||||||
|
overrides:
|
||||||
|
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||||
|
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
import type {
|
||||||
|
TicketDetailRelationshipsProps,
|
||||||
|
TicketLinkFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
|
||||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
defineEmits<{ editParent: [] }>();
|
defineEmits<{ editParent: [] }>();
|
||||||
@@ -33,17 +37,17 @@ const canAddLink = computed(
|
|||||||
props.ticketLinkTypes.length > 0 &&
|
props.ticketLinkTypes.length > 0 &&
|
||||||
props.ticketLinkOptions.length > 0,
|
props.ticketLinkOptions.length > 0,
|
||||||
);
|
);
|
||||||
const form = useForm({
|
const form = useForm<TicketLinkFormData>({
|
||||||
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||||
|
link_direction: 'source',
|
||||||
linked_ticket_id: '',
|
linked_ticket_id: '',
|
||||||
});
|
});
|
||||||
const protection = useFormProtection(form, {
|
const protection = useFormProtection(form, {
|
||||||
isDirty: () => props.canEditTicket && form.isDirty,
|
isDirty: () => props.canEditTicket && form.isDirty,
|
||||||
});
|
});
|
||||||
const selectedType = computed(() =>
|
const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
|
||||||
props.ticketLinkTypes.find(
|
() => props.ticketLinkTypes,
|
||||||
(type) => String(type.id) === String(form.ticket_link_type_id),
|
form,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const selectedTicket = computed(() =>
|
const selectedTicket = computed(() =>
|
||||||
props.ticketLinkOptions.find(
|
props.ticketLinkOptions.find(
|
||||||
@@ -224,16 +228,14 @@ function submit(): void {
|
|||||||
>
|
>
|
||||||
<TicketFormField
|
<TicketFormField
|
||||||
id="ticket_link_type_id"
|
id="ticket_link_type_id"
|
||||||
v-model="form.ticket_link_type_id"
|
v-model="selectedAction"
|
||||||
kind="select"
|
kind="select"
|
||||||
:label="t('ticketDetail.linkType')"
|
:label="t('ticketDetail.linkAction')"
|
||||||
:options="
|
:options="actions"
|
||||||
ticketLinkTypes.map((type) => ({
|
:error="
|
||||||
value: type.id,
|
form.errors.ticket_link_type_id ||
|
||||||
label: type.label,
|
form.errors.link_direction
|
||||||
}))
|
|
||||||
"
|
"
|
||||||
:error="form.errors.ticket_link_type_id"
|
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -260,22 +262,19 @@ function submit(): void {
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p
|
<p
|
||||||
v-if="selectedType && selectedTicket"
|
v-if="selectedActionLabel && selectedTicket"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-xs">{{
|
<span class="font-mono text-xs">{{
|
||||||
ticket.ticket_key
|
ticket.ticket_key
|
||||||
}}</span
|
}}</span
|
||||||
><span>{{ selectedType.label }}</span
|
><span>{{ selectedActionLabel }}</span
|
||||||
><ArrowRight class="size-4" aria-hidden="true" /><span
|
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||||
class="font-mono text-xs"
|
class="font-mono text-xs"
|
||||||
>{{ selectedTicket.ticket_key }}</span
|
>{{ selectedTicket.ticket_key }}</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
{{ t('ticketRelationships.directionHelp') }}
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="form.processing || !selectedTicket"
|
:disabled="form.processing || !selectedTicket"
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, useForm } from '@inertiajs/vue3';
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
import { Unlink } from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
|
||||||
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
@@ -20,7 +18,6 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{ removed: [] }>();
|
const emit = defineEmits<{ removed: [] }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
|
||||||
const form = useForm({});
|
const form = useForm({});
|
||||||
const protection = useFormProtection(form);
|
const protection = useFormProtection(form);
|
||||||
function remove(): void {
|
function remove(): void {
|
||||||
@@ -47,27 +44,6 @@ function remove(): void {
|
|||||||
:data-ticket-link="link.id"
|
:data-ticket-link="link.id"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="text-muted-foreground max-w-full whitespace-normal"
|
|
||||||
>
|
|
||||||
<ArrowUpRight
|
|
||||||
v-if="outgoing"
|
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/><ArrowDownLeft
|
|
||||||
v-else
|
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
outgoing
|
|
||||||
? 'ticketRelationships.outgoing'
|
|
||||||
: 'ticketRelationships.incoming',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
<span class="text-muted-foreground font-mono text-xs">{{
|
<span class="text-muted-foreground font-mono text-xs">{{
|
||||||
ticketKey
|
ticketKey
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { computed, toValue } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter } from 'vue';
|
||||||
|
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||||
|
import type {
|
||||||
|
TicketLinkActionOption,
|
||||||
|
TicketLinkFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
export function useTicketLinkActions(
|
||||||
|
ticketLinkTypes: MaybeRefOrGetter<TicketLinkTypeModel[]>,
|
||||||
|
form: TicketLinkFormData,
|
||||||
|
) {
|
||||||
|
const actions = computed<TicketLinkActionOption[]>(() =>
|
||||||
|
toValue(ticketLinkTypes).flatMap((type) => {
|
||||||
|
const choices: TicketLinkActionOption[] = [
|
||||||
|
{
|
||||||
|
value: `${type.id}:source`,
|
||||||
|
label: type.label,
|
||||||
|
ticketLinkTypeId: type.id,
|
||||||
|
direction: 'source',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
type.counterpart_label &&
|
||||||
|
type.counterpart_label !== type.label
|
||||||
|
) {
|
||||||
|
choices.push({
|
||||||
|
value: `${type.id}:counterpart`,
|
||||||
|
label: type.counterpart_label,
|
||||||
|
ticketLinkTypeId: type.id,
|
||||||
|
direction: 'counterpart',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return choices;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const selectedAction = computed({
|
||||||
|
get: () => `${form.ticket_link_type_id}:${form.link_direction}`,
|
||||||
|
set: (value: string | number | null) => {
|
||||||
|
const action = actions.value.find(
|
||||||
|
(option) => option.value === value,
|
||||||
|
);
|
||||||
|
if (action) {
|
||||||
|
form.ticket_link_type_id = action.ticketLinkTypeId;
|
||||||
|
form.link_direction = action.direction;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const selectedActionLabel = computed(
|
||||||
|
() =>
|
||||||
|
actions.value.find(
|
||||||
|
(action) => action.value === selectedAction.value,
|
||||||
|
)?.label,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { actions, selectedAction, selectedActionLabel };
|
||||||
|
}
|
||||||
@@ -1213,7 +1213,7 @@ export const en = {
|
|||||||
'ticketDetail.linkedTickets': 'Linked tickets',
|
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||||
'ticketDetail.linkedTicketsDescription':
|
'ticketDetail.linkedTicketsDescription':
|
||||||
'Track cross-ticket relationships with project-defined link labels.',
|
'Track cross-ticket relationships with project-defined link labels.',
|
||||||
'ticketDetail.linkType': 'Link type',
|
'ticketDetail.linkAction': 'Action',
|
||||||
'ticketDetail.linkedTicket': 'Linked ticket',
|
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||||
'ticketDetail.saveLink': 'Save link',
|
'ticketDetail.saveLink': 'Save link',
|
||||||
'ticketDetail.noLinks':
|
'ticketDetail.noLinks':
|
||||||
@@ -1227,10 +1227,6 @@ export const en = {
|
|||||||
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||||
'ticketRelationships.addLink': 'Add or update a link',
|
'ticketRelationships.addLink': 'Add or update a link',
|
||||||
'ticketRelationships.selectTicket': 'Select a ticket',
|
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||||
'ticketRelationships.directionHelp':
|
|
||||||
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
|
|
||||||
'ticketRelationships.outgoing': 'Outgoing',
|
|
||||||
'ticketRelationships.incoming': 'Incoming',
|
|
||||||
'ticketRelationships.removeLink': 'Remove link to {key}',
|
'ticketRelationships.removeLink': 'Remove link to {key}',
|
||||||
'ticketRelationships.noTypes':
|
'ticketRelationships.noTypes':
|
||||||
'No link types are configured for this project.',
|
'No link types are configured for this project.',
|
||||||
|
|||||||
@@ -67,3 +67,14 @@ export type TicketBranchFormData = {
|
|||||||
branch_name: string;
|
branch_name: string;
|
||||||
repository_connection_id: string;
|
repository_connection_id: string;
|
||||||
};
|
};
|
||||||
|
export type TicketLinkFormData = {
|
||||||
|
ticket_link_type_id: number | string;
|
||||||
|
link_direction: 'source' | 'counterpart';
|
||||||
|
linked_ticket_id: number | string;
|
||||||
|
};
|
||||||
|
export type TicketLinkActionOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
ticketLinkTypeId: number;
|
||||||
|
direction: TicketLinkFormData['link_direction'];
|
||||||
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::delete('/', [ProjectController::class, 'destroy'])
|
Route::delete('/', [ProjectController::class, 'destroy'])
|
||||||
->middleware('can:access-admin')
|
->middleware('can:admin-project,project')
|
||||||
->name('destroy');
|
->name('destroy');
|
||||||
|
|
||||||
Route::middleware('can:admin-project,project')->group(function (): void {
|
Route::middleware('can:admin-project,project')->group(function (): void {
|
||||||
|
|||||||
@@ -291,7 +291,14 @@ class ProjectApiTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.key', 'CORE');
|
->assertJsonPath('data.key', 'CORE');
|
||||||
|
|
||||||
$this->syncGlobalPermissions($admin, ['admin']);
|
$this->withApplicationKey($user)
|
||||||
|
->deleteJson(route('api.projects.destroy', $project), [
|
||||||
|
'confirmation_key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $admin, ['admin']);
|
||||||
|
$this->assertTrue($admin->can('admin-project', $project));
|
||||||
|
|
||||||
$this->withApplicationKey($admin)
|
$this->withApplicationKey($admin)
|
||||||
->deleteJson(route('api.projects.destroy', $project), [
|
->deleteJson(route('api.projects.destroy', $project), [
|
||||||
|
|||||||
@@ -293,6 +293,49 @@ class TicketApiTest extends TestCase
|
|||||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
$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
|
public function test_ticket_links_and_branch_endpoints_are_available(): void
|
||||||
{
|
{
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
|
|||||||
@@ -805,6 +805,9 @@ class ProjectControllerTest extends TestCase
|
|||||||
$this->assertDatabaseMissing('board_columns', [
|
$this->assertDatabaseMissing('board_columns', [
|
||||||
'id' => $boardColumn->id,
|
'id' => $boardColumn->id,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseMissing('ticket_statuses', [
|
||||||
|
'id' => $ticketStatus->id,
|
||||||
|
]);
|
||||||
$this->assertDatabaseMissing('tickets', [
|
$this->assertDatabaseMissing('tickets', [
|
||||||
'id' => $ticket->id,
|
'id' => $ticket->id,
|
||||||
]);
|
]);
|
||||||
@@ -817,6 +820,75 @@ class ProjectControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_project_admins_can_delete_their_own_projects_and_reuse_the_key(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->syncGlobalPermissions($user, ['create']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('projects.store'), [
|
||||||
|
'creation_mode' => 'scratch',
|
||||||
|
'name' => 'Core Platform',
|
||||||
|
'key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$project = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||||
|
$baseTemplateId = $project->project_template_id;
|
||||||
|
$this->assertTrue($user->can('admin-project', $project));
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->delete(route('projects.destroy', $project), [
|
||||||
|
'confirmation_key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.index'));
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('projects', ['id' => $project->id]);
|
||||||
|
$this->assertDatabaseMissing('project_templates', ['id' => $baseTemplateId]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('projects.store'), [
|
||||||
|
'creation_mode' => 'scratch',
|
||||||
|
'name' => 'New Core Platform',
|
||||||
|
'key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('projects', [
|
||||||
|
'key' => 'CORE',
|
||||||
|
'name' => 'New Core Platform',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_deleting_a_project_preserves_its_published_template_and_linked_projects(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$project = Project::factory()->create(['key' => 'CORE']);
|
||||||
|
$otherProject = Project::factory()->create(['key' => 'OTHER']);
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'source_project_id' => $project->id,
|
||||||
|
'is_published' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project->update(['project_template_id' => $template->id]);
|
||||||
|
$otherProject->update(['project_template_id' => $template->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->delete(route('projects.destroy', $project), [
|
||||||
|
'confirmation_key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.index'));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('project_templates', [
|
||||||
|
'id' => $template->id,
|
||||||
|
'source_project_id' => null,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('projects', [
|
||||||
|
'id' => $otherProject->id,
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure project deletion requires the exact project key confirmation.
|
* Ensure project deletion requires the exact project key confirmation.
|
||||||
*/
|
*/
|
||||||
@@ -841,16 +913,18 @@ class ProjectControllerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure non-admin users cannot delete projects.
|
* Ensure users without project administration cannot delete projects.
|
||||||
*/
|
*/
|
||||||
public function test_non_admin_users_cannot_delete_projects(): void
|
public function test_project_readers_cannot_delete_projects(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
$otherProject = Project::factory()->create();
|
||||||
$project = Project::factory()->create([
|
$project = Project::factory()->create([
|
||||||
'key' => 'CORE',
|
'key' => 'CORE',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['admin']);
|
$this->syncProjectPermissions($otherProject, $user, ['admin']);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->delete(route('projects.destroy', $project), [
|
->delete(route('projects.destroy', $project), [
|
||||||
|
|||||||
@@ -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.
|
* 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]))
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
->assertSessionHasErrors('linked_ticket_id');
|
->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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import tailwind from '@tailwindcss/vite';
|
|||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import { createInertiaApp } from '@inertiajs/vue3';
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
import { createServer } from 'vite';
|
import { createServer } from 'vite';
|
||||||
import { createSSRApp, h } from 'vue';
|
import { createSSRApp, h, reactive, ref } from 'vue';
|
||||||
import { renderToString } from 'vue/server-renderer';
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
const serve = process.argv.includes('--serve');
|
const serve = process.argv.includes('--serve');
|
||||||
@@ -398,6 +398,11 @@ if (serve) {
|
|||||||
} else {
|
} else {
|
||||||
params.delete('noLinks');
|
params.delete('noLinks');
|
||||||
params.delete('linkRejected');
|
params.delete('linkRejected');
|
||||||
|
if (data.link_direction === 'counterpart') {
|
||||||
|
params.set('incoming', '1');
|
||||||
|
} else {
|
||||||
|
params.delete('incoming');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -458,6 +463,40 @@ if (serve) {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
const { useTicketLinkActions } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useTicketLinkActions.ts',
|
||||||
|
);
|
||||||
|
const linkTypes = ref([
|
||||||
|
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
|
||||||
|
{ id: 2, label: 'Relates to', counterpart_label: 'Relates to' },
|
||||||
|
]);
|
||||||
|
const linkForm = reactive({
|
||||||
|
ticket_link_type_id: 1,
|
||||||
|
link_direction: 'source',
|
||||||
|
linked_ticket_id: 41,
|
||||||
|
});
|
||||||
|
const { actions, selectedAction, selectedActionLabel } =
|
||||||
|
useTicketLinkActions(linkTypes, linkForm);
|
||||||
|
assert.deepEqual(
|
||||||
|
actions.value.map((action) => action.label),
|
||||||
|
['Blocks', 'Blocked by', 'Relates to'],
|
||||||
|
);
|
||||||
|
assert.equal(selectedActionLabel.value, 'Blocks');
|
||||||
|
selectedAction.value = '1:counterpart';
|
||||||
|
assert.equal(selectedActionLabel.value, 'Blocked by');
|
||||||
|
assert.deepEqual(
|
||||||
|
{ ...linkForm },
|
||||||
|
{
|
||||||
|
ticket_link_type_id: 1,
|
||||||
|
link_direction: 'counterpart',
|
||||||
|
linked_ticket_id: 41,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
selectedAction.value = '2:source';
|
||||||
|
assert.equal(linkForm.ticket_link_type_id, 2);
|
||||||
|
assert.equal(linkForm.link_direction, 'source');
|
||||||
|
selectedAction.value = 'unknown';
|
||||||
|
assert.equal(selectedActionLabel.value, 'Relates to');
|
||||||
const { createLocalization } = await server.ssrLoadModule(
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
'/resources/js/lib/localization.ts',
|
'/resources/js/lib/localization.ts',
|
||||||
);
|
);
|
||||||
@@ -486,6 +525,24 @@ if (serve) {
|
|||||||
return result.body.split('id="main-content"')[1];
|
return result.body.split('id="main-content"')[1];
|
||||||
};
|
};
|
||||||
const html = await render();
|
const html = await render();
|
||||||
|
assert.ok(!html.includes('id="link_direction"'));
|
||||||
|
assert.ok(html.includes('Blocked by'));
|
||||||
|
const linkFields = [
|
||||||
|
...html.matchAll(/<fieldset\b[^>]*>[\s\S]*?<\/fieldset>/g),
|
||||||
|
].find(([fieldset]) =>
|
||||||
|
fieldset.includes('id="ticket_link_type_id"'),
|
||||||
|
)?.[0];
|
||||||
|
assert.ok(linkFields);
|
||||||
|
assert.equal((linkFields.match(/<select\b/g) ?? []).length, 2);
|
||||||
|
assert.match(
|
||||||
|
linkFields,
|
||||||
|
/<option value="1:source"[^>]*>Blocks<\/option>/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
linkFields,
|
||||||
|
/<option value="1:counterpart"[^>]*>Blocked by<\/option>/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(linkFields, /Blocks \/ Blocked by/);
|
||||||
for (const text of [
|
for (const text of [
|
||||||
ticket.title,
|
ticket.title,
|
||||||
'Keep every existing ticket action available.',
|
'Keep every existing ticket action available.',
|
||||||
@@ -563,9 +620,9 @@ if (serve) {
|
|||||||
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
||||||
);
|
);
|
||||||
assert.match(html, /Save link/);
|
assert.match(html, /Save link/);
|
||||||
assert.match(html, /Outgoing/);
|
assert.doesNotMatch(html, /Outgoing/);
|
||||||
const incoming = await render('?incoming');
|
const incoming = await render('?incoming');
|
||||||
assert.match(incoming, /Incoming/);
|
assert.doesNotMatch(incoming, /Incoming/);
|
||||||
assert.match(incoming, /Blocked by/);
|
assert.match(incoming, /Blocked by/);
|
||||||
assert.match(await render('?noTypes'), /No link types are configured/);
|
assert.match(await render('?noTypes'), /No link types are configured/);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ try {
|
|||||||
'Platform delivery',
|
'Platform delivery',
|
||||||
'Child tickets',
|
'Child tickets',
|
||||||
'Nested child with a long title that must wrap on mobile',
|
'Nested child with a long title that must wrap on mobile',
|
||||||
'Incoming',
|
|
||||||
'Blocked by',
|
|
||||||
]) {
|
]) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await section()
|
await section()
|
||||||
@@ -52,6 +50,13 @@ try {
|
|||||||
text,
|
text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert.equal(
|
||||||
|
await section()
|
||||||
|
.locator('[data-ticket-link]')
|
||||||
|
.getByText('Blocked by', { exact: true })
|
||||||
|
.isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
const collapse = section().getByRole('button', {
|
const collapse = section().getByRole('button', {
|
||||||
name: 'Collapse children of ENG-41',
|
name: 'Collapse children of ENG-41',
|
||||||
});
|
});
|
||||||
@@ -68,7 +73,7 @@ try {
|
|||||||
);
|
);
|
||||||
await page.keyboard.press('Space');
|
await page.keyboard.press('Space');
|
||||||
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||||
await control('Link type').focus();
|
await control('Action').focus();
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await control('Linked ticket').evaluate(
|
await control('Linked ticket').evaluate(
|
||||||
@@ -118,9 +123,13 @@ try {
|
|||||||
await save().click();
|
await save().click();
|
||||||
assert.deepEqual((await request).postDataJSON(), {
|
assert.deepEqual((await request).postDataJSON(), {
|
||||||
ticket_link_type_id: 1,
|
ticket_link_type_id: 1,
|
||||||
|
link_direction: 'source',
|
||||||
linked_ticket_id: 41,
|
linked_ticket_id: 41,
|
||||||
});
|
});
|
||||||
await section().getByText('Outgoing', { exact: true }).waitFor();
|
await section()
|
||||||
|
.locator('[data-ticket-link]')
|
||||||
|
.getByText('Blocks', { exact: true })
|
||||||
|
.waitFor();
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.querySelector('#linked_ticket_id')?.value === '',
|
() => document.querySelector('#linked_ticket_id')?.value === '',
|
||||||
);
|
);
|
||||||
@@ -141,6 +150,27 @@ try {
|
|||||||
document.activeElement?.textContent.includes('Linked tickets'),
|
document.activeElement?.textContent.includes('Linked tickets'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await visit('?noLinks');
|
||||||
|
await control('Action').selectOption('1:counterpart');
|
||||||
|
await control('Linked ticket').selectOption('41');
|
||||||
|
await section()
|
||||||
|
.locator('p[aria-live="polite"]')
|
||||||
|
.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()
|
||||||
|
.locator('[data-ticket-link]')
|
||||||
|
.getByText('Blocked by', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
await visit('?rejectLink');
|
await visit('?rejectLink');
|
||||||
await control('Linked ticket').selectOption('41');
|
await control('Linked ticket').selectOption('41');
|
||||||
await save().click();
|
await save().click();
|
||||||
@@ -148,7 +178,7 @@ try {
|
|||||||
.getByText('Choose another ticket.', { exact: true })
|
.getByText('Choose another ticket.', { exact: true })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
assert.equal(await control('Linked ticket').inputValue(), '41');
|
assert.equal(await control('Linked ticket').inputValue(), '41');
|
||||||
for (const label of ['Link type', 'Linked ticket']) {
|
for (const label of ['Action', 'Linked ticket']) {
|
||||||
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
|
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
|
||||||
}
|
}
|
||||||
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
|
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit;
|
|
||||||
|
|
||||||
use League\Flysystem\CorruptedPathDetected;
|
|
||||||
use League\Flysystem\WhitespacePathNormalizer;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class FilesystemPathSecurityTest extends TestCase
|
|
||||||
{
|
|
||||||
#[DataProvider('corruptedPaths')]
|
|
||||||
public function test_corrupted_paths_are_rejected(string $path): void
|
|
||||||
{
|
|
||||||
$this->expectException(CorruptedPathDetected::class);
|
|
||||||
|
|
||||||
(new WhitespacePathNormalizer)->normalizePath($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{string}>
|
|
||||||
*/
|
|
||||||
public static function corruptedPaths(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'control character' => ["avatars/user\x00.svg"],
|
|
||||||
'malformed UTF-8' => ["avatars/user\xFF.svg"],
|
|
||||||
'control character hidden by malformed UTF-8' => ["avatars/user\xFF\x00.svg"],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_valid_unicode_paths_remain_supported(): void
|
|
||||||
{
|
|
||||||
$this->assertSame(
|
|
||||||
'avatars/benützer.svg',
|
|
||||||
(new WhitespacePathNormalizer)->normalizePath('./avatars//benützer.svg'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user