Compare commits
1
Commits
main
..
80d911bcc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80d911bcc1 |
@@ -1,18 +1,78 @@
|
|||||||
name: test_and_check_app
|
name: "Lint and Test APP"
|
||||||
description: Build the test image and run all application checks
|
description: "Run Pint and the Pest test suite"
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Test and check app
|
- name: Setup PHP
|
||||||
shell: bash
|
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
||||||
run: |
|
with:
|
||||||
set -Eeuo pipefail
|
php-version: "8.5"
|
||||||
|
extensions: mbstring, pdo_sqlite
|
||||||
|
coverage: none
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
docker buildx build \
|
- name: Verify Composer lockfile
|
||||||
--file Dockerfile \
|
run: |
|
||||||
--target test \
|
if [ ! -f composer.lock ]; then
|
||||||
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
echo "::error::composer.lock not found"
|
||||||
--progress plain \
|
exit 1
|
||||||
--load \
|
fi
|
||||||
.
|
shell: bash
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@@ -11,13 +10,7 @@ class DeleteProject
|
|||||||
{
|
{
|
||||||
$projectName = $project->name;
|
$projectName = $project->name;
|
||||||
|
|
||||||
DB::transaction(function () use ($project): void {
|
$project->delete();
|
||||||
$baseTemplate = $project->baseTemplate()->first();
|
|
||||||
|
|
||||||
$project->ticketStatuses()->delete();
|
|
||||||
$project->delete();
|
|
||||||
$baseTemplate?->delete();
|
|
||||||
});
|
|
||||||
|
|
||||||
return $projectName;
|
return $projectName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,21 +17,18 @@ class StoreProjectTicketLink
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
|
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $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, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
|
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
|
||||||
Ticket::query()
|
Ticket::query()
|
||||||
->whereKey([$ticket->id, $linkedTicket->id])
|
->whereKey([$ticket->id, $linkedTicket->id])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
@@ -53,31 +50,31 @@ class StoreProjectTicketLink
|
|||||||
|
|
||||||
if ($ticketLink === null) {
|
if ($ticketLink === null) {
|
||||||
$ticketLink = TicketLink::query()->create([
|
$ticketLink = TicketLink::query()->create([
|
||||||
'source_ticket_id' => $sourceTicket->id,
|
'source_ticket_id' => $ticket->id,
|
||||||
'target_ticket_id' => $targetTicket->id,
|
'target_ticket_id' => $linkedTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$ticketLink->update([
|
$ticketLink->update([
|
||||||
'source_ticket_id' => $sourceTicket->id,
|
'source_ticket_id' => $ticket->id,
|
||||||
'target_ticket_id' => $targetTicket->id,
|
'target_ticket_id' => $linkedTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketLink->load('ticketLinkType');
|
$ticketLink->load('ticketLinkType');
|
||||||
|
|
||||||
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
||||||
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
||||||
|
|
||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
'ticket_link_type' => $ticketLabel,
|
'ticket_link_type' => $sourceLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -85,10 +82,10 @@ class StoreProjectTicketLink
|
|||||||
$linkedTicket,
|
$linkedTicket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $ticket->ticket_key,
|
'linked_ticket_key' => $ticket->ticket_key,
|
||||||
'ticket_link_type' => $linkedTicketLabel,
|
'ticket_link_type' => $targetLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
|
|||||||
/**
|
/**
|
||||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||||
*/
|
*/
|
||||||
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
|
public static function syncTicket(Ticket $ticket, array $items): void
|
||||||
{
|
{
|
||||||
$normalized = self::normalize($items);
|
$normalized = self::normalize($items);
|
||||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||||
@@ -73,8 +73,7 @@ class AcceptanceCriteriaChecklistMapper
|
|||||||
: $existingCriteria->get($criterionId);
|
: $existingCriteria->get($criterionId);
|
||||||
|
|
||||||
while (
|
while (
|
||||||
$reuseUnidentifiedCriteria
|
$criterion === null
|
||||||
&& $criterion === null
|
|
||||||
&& $fallbackIndex < $fallbackCriteria->count()
|
&& $fallbackIndex < $fallbackCriteria->count()
|
||||||
) {
|
) {
|
||||||
/** @var AcceptanceCriterion $candidate */
|
/** @var AcceptanceCriterion $candidate */
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Actions\Tickets\Support;
|
|
||||||
|
|
||||||
use League\CommonMark\Environment\Environment;
|
|
||||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
|
||||||
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
|
|
||||||
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
|
|
||||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
|
||||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
|
||||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
|
||||||
use League\CommonMark\Extension\Table\Table;
|
|
||||||
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
|
|
||||||
use League\CommonMark\Parser\MarkdownParser;
|
|
||||||
use League\Uri\Uri;
|
|
||||||
|
|
||||||
class TicketDescriptionFormat
|
|
||||||
{
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public static function contract(): array
|
|
||||||
{
|
|
||||||
return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function instructions(): string
|
|
||||||
{
|
|
||||||
$contract = self::contract();
|
|
||||||
|
|
||||||
return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria']))
|
|
||||||
." Valid description example (Markdown string):\n".$contract['example'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function criteriaInstructions(): string
|
|
||||||
{
|
|
||||||
return self::contract()['criteria'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function safeLink(string $value): bool
|
|
||||||
{
|
|
||||||
$contract = self::contract();
|
|
||||||
$decoded = rawurldecode($value);
|
|
||||||
if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded)
|
|
||||||
|| ! preg_match('~'.$contract['link_prefix'].'~i', $value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (str_starts_with($value, '/') || str_starts_with($value, '#')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
$url = Uri::tryNew($decoded);
|
|
||||||
|
|
||||||
return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
public static function unsupportedContent(string $value): array
|
|
||||||
{
|
|
||||||
$environment = new Environment;
|
|
||||||
$environment->addExtension(new CommonMarkCoreExtension);
|
|
||||||
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
|
||||||
$document = (new MarkdownParser($environment))->parse($value);
|
|
||||||
$unsupported = [];
|
|
||||||
foreach ($document->iterator() as $node) {
|
|
||||||
$type = match (true) {
|
|
||||||
$node instanceof HtmlBlock, $node instanceof HtmlInline => 'html',
|
|
||||||
$node instanceof Table => 'table',
|
|
||||||
$node instanceof Image => 'image',
|
|
||||||
$node instanceof TaskListItemMarker => 'task',
|
|
||||||
$node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
if ($type !== null) {
|
|
||||||
$unsupported[] = self::contract()['unsupported_tokens'][$type];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($unsupported));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,6 @@ class UpdateProjectTicket
|
|||||||
Ticket $ticket,
|
Ticket $ticket,
|
||||||
User $actor,
|
User $actor,
|
||||||
array $validated,
|
array $validated,
|
||||||
bool $replaceCriteriaWithIds = false,
|
|
||||||
): bool {
|
): bool {
|
||||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||||
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||||
@@ -59,7 +58,7 @@ class UpdateProjectTicket
|
|||||||
);
|
);
|
||||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
||||||
|
|
||||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
|
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
|
||||||
if (! $ticketAttributes['is_blocked']) {
|
if (! $ticketAttributes['is_blocked']) {
|
||||||
$ticketAttributes['blocked_comment'] = null;
|
$ticketAttributes['blocked_comment'] = null;
|
||||||
}
|
}
|
||||||
@@ -92,9 +91,7 @@ class UpdateProjectTicket
|
|||||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||||
$existingFieldValues = $ticket->fieldValues
|
$existingFieldValues = $ticket->fieldValues
|
||||||
->keyBy('ticket_field_definition_id');
|
->keyBy('ticket_field_definition_id');
|
||||||
$currentAcceptanceCriteria = $replaceCriteriaWithIds
|
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||||
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
|
|
||||||
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
|
||||||
|
|
||||||
$changes = [];
|
$changes = [];
|
||||||
$summaryLabels = [];
|
$summaryLabels = [];
|
||||||
@@ -212,7 +209,7 @@ class UpdateProjectTicket
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
|
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||||
|
|||||||
@@ -36,12 +36,11 @@ 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' => "{$label} link was saved.",
|
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->response()
|
->response()
|
||||||
|
|||||||
@@ -34,10 +34,9 @@ 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', "{$label} link was saved.");
|
->with('status', "{$ticketLink->ticketLinkType->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('admin-project', $this->route('project')) ?? false;
|
return $this->user()?->can('access-admin') ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ 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'])],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,32 +2,24 @@
|
|||||||
|
|
||||||
namespace App\Mcp\Servers;
|
namespace App\Mcp\Servers;
|
||||||
|
|
||||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
|
||||||
use App\Mcp\Tools\CreateTicket;
|
use App\Mcp\Tools\CreateTicket;
|
||||||
use App\Mcp\Tools\GetProjectConfiguration;
|
|
||||||
use App\Mcp\Tools\GetTicket;
|
use App\Mcp\Tools\GetTicket;
|
||||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||||
use App\Mcp\Tools\ListProjects;
|
|
||||||
use App\Mcp\Tools\ListTicketsByStatus;
|
use App\Mcp\Tools\ListTicketsByStatus;
|
||||||
use App\Mcp\Tools\ListTicketTypes;
|
use App\Mcp\Tools\ListTicketTypes;
|
||||||
use App\Mcp\Tools\MoveTicketStatus;
|
use App\Mcp\Tools\MoveTicketStatus;
|
||||||
use App\Mcp\Tools\SearchTickets;
|
|
||||||
use App\Mcp\Tools\UpdateTicket;
|
|
||||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||||
use App\Mcp\Tools\UpdateTicketDescription;
|
use App\Mcp\Tools\UpdateTicketDescription;
|
||||||
use Laravel\Mcp\Server;
|
use Laravel\Mcp\Server;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Instructions;
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
use Laravel\Mcp\Server\Attributes\Version;
|
use Laravel\Mcp\Server\Attributes\Version;
|
||||||
|
|
||||||
#[Name('KanbanGit MCP')]
|
#[Name('KanbanGit MCP')]
|
||||||
#[Version('0.2.1')]
|
#[Version('0.1.0')]
|
||||||
|
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
||||||
class KanbanGitServer extends Server
|
class KanbanGitServer extends Server
|
||||||
{
|
{
|
||||||
protected function boot(): void
|
|
||||||
{
|
|
||||||
$this->instructions = 'Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected array $tools = [
|
protected array $tools = [
|
||||||
ListMyAssignedTickets::class,
|
ListMyAssignedTickets::class,
|
||||||
ListTicketsByStatus::class,
|
ListTicketsByStatus::class,
|
||||||
@@ -37,10 +29,6 @@ class KanbanGitServer extends Server
|
|||||||
MoveTicketStatus::class,
|
MoveTicketStatus::class,
|
||||||
CreateTicket::class,
|
CreateTicket::class,
|
||||||
ListTicketTypes::class,
|
ListTicketTypes::class,
|
||||||
ListProjects::class,
|
|
||||||
GetProjectConfiguration::class,
|
|
||||||
SearchTickets::class,
|
|
||||||
UpdateTicket::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $resources = [
|
protected array $resources = [
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mcp\Support;
|
|
||||||
|
|
||||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
|
||||||
|
|
||||||
trait DescribesTicketWriting
|
|
||||||
{
|
|
||||||
public function description(): string
|
|
||||||
{
|
|
||||||
return parent::description().' '.TicketDescriptionFormat::instructions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ use App\Models\TicketStatus;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
@@ -79,17 +78,6 @@ class KanbanGitMcp
|
|||||||
return $ticket;
|
return $ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ticketForKey(User $user, string $ticketKey): Ticket
|
|
||||||
{
|
|
||||||
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
|
|
||||||
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
|
|
||||||
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
|
|
||||||
}
|
|
||||||
$this->ensureCanRead($user, $ticket->project);
|
|
||||||
|
|
||||||
return $ticket;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
@@ -213,18 +201,6 @@ class KanbanGitMcp
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
|
|
||||||
public function paginationPayload(LengthAwarePaginator $paginator): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'current_page' => $paginator->currentPage(),
|
|
||||||
'per_page' => $paginator->perPage(),
|
|
||||||
'total' => $paginator->total(),
|
|
||||||
'last_page' => $paginator->lastPage(),
|
|
||||||
'has_more_pages' => $paginator->hasMorePages(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mcp\Support;
|
|
||||||
|
|
||||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
|
||||||
use App\Http\Requests\UpdateProjectTicketRequest;
|
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Models\TicketFieldDefinition;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Rules\VisualTicketDescription;
|
|
||||||
use Illuminate\Routing\Route;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Laravel\Mcp\Request;
|
|
||||||
|
|
||||||
class TicketUpdateInput
|
|
||||||
{
|
|
||||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp) {}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function validate(Request $request, Ticket $ticket, User $user): array
|
|
||||||
{
|
|
||||||
$form = new UpdateProjectTicketRequest;
|
|
||||||
$route = new Route('PUT', '/', fn () => null);
|
|
||||||
$route->bind(\Illuminate\Http\Request::create('/', 'PUT'));
|
|
||||||
$route->setParameter('project', $ticket->project);
|
|
||||||
$route->setParameter('ticket', $ticket);
|
|
||||||
$form->setRouteResolver(fn () => $route);
|
|
||||||
$form->setUserResolver(fn () => $user);
|
|
||||||
$rules = $form->rules();
|
|
||||||
foreach ($rules as $key => &$rule) {
|
|
||||||
if (! str_contains($key, '.')) {
|
|
||||||
$rule = ['sometimes', ...$rule];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($rule);
|
|
||||||
$rules['description'][] = new VisualTicketDescription;
|
|
||||||
$rules['acceptance_criteria'] = ['sometimes', 'array', 'list'];
|
|
||||||
$rules['acceptance_criteria.*'] = ['array:id,text,checked,indent'];
|
|
||||||
$rules['acceptance_criteria.*.id'] = ['sometimes', 'integer', 'min:1', 'distinct', Rule::exists('acceptance_criteria', 'id')->where('ticket_id', $ticket->id)];
|
|
||||||
$rules['acceptance_criteria.*.checked'] = ['sometimes', 'boolean'];
|
|
||||||
$rules['acceptance_criteria.*.indent'] = ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL];
|
|
||||||
$rules['field_values'] = ['sometimes', 'array', 'list'];
|
|
||||||
$rules['field_values.*'] = ['array:ticket_field_definition_id,value_number,value_user_id'];
|
|
||||||
$rules['field_values.*.ticket_field_definition_id'][] = 'distinct';
|
|
||||||
$rules['ticket_status_id'] = ['prohibited'];
|
|
||||||
$rules['acceptance_criteria.*.text'][] = function (string $attribute, mixed $value, \Closure $fail): void {
|
|
||||||
if (is_string($value) && trim($value) === '') {
|
|
||||||
$fail('Criterion text must not be blank.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
$input = $request->validate($rules);
|
|
||||||
$previousIndent = -1;
|
|
||||||
foreach (Arr::get($input, 'acceptance_criteria', []) as $index => $item) {
|
|
||||||
$indent = Arr::get($item, 'indent', 0);
|
|
||||||
if ($indent > $previousIndent + 1) {
|
|
||||||
throw ValidationException::withMessages(["acceptance_criteria.{$index}.indent" => 'Indentation cannot skip a level; the first item must have indent 0.']);
|
|
||||||
}
|
|
||||||
$previousIndent = $indent;
|
|
||||||
}
|
|
||||||
$merged = array_replace($this->kanbanGitMcp->updatePayload($ticket), $input);
|
|
||||||
$merged['acceptance_criteria'] = Arr::exists($input, 'acceptance_criteria')
|
|
||||||
? AcceptanceCriteriaChecklistMapper::normalize($input['acceptance_criteria'])
|
|
||||||
: AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria()->get());
|
|
||||||
|
|
||||||
$validator = Validator::make($merged, $form->rules());
|
|
||||||
$form->setValidator($validator);
|
|
||||||
$validator->validate();
|
|
||||||
foreach ($form->after() as $callback) {
|
|
||||||
$callback($validator);
|
|
||||||
}
|
|
||||||
|
|
||||||
$people = User::query()->visibleToProject($ticket->project)->pluck('id')->all();
|
|
||||||
foreach (['assignee_user_id', 'author_user_id'] as $field) {
|
|
||||||
if (Arr::exists($input, $field) && $input[$field] !== null && ! in_array((int) $input[$field], $people, true)) {
|
|
||||||
$validator->errors()->add($field, 'The person must be able to view this project.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$definitions = $ticket->project->ticketFieldDefinitions()->get()->keyBy('id');
|
|
||||||
foreach (Arr::get($input, 'field_values', []) as $index => $value) {
|
|
||||||
$definition = $definitions->get($value['ticket_field_definition_id']);
|
|
||||||
$incompatible = $definition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
|
||||||
if (Arr::get($value, $incompatible) !== null) {
|
|
||||||
$validator->errors()->add("field_values.{$index}.{$incompatible}", 'The value must match the custom field type.');
|
|
||||||
}
|
|
||||||
if (Arr::get($value, 'value_user_id') !== null && ! in_array((int) $value['value_user_id'], $people, true)) {
|
|
||||||
$validator->errors()->add("field_values.{$index}.value_user_id", 'The person must be able to view this project.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($validator->errors()->isNotEmpty()) {
|
|
||||||
throw new ValidationException($validator);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Arr::exists($input, 'field_values')) {
|
|
||||||
$replacement = $definitions->map(fn (TicketFieldDefinition $definition): array => [
|
|
||||||
'ticket_field_definition_id' => $definition->id,
|
|
||||||
'value_number' => null,
|
|
||||||
'value_user_id' => null,
|
|
||||||
])->all();
|
|
||||||
foreach ($input['field_values'] as $value) {
|
|
||||||
$replacement[$value['ticket_field_definition_id']] = $value;
|
|
||||||
}
|
|
||||||
$merged['field_values'] = array_values($replacement);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $merged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,11 @@ namespace App\Mcp\Tools;
|
|||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Actions\Tickets\CreateProjectTicket;
|
use App\Actions\Tickets\CreateProjectTicket;
|
||||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Mcp\Support\DescribesTicketWriting;
|
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Rules\VisualTicketDescription;
|
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -31,8 +28,6 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class CreateTicket extends Tool
|
class CreateTicket extends Tool
|
||||||
{
|
{
|
||||||
use DescribesTicketWriting;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly CreateProjectTicket $createProjectTicket,
|
private readonly CreateProjectTicket $createProjectTicket,
|
||||||
@@ -49,7 +44,7 @@ class CreateTicket extends Tool
|
|||||||
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'title' => ['required', 'string', 'max:255'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
'description' => ['nullable', 'string', new VisualTicketDescription],
|
'description' => ['nullable', 'string'],
|
||||||
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
||||||
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
||||||
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
||||||
@@ -145,7 +140,7 @@ class CreateTicket extends Tool
|
|||||||
return [
|
return [
|
||||||
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
||||||
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
||||||
'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()),
|
'description' => $schema->string()->nullable()->description('Ticket description.'),
|
||||||
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
||||||
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
||||||
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
||||||
@@ -161,7 +156,7 @@ class CreateTicket extends Tool
|
|||||||
'checked' => $schema->boolean()->default(false),
|
'checked' => $schema->boolean()->default(false),
|
||||||
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
||||||
])->withoutAdditionalProperties())
|
])->withoutAdditionalProperties())
|
||||||
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()),
|
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
|
||||||
'field_values' => $schema->array()
|
'field_values' => $schema->array()
|
||||||
->items($schema->object([
|
->items($schema->object([
|
||||||
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mcp\Tools;
|
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
|
||||||
use App\Enums\Permission;
|
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
|
||||||
use App\Models\TicketStatus;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
|
||||||
use Laravel\Mcp\Request;
|
|
||||||
use Laravel\Mcp\Response;
|
|
||||||
use Laravel\Mcp\ResponseFactory;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Description;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
|
||||||
use Laravel\Mcp\Server\Tool;
|
|
||||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
|
||||||
|
|
||||||
#[Name('get_project_configuration')]
|
|
||||||
#[Description('Get compact current configuration for a visible project: ordered columns, statuses with IDs/slugs/column assignment and allowed target status IDs, types, priorities, custom field definitions, and permitted people IDs/names. Reads live effective configuration, without template snapshots or full user/project objects. People are paginated (default 25, maximum 100); use people_page for subsequent pages. Author changes require project admin permission, reported in permissions.')]
|
|
||||||
#[IsReadOnly]
|
|
||||||
class GetProjectConfiguration extends Tool
|
|
||||||
{
|
|
||||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
|
||||||
{
|
|
||||||
$input = $request->validate([
|
|
||||||
'project_id' => ['required', 'integer'],
|
|
||||||
'people_page' => ['sometimes', 'integer', 'min:1'],
|
|
||||||
'people_per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
|
||||||
]);
|
|
||||||
$user = $this->kanbanGitMcp->user($request);
|
|
||||||
$project = $this->kanbanGitMcp->projectForRead($user, $input['project_id']);
|
|
||||||
$people = User::query()->visibleToProject($project)->orderBy('id')->paginate($input['people_per_page'] ?? 25, ['id', 'name'], 'people_page', $input['people_page'] ?? 1);
|
|
||||||
|
|
||||||
$columns = $project->boardColumns()->orderBy('id')->get(['id', 'name', 'position']);
|
|
||||||
$columnPositions = $columns->pluck('position', 'id');
|
|
||||||
$statuses = $project->ticketStatuses()->with('allowedTargetStatuses')->get()->sortBy(
|
|
||||||
fn (TicketStatus $status): array => [$columnPositions->get($status->board_column_id, 0), $status->position, $status->id],
|
|
||||||
)->values();
|
|
||||||
|
|
||||||
return Response::structured([
|
|
||||||
'data' => [
|
|
||||||
'project' => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name],
|
|
||||||
'permissions' => [
|
|
||||||
'can_write' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
|
||||||
'can_change_author' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
|
||||||
],
|
|
||||||
'board_columns' => $columns->toArray(),
|
|
||||||
'ticket_statuses' => $statuses->map(fn (TicketStatus $status): array => [
|
|
||||||
'id' => $status->id, 'name' => $status->name, 'slug' => $status->slug,
|
|
||||||
'position' => $status->position, 'board_column_id' => $status->board_column_id,
|
|
||||||
'allowed_target_status_ids' => $status->allowedTargetStatuses->pluck('id')->all(),
|
|
||||||
])->all(),
|
|
||||||
'ticket_types' => $project->ticketTypes()->orderBy('id')->get(['id', 'name', 'position', 'is_parent'])->toArray(),
|
|
||||||
'ticket_priorities' => $project->ticketPriorities()->orderBy('id')->get(['id', 'name', 'position'])->toArray(),
|
|
||||||
'ticket_field_definitions' => $project->ticketFieldDefinitions()->orderBy('id')->get(['id', 'label', 'field_type', 'position'])->toArray(),
|
|
||||||
'people' => $people->getCollection()->map(fn (User $person): array => ['id' => $person->id, 'name' => $person->name])->all(),
|
|
||||||
],
|
|
||||||
'meta' => ['people' => $this->kanbanGitMcp->paginationPayload($people)],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, Type> */
|
|
||||||
public function schema(JsonSchema $schema): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'project_id' => $schema->integer()->description('Visible project ID from list_projects.')->required(),
|
|
||||||
'people_page' => $schema->integer()->min(1)->description('People page number, defaults to 1.'),
|
|
||||||
'people_per_page' => $schema->integer()->min(1)->max(100)->description('People page size, defaults to 25; maximum 100.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
|
|||||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
#[Name('get_ticket')]
|
#[Name('get_ticket')]
|
||||||
#[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||||
#[IsReadOnly]
|
#[IsReadOnly]
|
||||||
class GetTicket extends Tool
|
class GetTicket extends Tool
|
||||||
{
|
{
|
||||||
@@ -25,14 +25,11 @@ class GetTicket extends Tool
|
|||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
|
'ticket_id' => ['required', 'integer'],
|
||||||
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->kanbanGitMcp->user($request);
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
$ticket = isset($validated['ticket_id'])
|
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
|
||||||
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
|
|
||||||
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
|
|
||||||
|
|
||||||
return Response::structured([
|
return Response::structured([
|
||||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
||||||
@@ -46,8 +43,8 @@ class GetTicket extends Tool
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'ticket_id' => $schema->integer()
|
'ticket_id' => $schema->integer()
|
||||||
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
|
->description('Ticket ID.')
|
||||||
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
|
->required(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mcp\Tools;
|
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
|
||||||
use App\Models\Project;
|
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
|
||||||
use Laravel\Mcp\Request;
|
|
||||||
use Laravel\Mcp\Response;
|
|
||||||
use Laravel\Mcp\ResponseFactory;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Description;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
|
||||||
use Laravel\Mcp\Server\Tool;
|
|
||||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
|
||||||
|
|
||||||
#[Name('list_projects')]
|
|
||||||
#[Description('List visible projects independently of assigned tickets. Returns only ID, key and name, ordered by ID, with bounded pagination. page starts at 1; per_page defaults to 25 and may not exceed 100.')]
|
|
||||||
#[IsReadOnly]
|
|
||||||
class ListProjects extends Tool
|
|
||||||
{
|
|
||||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
|
||||||
{
|
|
||||||
$input = $request->validate(['page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100']]);
|
|
||||||
$user = $this->kanbanGitMcp->user($request);
|
|
||||||
$projects = Project::query()->whereIn('id', $this->permissionService->viewableProjectIds($user))
|
|
||||||
->orderBy('id')->paginate($input['per_page'] ?? 25, ['id', 'key', 'name'], 'page', $input['page'] ?? 1);
|
|
||||||
|
|
||||||
return Response::structured([
|
|
||||||
'data' => $projects->getCollection()->map(fn (Project $project): array => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name])->all(),
|
|
||||||
'meta' => $this->kanbanGitMcp->paginationPayload($projects),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, Type> */
|
|
||||||
public function schema(JsonSchema $schema): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
|
|
||||||
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
use App\Actions\Tickets\MoveProjectTicket;
|
use App\Actions\Tickets\MoveProjectTicket;
|
||||||
use App\Mcp\Support\DescribesTicketWriting;
|
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
@@ -20,8 +19,6 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class MoveTicketStatus extends Tool
|
class MoveTicketStatus extends Tool
|
||||||
{
|
{
|
||||||
use DescribesTicketWriting;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly MoveProjectTicket $moveProjectTicket,
|
private readonly MoveProjectTicket $moveProjectTicket,
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mcp\Tools;
|
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
|
||||||
use App\Models\Ticket;
|
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Laravel\Mcp\Request;
|
|
||||||
use Laravel\Mcp\Response;
|
|
||||||
use Laravel\Mcp\ResponseFactory;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Description;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
|
||||||
use Laravel\Mcp\Server\Tool;
|
|
||||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
|
||||||
|
|
||||||
#[Name('search_tickets')]
|
|
||||||
#[Description('Search visible tickets with combined filters: exact full ticket key, literal text in title/description, project, status, type, assignee and parent. Omitted filters do not restrict results; assignee_user_id/parent_ticket_id null selects unassigned/top-level tickets. Returns compact ID, key, title, project ID and status with deterministic ID order and bounded pagination (default 25, maximum 100).')]
|
|
||||||
#[IsReadOnly]
|
|
||||||
class SearchTickets extends Tool
|
|
||||||
{
|
|
||||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
|
||||||
{
|
|
||||||
$input = $request->validate([
|
|
||||||
'ticket_key' => ['sometimes', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
|
||||||
'query' => ['sometimes', 'string', 'max:255'],
|
|
||||||
'project_id' => ['sometimes', 'integer'],
|
|
||||||
'ticket_status_id' => ['sometimes', 'integer'],
|
|
||||||
'ticket_type_id' => ['sometimes', 'integer'],
|
|
||||||
'assignee_user_id' => ['sometimes', 'nullable', 'integer'],
|
|
||||||
'parent_ticket_id' => ['sometimes', 'nullable', 'integer'],
|
|
||||||
'page' => ['sometimes', 'integer', 'min:1'],
|
|
||||||
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
|
||||||
]);
|
|
||||||
$user = $this->kanbanGitMcp->user($request);
|
|
||||||
$this->kanbanGitMcp->projectForRead($user, $input['project_id'] ?? null);
|
|
||||||
$query = Ticket::query()->whereIn('project_id', $this->permissionService->viewableProjectIds($user));
|
|
||||||
foreach (['ticket_key', 'project_id', 'ticket_status_id', 'ticket_type_id', 'assignee_user_id', 'parent_ticket_id'] as $filter) {
|
|
||||||
if (Arr::exists($input, $filter)) {
|
|
||||||
$query->where($filter, $input[$filter]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Arr::exists($input, 'query')) {
|
|
||||||
$pattern = '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $input['query']).'%';
|
|
||||||
$query->where(fn (Builder $q) => $q->whereRaw("title LIKE ? ESCAPE '!'", [$pattern])->orWhereRaw("description LIKE ? ESCAPE '!'", [$pattern]));
|
|
||||||
}
|
|
||||||
$tickets = $query->with('ticketStatus:id,name,slug,board_column_id')->orderBy('id')
|
|
||||||
->paginate($input['per_page'] ?? 25, ['id', 'ticket_key', 'title', 'project_id', 'ticket_status_id'], 'page', $input['page'] ?? 1);
|
|
||||||
|
|
||||||
return Response::structured([
|
|
||||||
'data' => $tickets->getCollection()->map(fn (Ticket $ticket): array => [
|
|
||||||
'id' => $ticket->id,
|
|
||||||
'ticket_key' => $ticket->ticket_key,
|
|
||||||
'title' => $ticket->title,
|
|
||||||
'project_id' => $ticket->project_id,
|
|
||||||
'ticket_status_id' => $ticket->ticket_status_id,
|
|
||||||
'status' => $ticket->ticketStatus === null ? null : ['id' => $ticket->ticketStatus->id, 'name' => $ticket->ticketStatus->name, 'slug' => $ticket->ticketStatus->slug],
|
|
||||||
])->all(),
|
|
||||||
'meta' => $this->kanbanGitMcp->paginationPayload($tickets),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, Type> */
|
|
||||||
public function schema(JsonSchema $schema): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'ticket_key' => $schema->string()->description('Exact full key, e.g. KAN-67.'),
|
|
||||||
'query' => $schema->string()->max(255)->description('Literal substring in title or description.'),
|
|
||||||
'project_id' => $schema->integer()->description('Visible project ID.'),
|
|
||||||
'ticket_status_id' => $schema->integer()->description('Status ID from get_project_configuration.'),
|
|
||||||
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
|
||||||
'assignee_user_id' => $schema->integer()->nullable()->description('Assignee ID; null selects unassigned tickets.'),
|
|
||||||
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent ID; null selects top-level tickets.'),
|
|
||||||
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
|
|
||||||
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mcp\Tools;
|
|
||||||
|
|
||||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
|
||||||
use App\Mcp\Support\DescribesTicketWriting;
|
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
|
||||||
use App\Mcp\Support\TicketUpdateInput;
|
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
|
||||||
use Laravel\Mcp\Request;
|
|
||||||
use Laravel\Mcp\Response;
|
|
||||||
use Laravel\Mcp\ResponseFactory;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Description;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
|
||||||
use Laravel\Mcp\Server\Tool;
|
|
||||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|
||||||
|
|
||||||
#[Name('update_ticket')]
|
|
||||||
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
|
|
||||||
#[IsDestructive(false)]
|
|
||||||
class UpdateTicket extends Tool
|
|
||||||
{
|
|
||||||
use DescribesTicketWriting;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
|
||||||
private readonly TicketUpdateInput $ticketUpdateInput,
|
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
|
||||||
{
|
|
||||||
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
|
|
||||||
$user = $this->kanbanGitMcp->user($request);
|
|
||||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
|
|
||||||
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
|
|
||||||
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
|
|
||||||
|
|
||||||
return Response::structured([
|
|
||||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
|
||||||
'meta' => ['updated' => $updated],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, Type> */
|
|
||||||
public function schema(JsonSchema $schema): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
|
|
||||||
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
|
|
||||||
'description' => $schema->string()->nullable()->description('Description; null clears it. '.TicketDescriptionFormat::instructions()),
|
|
||||||
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
|
||||||
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
|
|
||||||
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
|
|
||||||
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
|
|
||||||
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
|
|
||||||
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
|
|
||||||
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
|
|
||||||
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
|
|
||||||
'acceptance_criteria' => $schema->array()->items($schema->object([
|
|
||||||
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
|
|
||||||
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
|
|
||||||
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
|
|
||||||
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
|
|
||||||
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid. '.TicketDescriptionFormat::criteriaInstructions()),
|
|
||||||
'field_values' => $schema->array()->items($schema->object([
|
|
||||||
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
|
|
||||||
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
|
|
||||||
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
|
|
||||||
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
use App\Mcp\Support\DescribesTicketWriting;
|
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
@@ -20,8 +19,6 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class UpdateTicketAssignee extends Tool
|
class UpdateTicketAssignee extends Tool
|
||||||
{
|
{
|
||||||
use DescribesTicketWriting;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
|
|||||||
@@ -2,14 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
use App\Mcp\Support\DescribesTicketWriting;
|
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use App\Rules\VisualTicketDescription;
|
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
use Laravel\Mcp\ResponseFactory;
|
use Laravel\Mcp\ResponseFactory;
|
||||||
@@ -23,8 +19,6 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class UpdateTicketDescription extends Tool
|
class UpdateTicketDescription extends Tool
|
||||||
{
|
{
|
||||||
use DescribesTicketWriting;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
@@ -33,7 +27,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'description' => ['nullable', 'string', new VisualTicketDescription],
|
'description' => ['nullable', 'string'],
|
||||||
'ticket_id' => ['required', 'integer'],
|
'ticket_id' => ['required', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -41,7 +35,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
||||||
$payload = [
|
$payload = [
|
||||||
...$this->kanbanGitMcp->updatePayload($ticket),
|
...$this->kanbanGitMcp->updatePayload($ticket),
|
||||||
...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
|
'description' => $validated['description'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
||||||
@@ -64,7 +58,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
return [
|
return [
|
||||||
'description' => $schema->string()
|
'description' => $schema->string()
|
||||||
->nullable()
|
->nullable()
|
||||||
->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()),
|
->description('New ticket description. Pass null to clear it.'),
|
||||||
'ticket_id' => $schema->integer()
|
'ticket_id' => $schema->integer()
|
||||||
->description('Ticket ID.')
|
->description('Ticket ID.')
|
||||||
->required(),
|
->required(),
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Rules;
|
|
||||||
|
|
||||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
|
|
||||||
class VisualTicketDescription implements ValidationRule
|
|
||||||
{
|
|
||||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
|
||||||
{
|
|
||||||
if (! is_string($value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$unsupported = TicketDescriptionFormat::unsupportedContent($value);
|
|
||||||
if ($unsupported !== []) {
|
|
||||||
$fail('The description contains unsupported '.implode(', ', $unsupported).'. Remove these contents or show literal examples inside code. Use ordinary Markdown and safe links; put acceptance criteria only in acceptance_criteria. No ticket fields were changed.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+22
-26
@@ -1451,20 +1451,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v13.34.0",
|
"version": "v13.26.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel/framework.git",
|
||||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
|
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
|
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
|
||||||
"composer-runtime-api": "^2.2",
|
"composer-runtime-api": "^2.2",
|
||||||
"doctrine/inflector": "^2.0.5",
|
"doctrine/inflector": "^2.0.5",
|
||||||
"dragonmantank/cron-expression": "^3.4",
|
"dragonmantank/cron-expression": "^3.4",
|
||||||
@@ -1576,20 +1576,18 @@
|
|||||||
"league/flysystem-sftp-v3": "^3.25.1",
|
"league/flysystem-sftp-v3": "^3.25.1",
|
||||||
"mockery/mockery": "^1.6.10",
|
"mockery/mockery": "^1.6.10",
|
||||||
"opis/json-schema": "^2.4.1",
|
"opis/json-schema": "^2.4.1",
|
||||||
"orchestra/testbench-core": "^11.5.0",
|
"orchestra/testbench-core": "^11.0.0",
|
||||||
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
||||||
"php-http/discovery": "^1.15",
|
"php-http/discovery": "^1.15",
|
||||||
"phpstan/phpstan": "2.2.15",
|
"phpstan/phpstan": "^2.0",
|
||||||
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
||||||
"predis/predis": "^2.3 || ^3.0",
|
"predis/predis": "^2.3 || ^3.0",
|
||||||
"rector/rector": "2.6.3",
|
"rector/rector": "^2.3",
|
||||||
"resend/resend-php": "^1.0",
|
"resend/resend-php": "^1.0",
|
||||||
"symfony/cache": "^7.4.0 || ^8.0.0",
|
"symfony/cache": "^7.4.0 || ^8.0.0",
|
||||||
"symfony/http-client": "^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/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": {
|
"suggest": {
|
||||||
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
||||||
@@ -1625,10 +1623,8 @@
|
|||||||
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
|
"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/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/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/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",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -1678,7 +1674,7 @@
|
|||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"source": "https://github.com/laravel/framework"
|
||||||
},
|
},
|
||||||
"time": "2026-09-29T12:46:21+00:00"
|
"time": "2026-08-18T20:31:28+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/mcp",
|
"name": "laravel/mcp",
|
||||||
@@ -2007,16 +2003,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.10.3",
|
"version": "2.10.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2110,7 +2106,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-09-21T13:07:34+00:00"
|
"time": "2026-08-11T16:06:25+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
@@ -2196,16 +2192,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/flysystem",
|
"name": "league/flysystem",
|
||||||
"version": "3.36.0",
|
"version": "3.35.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/flysystem.git",
|
"url": "https://github.com/thephpleague/flysystem.git",
|
||||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
|
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
|
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
|
||||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
|
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2273,9 +2269,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
"issues": "https://github.com/thephpleague/flysystem/issues",
|
||||||
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
|
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
|
||||||
},
|
},
|
||||||
"time": "2026-09-02T08:00:27+00:00"
|
"time": "2026-07-06T14:42:07+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/flysystem-aws-s3-v3",
|
"name": "league/flysystem-aws-s3-v3",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.2.8",
|
"version": "0.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,5 +25,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.11.4"
|
"@tauri-apps/cli": "2.11.4"
|
||||||
},
|
},
|
||||||
"releaseNotes": "Fix blocked navigation after changing project creation options or other forms on iOS by displaying native confirmations for unsaved changes. Pending confirmations close safely when changing workspaces."
|
"releaseNotes": "Manage local workspace settings in the web app on macOS and iOS, with shared macOS update controls and native recovery for older or unavailable servers."
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.8"
|
version = "0.2.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.8"
|
version = "0.2.6"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
private var settingsAvailable = false
|
private var settingsAvailable = false
|
||||||
private var toolbarHeight: NSLayoutConstraint!
|
private var toolbarHeight: NSLayoutConstraint!
|
||||||
private var appearance: UISegmentedControl!
|
private var appearance: UISegmentedControl!
|
||||||
private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)?
|
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
@@ -52,8 +51,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
deinit { javaScriptConfirmation?.completion(false) }
|
|
||||||
|
|
||||||
private func text(_ key: String) -> String {
|
private func text(_ key: String) -> String {
|
||||||
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
|
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
|
||||||
}
|
}
|
||||||
@@ -202,7 +199,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
|
|
||||||
func open(_ url: URL) {
|
func open(_ url: URL) {
|
||||||
if server?.url == url, connection.ready { showWorkspace(); return }
|
if server?.url == url, connection.ready { showWorkspace(); return }
|
||||||
completeJavaScriptConfirmation(false)
|
|
||||||
let selected = WorkspaceServer.selecting(url, current: server)
|
let selected = WorkspaceServer.selecting(url, current: server)
|
||||||
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||||
defaults.set(data, forKey: serverKey)
|
defaults.set(data, forKey: serverKey)
|
||||||
@@ -269,7 +265,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func showSettings() {
|
@objc private func showSettings() {
|
||||||
completeJavaScriptConfirmation(false)
|
|
||||||
loading.stopAnimating()
|
loading.stopAnimating()
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
launcher.isHidden = false
|
launcher.isHidden = false
|
||||||
@@ -325,39 +320,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
|
||||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
|
||||||
guard webView === workspace, viewIfLoaded?.window != nil,
|
|
||||||
presentedViewController == nil, javaScriptConfirmation == nil else {
|
|
||||||
completionHandler(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let alert = UIAlertController(title: frame.request.url?.host ?? text("appName"),
|
|
||||||
message: message, preferredStyle: .alert)
|
|
||||||
let cancel = UIAlertAction(title: text("cancel"), style: .cancel) { [weak self] _ in
|
|
||||||
self?.completeJavaScriptConfirmation(false)
|
|
||||||
}
|
|
||||||
alert.addAction(cancel)
|
|
||||||
alert.addAction(UIAlertAction(title: text("ok"), style: .default) { [weak self] _ in
|
|
||||||
self?.completeJavaScriptConfirmation(true)
|
|
||||||
})
|
|
||||||
alert.preferredAction = cancel
|
|
||||||
javaScriptConfirmation = (alert, completionHandler)
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func completeJavaScriptConfirmation(_ accepted: Bool) {
|
|
||||||
guard let confirmation = javaScriptConfirmation else { return }
|
|
||||||
javaScriptConfirmation = nil
|
|
||||||
if confirmation.alert.presentingViewController == nil {
|
|
||||||
confirmation.completion(accepted)
|
|
||||||
} else {
|
|
||||||
confirmation.alert.dismiss(animated: false) {
|
|
||||||
confirmation.completion(accepted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||||
guard server?.allows(url) == true else {
|
guard server?.allows(url) == true else {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
|
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
|
||||||
"confirmSwitch" = "Switch server";
|
"confirmSwitch" = "Switch server";
|
||||||
"cancel" = "Cancel";
|
"cancel" = "Cancel";
|
||||||
"ok" = "OK";
|
|
||||||
"connecting" = "Connecting to your server…";
|
"connecting" = "Connecting to your server…";
|
||||||
"connected" = "Your workspace is open. Use Server settings to change servers.";
|
"connected" = "Your workspace is open. Use Server settings to change servers.";
|
||||||
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
|
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
|
||||||
|
|||||||
@@ -135,94 +135,6 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
"Changing workspaces must retain native gesture navigation")
|
"Changing workspaces must retain native gesture navigation")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testDirtyFormNavigationPresentsNativeConfirmation() async throws {
|
|
||||||
let animationsEnabled = UIView.areAnimationsEnabled
|
|
||||||
UIView.setAnimationsEnabled(false)
|
|
||||||
defer { UIView.setAnimationsEnabled(animationsEnabled) }
|
|
||||||
let name = "ios-confirmation-\(UUID())"
|
|
||||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
|
||||||
defer { defaults.removePersistentDomain(forName: name) }
|
|
||||||
let controller = WorkspaceViewController(defaults: defaults)
|
|
||||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
||||||
window.rootViewController = controller
|
|
||||||
window.makeKeyAndVisible()
|
|
||||||
defer { window.isHidden = true; window.rootViewController = nil }
|
|
||||||
let url = URL(string: "https://example.invalid/")!
|
|
||||||
controller.open(url)
|
|
||||||
let webview = try XCTUnwrap(controller.workspace)
|
|
||||||
webview.stopLoading()
|
|
||||||
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
|
|
||||||
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
|
|
||||||
<a id="back" href="#back">Back</a><script>
|
|
||||||
window.visits = 0;
|
|
||||||
document.querySelector('#back').onclick = event => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
|
|
||||||
};
|
|
||||||
</script></body></html>
|
|
||||||
""")
|
|
||||||
try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'")
|
|
||||||
_ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true")
|
|
||||||
let pristineVisits = try await webview.evaluateJavaScript("window.visits")
|
|
||||||
XCTAssertEqual(pristineVisits as? Int, 1)
|
|
||||||
XCTAssertNil(controller.presentedViewController)
|
|
||||||
_ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true")
|
|
||||||
var expectedVisits = 1
|
|
||||||
for (index, accepted) in [false, true, false, true].enumerated() {
|
|
||||||
let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
|
|
||||||
let alert = try await waitForConfirmation(controller)
|
|
||||||
XCTAssertEqual(alert.title, "example.invalid")
|
|
||||||
XCTAssertEqual(alert.message, "Discard unsaved changes?")
|
|
||||||
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default])
|
|
||||||
XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft")
|
|
||||||
if index == 3 {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
alert.dismiss(animated: false) { continuation.resume() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
controller.completeJavaScriptConfirmation(accepted)
|
|
||||||
controller.completeJavaScriptConfirmation(!accepted)
|
|
||||||
_ = try await click.value
|
|
||||||
if accepted { expectedVisits += 1 }
|
|
||||||
let visits = try await webview.evaluateJavaScript("window.visits")
|
|
||||||
XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each")
|
|
||||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
|
||||||
XCTAssertEqual(draft as? String, "Unsaved project")
|
|
||||||
XCTAssertNil(controller.presentedViewController)
|
|
||||||
}
|
|
||||||
|
|
||||||
let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert)
|
|
||||||
controller.present(otherAlert, animated: false)
|
|
||||||
let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')")
|
|
||||||
XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript")
|
|
||||||
XCTAssertTrue(controller.presentedViewController === otherAlert)
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
otherAlert.dismiss(animated: false) { continuation.resume() }
|
|
||||||
}
|
|
||||||
|
|
||||||
let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
|
|
||||||
_ = try await waitForConfirmation(controller)
|
|
||||||
controller.open(URL(string: "https://other.invalid/")!)
|
|
||||||
_ = try await pendingClick.value
|
|
||||||
XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation")
|
|
||||||
let oldVisits = try await webview.evaluateJavaScript("window.visits")
|
|
||||||
XCTAssertEqual(oldVisits as? Int, expectedVisits)
|
|
||||||
let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')")
|
|
||||||
XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations")
|
|
||||||
XCTAssertNil(controller.presentedViewController)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController {
|
|
||||||
for _ in 0..<100 {
|
|
||||||
if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert }
|
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
|
||||||
}
|
|
||||||
return try XCTUnwrap(controller.presentedViewController as? UIAlertController,
|
|
||||||
"Dirty forms must offer a visible confirmation instead of silently cancelling navigation")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
|
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
|
||||||
for body in [
|
for body in [
|
||||||
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
||||||
|
|||||||
@@ -33,13 +33,6 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
|
|
||||||
remoteInfo = "kanbangit-native_iOS";
|
|
||||||
};
|
|
||||||
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
|
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||||
@@ -55,6 +48,7 @@
|
|||||||
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
||||||
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
||||||
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
|
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
|
||||||
|
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
||||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||||
@@ -73,7 +67,6 @@
|
|||||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||||
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
|
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
|
||||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
||||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
|
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
|
||||||
@@ -196,7 +189,7 @@
|
|||||||
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
|
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */,
|
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
|
||||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
|
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
|
||||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
|
||||||
);
|
);
|
||||||
@@ -255,7 +248,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = "kanbangit-native_iOS";
|
productName = "kanbangit-native_iOS";
|
||||||
productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */;
|
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
|
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
|
||||||
@@ -286,7 +279,6 @@
|
|||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
|
|
||||||
);
|
);
|
||||||
name = WorkspaceTests;
|
name = WorkspaceTests;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -426,11 +418,6 @@
|
|||||||
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||||
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
|
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
|
||||||
targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
@@ -464,7 +451,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 7;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -480,7 +467,7 @@
|
|||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
MARKETING_VERSION = 0.2.8;
|
MARKETING_VERSION = 0.2.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = KanbanGit;
|
PRODUCT_NAME = KanbanGit;
|
||||||
@@ -641,7 +628,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 7;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -657,7 +644,7 @@
|
|||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
MARKETING_VERSION = 0.2.8;
|
MARKETING_VERSION = 0.2.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = KanbanGit;
|
PRODUCT_NAME = KanbanGit;
|
||||||
@@ -704,7 +691,6 @@
|
|||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
|
|
||||||
};
|
};
|
||||||
name = debug;
|
name = debug;
|
||||||
};
|
};
|
||||||
@@ -724,7 +710,6 @@
|
|||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
|
|
||||||
};
|
};
|
||||||
name = release;
|
name = release;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ settingGroups:
|
|||||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||||
MARKETING_VERSION: "0.2.8"
|
MARKETING_VERSION: "0.2.6"
|
||||||
CURRENT_PROJECT_VERSION: "7"
|
CURRENT_PROJECT_VERSION: "6"
|
||||||
configs:
|
configs:
|
||||||
debug:
|
debug:
|
||||||
CODE_SIGN_IDENTITY: Apple Development
|
CODE_SIGN_IDENTITY: Apple Development
|
||||||
@@ -40,7 +40,6 @@ targets:
|
|||||||
kanbangit-native_iOS:
|
kanbangit-native_iOS:
|
||||||
type: application
|
type: application
|
||||||
platform: iOS
|
platform: iOS
|
||||||
productName: KanbanGit
|
|
||||||
sources:
|
sources:
|
||||||
- path: Sources
|
- path: Sources
|
||||||
- path: ../../../ui/workspace-navigation.js
|
- path: ../../../ui/workspace-navigation.js
|
||||||
@@ -116,8 +115,6 @@ targets:
|
|||||||
WorkspaceTests:
|
WorkspaceTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
dependencies:
|
|
||||||
- target: kanbangit-native_iOS
|
|
||||||
sources:
|
sources:
|
||||||
- path: ../../../ui/workspace-navigation.js
|
- path: ../../../ui/workspace-navigation.js
|
||||||
- path: ../../../ui/workspace-settings.js
|
- path: ../../../ui/workspace-settings.js
|
||||||
@@ -129,7 +126,6 @@ targets:
|
|||||||
base:
|
base:
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
|
||||||
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
|
|
||||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
CODE_SIGN_STYLE: Manual
|
CODE_SIGN_STYLE: Manual
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
|
|||||||
@@ -1,55 +1,6 @@
|
|||||||
#import <AppKit/AppKit.h>
|
|
||||||
#import <WebKit/WebKit.h>
|
#import <WebKit/WebKit.h>
|
||||||
#import <objc/runtime.h>
|
|
||||||
|
|
||||||
@interface KanbanWorkspaceUIDelegate : NSObject <WKUIDelegate>
|
|
||||||
@property(nonatomic, strong) id<WKUIDelegate> originalDelegate;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KanbanWorkspaceUIDelegate
|
|
||||||
- (BOOL)respondsToSelector:(SEL)selector {
|
|
||||||
return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (id)forwardingTargetForSelector:(SEL)selector {
|
|
||||||
if ([self.originalDelegate respondsToSelector:selector]) {
|
|
||||||
return self.originalDelegate;
|
|
||||||
}
|
|
||||||
return [super forwardingTargetForSelector:selector];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message
|
|
||||||
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
|
|
||||||
NSWindow *window = webView.window;
|
|
||||||
if (!window || window.attachedSheet) {
|
|
||||||
completionHandler(NO);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSAlert *alert = [NSAlert new];
|
|
||||||
alert.alertStyle = NSAlertStyleWarning;
|
|
||||||
alert.messageText = frame.request.URL.host ?: @"KanbanGit";
|
|
||||||
alert.informativeText = message;
|
|
||||||
[alert addButtonWithTitle:@"OK"];
|
|
||||||
[alert addButtonWithTitle:@"Cancel"];
|
|
||||||
[alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
|
|
||||||
completionHandler(response == NSAlertFirstButtonReturn);
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
@end
|
|
||||||
|
|
||||||
static char workspaceUIDelegateKey;
|
|
||||||
|
|
||||||
void install_workspace_navigation(void *pointer) {
|
void install_workspace_navigation(void *pointer) {
|
||||||
WKWebView *webview = (__bridge WKWebView *)pointer;
|
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||||
webview.allowsBackForwardNavigationGestures = YES;
|
webview.allowsBackForwardNavigationGestures = YES;
|
||||||
if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new];
|
|
||||||
delegate.originalDelegate = webview.UIDelegate;
|
|
||||||
// WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view.
|
|
||||||
objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
|
||||||
webview.UIDelegate = delegate;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "KanbanGit",
|
"productName": "KanbanGit",
|
||||||
"version": "0.2.8",
|
"version": "0.2.6",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "14.0",
|
"minimumSystemVersion": "14.0",
|
||||||
"bundleVersion": "12",
|
"bundleVersion": "11",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"iOS": {
|
"iOS": {
|
||||||
"minimumSystemVersion": "17.0",
|
"minimumSystemVersion": "17.0",
|
||||||
"developmentTeam": "ZZX2KZGTHU",
|
"developmentTeam": "ZZX2KZGTHU",
|
||||||
"bundleVersion": "7"
|
"bundleVersion": "6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,53 +17,6 @@ static void waitUntil(BOOL (^condition)(void), NSString *message) {
|
|||||||
check(condition(), message);
|
check(condition(), message);
|
||||||
}
|
}
|
||||||
|
|
||||||
static id evaluate(WKWebView *webview, NSString *script) {
|
|
||||||
__block BOOL finished = NO;
|
|
||||||
__block id result;
|
|
||||||
[webview evaluateJavaScript:script completionHandler:^(id value, NSError *error) {
|
|
||||||
check(error == nil, error.localizedDescription);
|
|
||||||
result = value;
|
|
||||||
finished = YES;
|
|
||||||
}];
|
|
||||||
waitUntil(^BOOL { return finished; }, @"JavaScript must finish after the confirmation is dismissed");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static NSButton *findButton(NSView *view, NSString *title) {
|
|
||||||
if ([view isKindOfClass:NSButton.class] && [((NSButton *)view).title isEqualToString:title]) {
|
|
||||||
return (NSButton *)view;
|
|
||||||
}
|
|
||||||
for (NSView *child in view.subviews) {
|
|
||||||
NSButton *button = findButton(child, title);
|
|
||||||
if (button) { return button; }
|
|
||||||
}
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
static BOOL containsText(NSView *view, NSString *text) {
|
|
||||||
if ([view isKindOfClass:NSTextField.class] && [((NSTextField *)view).stringValue isEqualToString:text]) {
|
|
||||||
return YES;
|
|
||||||
}
|
|
||||||
for (NSView *child in view.subviews) {
|
|
||||||
if (containsText(child, text)) { return YES; }
|
|
||||||
}
|
|
||||||
return NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
@interface WorkspaceUI : NSObject <WKUIDelegate>
|
|
||||||
@property(nonatomic) BOOL closed;
|
|
||||||
@property(nonatomic) BOOL uploaded;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation WorkspaceUI
|
|
||||||
- (void)webViewDidClose:(WKWebView *)webView { self.closed = YES; }
|
|
||||||
- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters
|
|
||||||
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray<NSURL *> *))completionHandler {
|
|
||||||
self.uploaded = YES;
|
|
||||||
completionHandler(nil);
|
|
||||||
}
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
|
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
|
||||||
@property(nonatomic) NSUInteger finished;
|
@property(nonatomic) NSUInteger finished;
|
||||||
@end
|
@end
|
||||||
@@ -93,18 +46,9 @@ int main(void) {
|
|||||||
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
|
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
|
||||||
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
|
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
|
||||||
webview.navigationDelegate = pages;
|
webview.navigationDelegate = pages;
|
||||||
WorkspaceUI *originalUI = [WorkspaceUI new];
|
|
||||||
webview.UIDelegate = originalUI;
|
|
||||||
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
|
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
|
||||||
install_workspace_navigation((__bridge void *)webview);
|
install_workspace_navigation((__bridge void *)webview);
|
||||||
id<WKUIDelegate> installedUI = webview.UIDelegate;
|
|
||||||
install_workspace_navigation((__bridge void *)webview);
|
install_workspace_navigation((__bridge void *)webview);
|
||||||
check(webview.UIDelegate == installedUI, @"Installing workspace behavior twice must retain the same delegate");
|
|
||||||
check([installedUI respondsToSelector:@selector(webViewDidClose:)], @"Existing popup callbacks must remain available");
|
|
||||||
[installedUI webViewDidClose:webview];
|
|
||||||
check(originalUI.closed, @"Existing popup callbacks must reach the original delegate");
|
|
||||||
check([installedUI respondsToSelector:@selector(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:)],
|
|
||||||
@"Existing file upload callbacks must remain available");
|
|
||||||
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
|
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
|
||||||
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
|
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
|
||||||
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
|
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
|
||||||
@@ -125,41 +69,6 @@ int main(void) {
|
|||||||
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
|
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
|
||||||
@"Forward must restore the second page");
|
@"Forward must restore the second page");
|
||||||
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
|
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
|
||||||
|
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
|
||||||
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
|
|
||||||
styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
|
|
||||||
window.releasedWhenClosed = NO;
|
|
||||||
window.title = @"KanbanGit navigation test";
|
|
||||||
window.contentView = webview;
|
|
||||||
[window orderFront:nil];
|
|
||||||
evaluate(webview, @"document.body.innerHTML = `<input id='duplicate' type='radio'><input id='draft' value='Unsaved project'><input id='upload' type='file'><a id='back' href='#back'>Back</a>`; window.visits = 0; document.querySelector('#back').onclick = event => { event.preventDefault(); if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++; }; true;");
|
|
||||||
evaluate(webview, @"document.querySelector('#upload').click()");
|
|
||||||
waitUntil(^BOOL { return originalUI.uploaded; }, @"File selection must still reach the original UI delegate");
|
|
||||||
evaluate(webview, @"document.querySelector('#back').click()");
|
|
||||||
check([evaluate(webview, @"window.visits") intValue] == 1, @"Pristine forms must navigate without a confirmation");
|
|
||||||
evaluate(webview, @"document.querySelector('#duplicate').click()");
|
|
||||||
for (NSString *choice in @[@"Cancel", @"OK", @"Cancel", @"OK"]) {
|
|
||||||
__block BOOL finished = NO;
|
|
||||||
[webview evaluateJavaScript:@"document.querySelector('#back').click()" completionHandler:^(id result, NSError *error) {
|
|
||||||
check(error == nil, error.localizedDescription);
|
|
||||||
finished = YES;
|
|
||||||
}];
|
|
||||||
waitUntil(^BOOL { return window.attachedSheet != nil; }, @"Dirty form navigation must show a native confirmation");
|
|
||||||
check(!finished, @"The original click must wait for the user's decision");
|
|
||||||
check(containsText(window.attachedSheet.contentView, @"workspace"), @"The confirmation must identify the requesting website");
|
|
||||||
check(containsText(window.attachedSheet.contentView, @"Discard unsaved changes?"), @"The confirmation must show the application's message");
|
|
||||||
NSButton *button = findButton(window.attachedSheet.contentView, choice);
|
|
||||||
check(button != nil, @"The native confirmation must offer OK and Cancel");
|
|
||||||
[button performClick:nil];
|
|
||||||
waitUntil(^BOOL { return finished && window.attachedSheet == nil; }, @"Dismissal must release the original navigation");
|
|
||||||
check([evaluate(webview, @"document.querySelector('#draft').value") isEqualToString:@"Unsaved project"],
|
|
||||||
@"Cancelling a confirmation must preserve the draft");
|
|
||||||
}
|
|
||||||
check([evaluate(webview, @"window.visits") intValue] == 3, @"Only accepted visits must run, exactly once each");
|
|
||||||
window.contentView = nil;
|
|
||||||
check(![evaluate(webview, @"confirm('Detached view')") boolValue], @"A detached view must cancel and release JavaScript immediately");
|
|
||||||
[window orderOut:nil];
|
|
||||||
[window close];
|
|
||||||
puts("PASS: WebKit interactive gesture configuration, full-page back/forward, native confirmations and delegate forwarding");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import test from 'node:test';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'WebKit preserves history and confirms dirty form navigation without losing delegate callbacks',
|
'WebKit owns interactive navigation and preserves native back/forward history',
|
||||||
{ skip: process.platform !== 'darwin' },
|
{ skip: process.platform !== 'darwin' },
|
||||||
() => {
|
() => {
|
||||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
||||||
@@ -48,7 +48,7 @@ test(
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
result.status,
|
result.status,
|
||||||
0,
|
0,
|
||||||
result.stderr || result.error?.message || result.signal,
|
result.stderr || result.error?.message,
|
||||||
);
|
);
|
||||||
assert.match(result.stdout, /PASS: WebKit/);
|
assert.match(result.stdout, /PASS: WebKit/);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+2
-2
@@ -15,7 +15,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
"lint:check": "eslint .",
|
"lint:check": "eslint .",
|
||||||
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
|
"types:check": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.1",
|
"tailwindcss": "^4.1.1",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
"vue": "^3.5.43",
|
"vue": "^3.5.13",
|
||||||
"vue-input-otp": "^0.3.2"
|
"vue-input-otp": "^0.3.2"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
Generated
+141
-167
@@ -203,17 +203,6 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
overrides:
|
|
||||||
'@vue/server-renderer@<3.5.42': ^3.5.42
|
|
||||||
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
|
|
||||||
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
|
|
||||||
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
|
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -223,7 +212,7 @@ importers:
|
|||||||
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
'@inertiajs/vue3':
|
'@inertiajs/vue3':
|
||||||
specifier: ^2.3.7
|
specifier: ^2.3.7
|
||||||
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.43(typescript@5.9.3))
|
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))
|
||||||
'@tiptap/core':
|
'@tiptap/core':
|
||||||
specifier: ^3.31.3
|
specifier: ^3.31.3
|
||||||
version: 3.31.3(@tiptap/pm@3.31.3)
|
version: 3.31.3(@tiptap/pm@3.31.3)
|
||||||
@@ -238,7 +227,7 @@ importers:
|
|||||||
version: 3.31.3
|
version: 3.31.3
|
||||||
'@tiptap/vue-3':
|
'@tiptap/vue-3':
|
||||||
specifier: ^3.31.3
|
specifier: ^3.31.3
|
||||||
version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.43(typescript@5.9.3))
|
version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^12.8.2
|
specifier: ^12.8.2
|
||||||
version: 12.8.2(typescript@5.9.3)
|
version: 12.8.2(typescript@5.9.3)
|
||||||
@@ -253,10 +242,10 @@ importers:
|
|||||||
version: 3.0.0(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))
|
version: 3.0.0(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
lucide-vue-next:
|
lucide-vue-next:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0(vue@3.5.43(typescript@5.9.3))
|
version: 0.468.0(vue@3.5.30(typescript@5.9.3))
|
||||||
reka-ui:
|
reka-ui:
|
||||||
specifier: ^2.6.1
|
specifier: ^2.6.1
|
||||||
version: 2.9.2(vue@3.5.43(typescript@5.9.3))
|
version: 2.9.2(vue@3.5.30(typescript@5.9.3))
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -267,11 +256,11 @@ importers:
|
|||||||
specifier: ^1.2.5
|
specifier: ^1.2.5
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.43
|
specifier: ^3.5.13
|
||||||
version: 3.5.43(typescript@5.9.3)
|
version: 3.5.30(typescript@5.9.3)
|
||||||
vue-input-otp:
|
vue-input-otp:
|
||||||
specifier: ^0.3.2
|
specifier: ^0.3.2
|
||||||
version: 0.3.2(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
|
version: 0.3.2(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.4
|
specifier: ^9.39.4
|
||||||
@@ -296,7 +285,7 @@ importers:
|
|||||||
version: 8.58.1(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
|
version: 8.58.1(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.43(typescript@5.9.3))
|
version: 6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.30(typescript@5.9.3))
|
||||||
'@vue/eslint-config-typescript':
|
'@vue/eslint-config-typescript':
|
||||||
specifier: ^14.3.0
|
specifier: ^14.3.0
|
||||||
version: 14.7.0(eslint-plugin-vue@9.33.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1))(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
|
version: 14.7.0(eslint-plugin-vue@9.33.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1))(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
|
||||||
@@ -373,21 +362,21 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.29.7':
|
'@babel/helper-string-parser@7.27.1':
|
||||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.29.7':
|
'@babel/helper-validator-identifier@7.28.5':
|
||||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/parser@7.29.9':
|
'@babel/parser@7.29.2':
|
||||||
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
|
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@babel/types@7.29.8':
|
'@babel/types@7.29.0':
|
||||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@emnapi/core@1.9.1':
|
'@emnapi/core@1.9.1':
|
||||||
@@ -1112,17 +1101,17 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vue/compiler-core@3.5.43':
|
'@vue/compiler-core@3.5.30':
|
||||||
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
|
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
|
||||||
|
|
||||||
'@vue/compiler-dom@3.5.43':
|
'@vue/compiler-dom@3.5.30':
|
||||||
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
|
resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
|
||||||
|
|
||||||
'@vue/compiler-sfc@3.5.43':
|
'@vue/compiler-sfc@3.5.30':
|
||||||
resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==}
|
resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
|
||||||
|
|
||||||
'@vue/compiler-ssr@3.5.43':
|
'@vue/compiler-ssr@3.5.30':
|
||||||
resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==}
|
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
|
||||||
|
|
||||||
'@vue/compiler-vue2@2.7.16':
|
'@vue/compiler-vue2@2.7.16':
|
||||||
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
|
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
|
||||||
@@ -1146,20 +1135,22 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vue/reactivity@3.5.43':
|
'@vue/reactivity@3.5.30':
|
||||||
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
|
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
|
||||||
|
|
||||||
'@vue/runtime-core@3.5.43':
|
'@vue/runtime-core@3.5.30':
|
||||||
resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
|
resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
|
||||||
|
|
||||||
'@vue/runtime-dom@3.5.43':
|
'@vue/runtime-dom@3.5.30':
|
||||||
resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
|
resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
|
||||||
|
|
||||||
'@vue/server-renderer@3.5.43':
|
'@vue/server-renderer@3.5.30':
|
||||||
resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
|
resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: 3.5.30
|
||||||
|
|
||||||
'@vue/shared@3.5.43':
|
'@vue/shared@3.5.30':
|
||||||
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
|
resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
|
||||||
|
|
||||||
'@vueuse/core@12.8.2':
|
'@vueuse/core@12.8.2':
|
||||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||||
@@ -1253,8 +1244,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.20.0:
|
axios@1.19.0:
|
||||||
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
@@ -1266,14 +1257,14 @@ packages:
|
|||||||
boolbase@1.0.0:
|
boolbase@1.0.0:
|
||||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
brace-expansion@1.1.21:
|
brace-expansion@1.1.18:
|
||||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||||
|
|
||||||
brace-expansion@2.1.7:
|
brace-expansion@2.1.4:
|
||||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||||
|
|
||||||
brace-expansion@5.0.12:
|
brace-expansion@5.0.9:
|
||||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
@@ -2167,11 +2158,6 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
nanoid@3.3.20:
|
|
||||||
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
|
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
napi-postinstall@0.3.4:
|
napi-postinstall@0.3.4:
|
||||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
@@ -2270,10 +2256,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
postcss@8.5.29:
|
|
||||||
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
|
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2490,8 +2472,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
shell-quote@1.12.0:
|
shell-quote@1.9.0:
|
||||||
resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==}
|
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
side-channel-list@1.0.1:
|
side-channel-list@1.0.1:
|
||||||
@@ -2510,8 +2492,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
source-map-js@1.2.2:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
stable-hash-x@0.2.0:
|
stable-hash-x@0.2.0:
|
||||||
@@ -2738,8 +2720,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=5.0.0'
|
typescript: '>=5.0.0'
|
||||||
|
|
||||||
vue@3.5.43:
|
vue@3.5.30:
|
||||||
resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
|
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '*'
|
typescript: '*'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
@@ -2800,18 +2782,18 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.29.7': {}
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.29.7': {}
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
'@babel/parser@7.29.9':
|
'@babel/parser@7.29.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.8
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
'@babel/types@7.29.8':
|
'@babel/types@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-string-parser': 7.29.7
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.29.7
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@emnapi/core@1.9.1':
|
'@emnapi/core@1.9.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2886,11 +2868,11 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.11': {}
|
'@floating-ui/utils@0.2.11': {}
|
||||||
|
|
||||||
'@floating-ui/vue@1.1.11(vue@3.5.43(typescript@5.9.3))':
|
'@floating-ui/vue@1.1.11(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.6
|
'@floating-ui/dom': 1.7.6
|
||||||
'@floating-ui/utils': 0.2.11
|
'@floating-ui/utils': 0.2.11
|
||||||
vue-demi: 0.14.10(vue@3.5.43(typescript@5.9.3))
|
vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
@@ -2914,7 +2896,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.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
axios: 1.19.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
|
||||||
@@ -2922,13 +2904,13 @@ snapshots:
|
|||||||
- debug
|
- debug
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@inertiajs/vue3@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.43(typescript@5.9.3))':
|
'@inertiajs/vue3@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@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)
|
||||||
'@types/lodash-es': 4.17.12
|
'@types/lodash-es': 4.17.12
|
||||||
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
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -3063,7 +3045,7 @@ snapshots:
|
|||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
source-map-js: 1.2.2
|
source-map-js: 1.2.1
|
||||||
tailwindcss: 4.2.2
|
tailwindcss: 4.2.2
|
||||||
|
|
||||||
'@tailwindcss/oxide-android-arm64@4.2.2':
|
'@tailwindcss/oxide-android-arm64@4.2.2':
|
||||||
@@ -3126,10 +3108,10 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/virtual-core@3.13.23': {}
|
'@tanstack/virtual-core@3.13.23': {}
|
||||||
|
|
||||||
'@tanstack/vue-virtual@3.13.23(vue@3.5.43(typescript@5.9.3))':
|
'@tanstack/vue-virtual@3.13.23(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.13.23
|
'@tanstack/virtual-core': 3.13.23
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
|
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3293,12 +3275,12 @@ snapshots:
|
|||||||
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
'@tiptap/pm': 3.31.3
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
'@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.43(typescript@5.9.3))':
|
'@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.6
|
'@floating-ui/dom': 1.7.6
|
||||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
'@tiptap/pm': 3.31.3
|
'@tiptap/pm': 3.31.3
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
'@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
'@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
'@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
@@ -3476,11 +3458,11 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vitejs/plugin-vue@6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.43(typescript@5.9.3))':
|
'@vitejs/plugin-vue@6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.2
|
'@rolldown/pluginutils': 1.0.0-rc.2
|
||||||
vite: 8.2.2(@types/node@22.19.15)(jiti@2.6.1)
|
vite: 8.2.2(@types/node@22.19.15)(jiti@2.6.1)
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
'@volar/language-core@2.4.15':
|
'@volar/language-core@2.4.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3496,35 +3478,35 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@vue/compiler-core@3.5.43':
|
'@vue/compiler-core@3.5.30':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.9
|
'@babel/parser': 7.29.2
|
||||||
'@vue/shared': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
entities: 7.0.1
|
entities: 7.0.1
|
||||||
estree-walker: 2.0.2
|
estree-walker: 2.0.2
|
||||||
source-map-js: 1.2.2
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
'@vue/compiler-dom@3.5.43':
|
'@vue/compiler-dom@3.5.30':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-core': 3.5.43
|
'@vue/compiler-core': 3.5.30
|
||||||
'@vue/shared': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
|
|
||||||
'@vue/compiler-sfc@3.5.43':
|
'@vue/compiler-sfc@3.5.30':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.9
|
'@babel/parser': 7.29.2
|
||||||
'@vue/compiler-core': 3.5.43
|
'@vue/compiler-core': 3.5.30
|
||||||
'@vue/compiler-dom': 3.5.43
|
'@vue/compiler-dom': 3.5.30
|
||||||
'@vue/compiler-ssr': 3.5.43
|
'@vue/compiler-ssr': 3.5.30
|
||||||
'@vue/shared': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
estree-walker: 2.0.2
|
estree-walker: 2.0.2
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
postcss: 8.5.29
|
postcss: 8.5.26
|
||||||
source-map-js: 1.2.2
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
'@vue/compiler-ssr@3.5.43':
|
'@vue/compiler-ssr@3.5.30':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-dom': 3.5.43
|
'@vue/compiler-dom': 3.5.30
|
||||||
'@vue/shared': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
|
|
||||||
'@vue/compiler-vue2@2.7.16':
|
'@vue/compiler-vue2@2.7.16':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3547,9 +3529,9 @@ snapshots:
|
|||||||
'@vue/language-core@2.2.12(typescript@5.9.3)':
|
'@vue/language-core@2.2.12(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/language-core': 2.4.15
|
'@volar/language-core': 2.4.15
|
||||||
'@vue/compiler-dom': 3.5.43
|
'@vue/compiler-dom': 3.5.30
|
||||||
'@vue/compiler-vue2': 2.7.16
|
'@vue/compiler-vue2': 2.7.16
|
||||||
'@vue/shared': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
alien-signals: 1.0.13
|
alien-signals: 1.0.13
|
||||||
minimatch: 9.0.9
|
minimatch: 9.0.9
|
||||||
muggle-string: 0.4.1
|
muggle-string: 0.4.1
|
||||||
@@ -3557,45 +3539,45 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@vue/reactivity@3.5.43':
|
'@vue/reactivity@3.5.30':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/shared': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
|
|
||||||
'@vue/runtime-core@3.5.43':
|
'@vue/runtime-core@3.5.30':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/reactivity': 3.5.43
|
'@vue/reactivity': 3.5.30
|
||||||
'@vue/shared': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
|
|
||||||
'@vue/runtime-dom@3.5.43':
|
'@vue/runtime-dom@3.5.30':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/reactivity': 3.5.43
|
'@vue/reactivity': 3.5.30
|
||||||
'@vue/runtime-core': 3.5.43
|
'@vue/runtime-core': 3.5.30
|
||||||
'@vue/shared': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
'@vue/server-renderer@3.5.43':
|
'@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-ssr': 3.5.43
|
'@vue/compiler-ssr': 3.5.30
|
||||||
'@vue/runtime-dom': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
'@vue/shared': 3.5.43
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
'@vue/shared@3.5.43': {}
|
'@vue/shared@3.5.30': {}
|
||||||
|
|
||||||
'@vueuse/core@12.8.2(typescript@5.9.3)':
|
'@vueuse/core@12.8.2(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/web-bluetooth': 0.0.21
|
'@types/web-bluetooth': 0.0.21
|
||||||
'@vueuse/metadata': 12.8.2
|
'@vueuse/metadata': 12.8.2
|
||||||
'@vueuse/shared': 12.8.2(typescript@5.9.3)
|
'@vueuse/shared': 12.8.2(typescript@5.9.3)
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
'@vueuse/core@14.2.1(vue@3.5.43(typescript@5.9.3))':
|
'@vueuse/core@14.2.1(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/web-bluetooth': 0.0.21
|
'@types/web-bluetooth': 0.0.21
|
||||||
'@vueuse/metadata': 14.2.1
|
'@vueuse/metadata': 14.2.1
|
||||||
'@vueuse/shared': 14.2.1(vue@3.5.43(typescript@5.9.3))
|
'@vueuse/shared': 14.2.1(vue@3.5.30(typescript@5.9.3))
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
'@vueuse/metadata@12.8.2': {}
|
'@vueuse/metadata@12.8.2': {}
|
||||||
|
|
||||||
@@ -3603,13 +3585,13 @@ snapshots:
|
|||||||
|
|
||||||
'@vueuse/shared@12.8.2(typescript@5.9.3)':
|
'@vueuse/shared@12.8.2(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
'@vueuse/shared@14.2.1(vue@3.5.43(typescript@5.9.3))':
|
'@vueuse/shared@14.2.1(vue@3.5.30(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.16.0):
|
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3702,7 +3684,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
|
|
||||||
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
axios@1.19.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
|
||||||
@@ -3718,16 +3700,16 @@ snapshots:
|
|||||||
|
|
||||||
boolbase@1.0.0: {}
|
boolbase@1.0.0: {}
|
||||||
|
|
||||||
brace-expansion@1.1.21:
|
brace-expansion@1.1.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
concat-map: 0.0.1
|
concat-map: 0.0.1
|
||||||
|
|
||||||
brace-expansion@2.1.7:
|
brace-expansion@2.1.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
brace-expansion@5.0.12:
|
brace-expansion@5.0.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.4
|
balanced-match: 4.0.4
|
||||||
|
|
||||||
@@ -3787,7 +3769,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
shell-quote: 1.12.0
|
shell-quote: 1.9.0
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
tree-kill: 1.2.2
|
tree-kill: 1.2.2
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
@@ -4470,7 +4452,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.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
axios: 1.19.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
|
||||||
@@ -4598,9 +4580,9 @@ snapshots:
|
|||||||
|
|
||||||
lodash@4.18.1: {}
|
lodash@4.18.1: {}
|
||||||
|
|
||||||
lucide-vue-next@0.468.0(vue@3.5.43(typescript@5.9.3)):
|
lucide-vue-next@0.468.0(vue@3.5.30(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4625,15 +4607,15 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.12
|
brace-expansion: 5.0.9
|
||||||
|
|
||||||
minimatch@3.1.5:
|
minimatch@3.1.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.21
|
brace-expansion: 1.1.18
|
||||||
|
|
||||||
minimatch@9.0.9:
|
minimatch@9.0.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 2.1.7
|
brace-expansion: 2.1.4
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
@@ -4643,8 +4625,6 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.18: {}
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
nanoid@3.3.20: {}
|
|
||||||
|
|
||||||
napi-postinstall@0.3.4: {}
|
napi-postinstall@0.3.4: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
@@ -4742,13 +4722,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.18
|
nanoid: 3.3.18
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.2
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
postcss@8.5.29:
|
|
||||||
dependencies:
|
|
||||||
nanoid: 3.3.20
|
|
||||||
picocolors: 1.1.1
|
|
||||||
source-map-js: 1.2.2
|
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
@@ -4867,19 +4841,19 @@ snapshots:
|
|||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
set-function-name: 2.0.2
|
set-function-name: 2.0.2
|
||||||
|
|
||||||
reka-ui@2.9.2(vue@3.5.43(typescript@5.9.3)):
|
reka-ui@2.9.2(vue@3.5.30(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.6
|
'@floating-ui/dom': 1.7.6
|
||||||
'@floating-ui/vue': 1.1.11(vue@3.5.43(typescript@5.9.3))
|
'@floating-ui/vue': 1.1.11(vue@3.5.30(typescript@5.9.3))
|
||||||
'@internationalized/date': 3.12.0
|
'@internationalized/date': 3.12.0
|
||||||
'@internationalized/number': 3.6.5
|
'@internationalized/number': 3.6.5
|
||||||
'@tanstack/vue-virtual': 3.13.23(vue@3.5.43(typescript@5.9.3))
|
'@tanstack/vue-virtual': 3.13.23(vue@3.5.30(typescript@5.9.3))
|
||||||
'@vueuse/core': 14.2.1(vue@3.5.43(typescript@5.9.3))
|
'@vueuse/core': 14.2.1(vue@3.5.30(typescript@5.9.3))
|
||||||
'@vueuse/shared': 14.2.1(vue@3.5.43(typescript@5.9.3))
|
'@vueuse/shared': 14.2.1(vue@3.5.30(typescript@5.9.3))
|
||||||
aria-hidden: 1.2.6
|
aria-hidden: 1.2.6
|
||||||
defu: 6.1.7
|
defu: 6.1.7
|
||||||
ohash: 2.0.11
|
ohash: 2.0.11
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
|
|
||||||
@@ -4979,7 +4953,7 @@ snapshots:
|
|||||||
|
|
||||||
shebang-regex@3.0.0: {}
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
shell-quote@1.12.0: {}
|
shell-quote@1.9.0: {}
|
||||||
|
|
||||||
side-channel-list@1.0.1:
|
side-channel-list@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5009,7 +4983,7 @@ snapshots:
|
|||||||
side-channel-map: 1.0.1
|
side-channel-map: 1.0.1
|
||||||
side-channel-weakmap: 1.0.2
|
side-channel-weakmap: 1.0.2
|
||||||
|
|
||||||
source-map-js@1.2.2: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
stable-hash-x@0.2.0: {}
|
stable-hash-x@0.2.0: {}
|
||||||
|
|
||||||
@@ -5211,9 +5185,9 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
vue-demi@0.14.10(vue@3.5.43(typescript@5.9.3)):
|
vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1):
|
vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5240,11 +5214,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
vue-input-otp@0.3.2(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)):
|
vue-input-otp@0.3.2(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vueuse/core': 12.8.2(typescript@5.9.3)
|
'@vueuse/core': 12.8.2(typescript@5.9.3)
|
||||||
reka-ui: 2.9.2(vue@3.5.43(typescript@5.9.3))
|
reka-ui: 2.9.2(vue@3.5.30(typescript@5.9.3))
|
||||||
vue: 3.5.43(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- typescript
|
- typescript
|
||||||
@@ -5255,13 +5229,13 @@ snapshots:
|
|||||||
'@vue/language-core': 2.2.12(typescript@5.9.3)
|
'@vue/language-core': 2.2.12(typescript@5.9.3)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
vue@3.5.43(typescript@5.9.3):
|
vue@3.5.30(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/compiler-dom': 3.5.43
|
'@vue/compiler-dom': 3.5.30
|
||||||
'@vue/compiler-sfc': 3.5.43
|
'@vue/compiler-sfc': 3.5.30
|
||||||
'@vue/runtime-dom': 3.5.43
|
'@vue/runtime-dom': 3.5.30
|
||||||
'@vue/server-renderer': 3.5.43
|
'@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3))
|
||||||
'@vue/shared': 3.5.43
|
'@vue/shared': 3.5.30
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,4 @@
|
|||||||
minimumReleaseAge: 10080
|
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:
|
allowBuilds:
|
||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
vue-demi: true
|
vue-demi: true
|
||||||
overrides:
|
|
||||||
'@vue/server-renderer@<3.5.42': ^3.5.42
|
|
||||||
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
|
|
||||||
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
|
|
||||||
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
|
|
||||||
audit:
|
|
||||||
ignore:
|
|
||||||
- GHSA-vfj7-8cjw-p6xm
|
|
||||||
|
|||||||
@@ -12,12 +12,8 @@ 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 {
|
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||||
TicketDetailRelationshipsProps,
|
|
||||||
TicketLinkFormData,
|
|
||||||
} from '@/types/ticket-detail';
|
|
||||||
|
|
||||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
defineEmits<{ editParent: [] }>();
|
defineEmits<{ editParent: [] }>();
|
||||||
@@ -37,17 +33,17 @@ const canAddLink = computed(
|
|||||||
props.ticketLinkTypes.length > 0 &&
|
props.ticketLinkTypes.length > 0 &&
|
||||||
props.ticketLinkOptions.length > 0,
|
props.ticketLinkOptions.length > 0,
|
||||||
);
|
);
|
||||||
const form = useForm<TicketLinkFormData>({
|
const form = useForm({
|
||||||
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 { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
|
const selectedType = computed(() =>
|
||||||
() => props.ticketLinkTypes,
|
props.ticketLinkTypes.find(
|
||||||
form,
|
(type) => String(type.id) === String(form.ticket_link_type_id),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const selectedTicket = computed(() =>
|
const selectedTicket = computed(() =>
|
||||||
props.ticketLinkOptions.find(
|
props.ticketLinkOptions.find(
|
||||||
@@ -228,14 +224,16 @@ function submit(): void {
|
|||||||
>
|
>
|
||||||
<TicketFormField
|
<TicketFormField
|
||||||
id="ticket_link_type_id"
|
id="ticket_link_type_id"
|
||||||
v-model="selectedAction"
|
v-model="form.ticket_link_type_id"
|
||||||
kind="select"
|
kind="select"
|
||||||
:label="t('ticketDetail.linkAction')"
|
:label="t('ticketDetail.linkType')"
|
||||||
:options="actions"
|
:options="
|
||||||
:error="
|
ticketLinkTypes.map((type) => ({
|
||||||
form.errors.ticket_link_type_id ||
|
value: type.id,
|
||||||
form.errors.link_direction
|
label: type.label,
|
||||||
|
}))
|
||||||
"
|
"
|
||||||
|
:error="form.errors.ticket_link_type_id"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -262,19 +260,22 @@ function submit(): void {
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p
|
<p
|
||||||
v-if="selectedActionLabel && selectedTicket"
|
v-if="selectedType && 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>{{ selectedActionLabel }}</span
|
><span>{{ selectedType.label }}</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,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, useForm } from '@inertiajs/vue3';
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
import { Unlink } from 'lucide-vue-next';
|
import { ArrowDownLeft, ArrowUpRight, 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';
|
||||||
@@ -18,6 +20,7 @@ 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 {
|
||||||
@@ -44,6 +47,27 @@ 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>
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"format": "Markdown stored verbatim and supported by the Tiptap WYSIWYG editor and ticket detail view.",
|
|
||||||
"supported": "Supported: plain text, paragraphs, line breaks, headings, bold, italic, strikethrough, ordinary ordered/unordered lists (including nested lists), blockquotes, inline/fenced/indented code, horizontal rules, and safe links.",
|
|
||||||
"unsupported": "Not visually supported: task checkboxes, tables, HTML, images, or unsafe links. Newly supplied incompatible descriptions reject the entire mutation with a description field error; existing descriptions are preserved when description is omitted.",
|
|
||||||
"criteria": "Put acceptance criteria exclusively in the structured acceptance_criteria field. Do not repeat them in description as task checkboxes or a copied criteria list. No criteria are automatically extracted or moved.",
|
|
||||||
"links": "Safe link destinations: http://, https://, mailto:, a root-relative path starting with a single /, or a #fragment. Protocol-relative URLs, other schemes, whitespace, control characters, and backslashes (including percent-encoded forms) are not allowed.",
|
|
||||||
"example": "## Goal\n\nExplain the **context** and approach.\n\n- Implement the change\n- Verify [the guide](https://example.com/guide)\n\n> Keep the scope clear.\n\nUse `code` when needed.",
|
|
||||||
"unsupported_tokens": {
|
|
||||||
"html": "HTML",
|
|
||||||
"table": "tables",
|
|
||||||
"image": "images",
|
|
||||||
"task": "task checkboxes",
|
|
||||||
"unsafe_link": "unsafe links"
|
|
||||||
},
|
|
||||||
"link_prefix": "^(?:https?://|mailto:|/(?!/)|#)",
|
|
||||||
"unsafe_link_characters": "[\\s\\\\\\x00-\\x1f\\x7f]"
|
|
||||||
}
|
|
||||||
@@ -2,40 +2,30 @@ import type { JSONContent } from '@tiptap/core';
|
|||||||
import { Markdown, MarkdownManager } from '@tiptap/markdown';
|
import { Markdown, MarkdownManager } from '@tiptap/markdown';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
import descriptionFormat from './ticket-description-format.json';
|
|
||||||
import type { VNodeChild } from 'vue';
|
import type { VNodeChild } from 'vue';
|
||||||
|
|
||||||
export function safeDescriptionLink(value: unknown): string | null {
|
export function safeDescriptionLink(value: unknown): string | null {
|
||||||
if (
|
if (
|
||||||
typeof value !== 'string' ||
|
typeof value !== 'string' ||
|
||||||
new RegExp(descriptionFormat.unsafe_link_characters, 'u').test(value)
|
/[\s\\]/u.test(value) ||
|
||||||
|
[...value].some(
|
||||||
|
(character) =>
|
||||||
|
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
if (
|
|
||||||
new RegExp(descriptionFormat.unsafe_link_characters, 'u').test(
|
|
||||||
decodeURIComponent(value),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (/^(https?:\/\/|mailto:)/i.test(value)) {
|
if (/^(https?:\/\/|mailto:)/i.test(value)) {
|
||||||
try {
|
try {
|
||||||
new URL(value);
|
const url = new URL(value);
|
||||||
return new RegExp(descriptionFormat.link_prefix, 'i').test(value)
|
return ['http:', 'https:', 'mailto:'].includes(url.protocol)
|
||||||
? value
|
? value
|
||||||
: null;
|
: null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new RegExp(descriptionFormat.link_prefix, 'i').test(value)
|
return /^(\/(?!\/)|#)/.test(value) ? value : null;
|
||||||
? value
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function descriptionExtensions() {
|
export function descriptionExtensions() {
|
||||||
@@ -65,21 +55,15 @@ const markdown = new MarkdownManager({
|
|||||||
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
|
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
|
||||||
export function supportsVisualDescription(value: string): boolean {
|
export function supportsVisualDescription(value: string): boolean {
|
||||||
let supported = true;
|
let supported = true;
|
||||||
markdown.instance.walkTokens(
|
markdown.instance.walkTokens(markdown.instance.lexer(value), (token) => {
|
||||||
markdown.instance.lexer(value, { gfm: true, breaks: true }),
|
if (
|
||||||
(token) => {
|
['html', 'table', 'image'].includes(token.type) ||
|
||||||
if (
|
(token.type === 'list_item' && token.task) ||
|
||||||
Object.hasOwn(
|
(token.type === 'link' && !safeDescriptionLink(token.href))
|
||||||
descriptionFormat.unsupported_tokens,
|
) {
|
||||||
token.type,
|
supported = false;
|
||||||
) ||
|
}
|
||||||
(token.type === 'list_item' && token.task) ||
|
});
|
||||||
(token.type === 'link' && !safeDescriptionLink(token.href))
|
|
||||||
) {
|
|
||||||
supported = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return supported;
|
return supported;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.linkAction': 'Action',
|
'ticketDetail.linkType': 'Link type',
|
||||||
'ticketDetail.linkedTicket': 'Linked ticket',
|
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||||
'ticketDetail.saveLink': 'Save link',
|
'ticketDetail.saveLink': 'Save link',
|
||||||
'ticketDetail.noLinks':
|
'ticketDetail.noLinks':
|
||||||
@@ -1227,6 +1227,10 @@ 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,14 +67,3 @@ 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:admin-project,project')
|
->middleware('can:access-admin')
|
||||||
->name('destroy');
|
->name('destroy');
|
||||||
|
|
||||||
Route::middleware('can:admin-project,project')->group(function (): void {
|
Route::middleware('can:admin-project,project')->group(function (): void {
|
||||||
|
|||||||
@@ -291,14 +291,7 @@ class ProjectApiTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.key', 'CORE');
|
->assertJsonPath('data.key', 'CORE');
|
||||||
|
|
||||||
$this->withApplicationKey($user)
|
$this->syncGlobalPermissions($admin, ['admin']);
|
||||||
->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,49 +293,6 @@ 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();
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Mcp;
|
|
||||||
|
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
|
||||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
|
||||||
use App\Mcp\Servers\KanbanGitServer;
|
|
||||||
use App\Mcp\Tools\CreateTicket;
|
|
||||||
use App\Mcp\Tools\GetTicket;
|
|
||||||
use App\Mcp\Tools\UpdateTicket;
|
|
||||||
use App\Mcp\Tools\UpdateTicketDescription;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Testing\Fluent\AssertableJson;
|
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TicketDescriptionContractTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private Project $project;
|
|
||||||
|
|
||||||
private User $user;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
$this->user = User::factory()->create();
|
|
||||||
$this->project = Project::factory()->create(['key' => 'CORE']);
|
|
||||||
app(InitializeProjectTicketMetadata::class)->handle($this->project);
|
|
||||||
$this->syncProjectPermissions($this->project, $this->user, ['view', 'write']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_discovery_exposes_the_shared_contract_for_every_writing_tool_and_field_for_any_key_owner(): void
|
|
||||||
{
|
|
||||||
foreach ([$this->user, User::factory()->create()] as $user) {
|
|
||||||
$this->syncProjectPermissions($this->project, $user, ['view', 'write']);
|
|
||||||
$this->withApplicationKey($user);
|
|
||||||
$this->postJson('/mcp/kanbangit', [
|
|
||||||
'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize',
|
|
||||||
'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => [], 'clientInfo' => ['name' => 'Contract test', 'version' => '1.0']],
|
|
||||||
])->assertOk()->assertJsonPath('result.instructions', fn (string $value): bool => str_contains($value, TicketDescriptionFormat::instructions()));
|
|
||||||
$response = $this->postJson('/mcp/kanbangit', [
|
|
||||||
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => [],
|
|
||||||
])->assertOk();
|
|
||||||
$writers = [];
|
|
||||||
foreach ($response->json('result.tools') as $tool) {
|
|
||||||
$fields = $tool['inputSchema']['properties'];
|
|
||||||
if (! ($tool['annotations']['readOnlyHint'] ?? false)) {
|
|
||||||
$writers[] = $tool['name'];
|
|
||||||
$this->assertStringContainsString(TicketDescriptionFormat::instructions(), $tool['description'], $tool['name']);
|
|
||||||
}
|
|
||||||
if (isset($fields['description'])) {
|
|
||||||
$this->assertStringContainsString(TicketDescriptionFormat::instructions(), $fields['description']['description'], $tool['name']);
|
|
||||||
}
|
|
||||||
if (isset($fields['acceptance_criteria'])) {
|
|
||||||
$this->assertStringContainsString(TicketDescriptionFormat::criteriaInstructions(), $fields['acceptance_criteria']['description'], $tool['name']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->assertContains('create_ticket', $writers);
|
|
||||||
$this->assertContains('update_ticket', $writers);
|
|
||||||
$this->assertContains('update_ticket_description', $writers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('descriptionCases')]
|
|
||||||
public function test_backend_and_editor_share_compatibility_cases(string $value, bool $supported): void
|
|
||||||
{
|
|
||||||
$this->assertSame($supported, TicketDescriptionFormat::unsupportedContent($value) === []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function descriptionCases(): array
|
|
||||||
{
|
|
||||||
$cases = json_decode(file_get_contents(__DIR__.'/../../Fixtures/ticket-description-cases.json'), true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
return collect($cases)->mapWithKeys(fn (array $case): array => [$case['name'] => [$case['value'], $case['supported']]])->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('unsupportedDescriptions')]
|
|
||||||
public function test_incompatible_description_rejects_creation_and_every_update_without_partial_changes(string $description): void
|
|
||||||
{
|
|
||||||
$projectBefore = $this->project->fresh()->getAttributes();
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->createPayload($description))
|
|
||||||
->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']);
|
|
||||||
$this->assertDatabaseCount('tickets', 0);
|
|
||||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
|
||||||
$this->assertSame($projectBefore, $this->project->fresh()->getAttributes());
|
|
||||||
|
|
||||||
$ticket = $this->ticket();
|
|
||||||
$before = $ticket->fresh()->getAttributes();
|
|
||||||
foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) {
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool($tool, [
|
|
||||||
'ticket_id' => $ticket->id, 'description' => $description,
|
|
||||||
'title' => 'Must not change', 'story_points' => 99,
|
|
||||||
'acceptance_criteria' => [['text' => 'Must not replace']],
|
|
||||||
])->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']);
|
|
||||||
$this->assertSame($before, $ticket->fresh()->getAttributes());
|
|
||||||
$this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text);
|
|
||||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function unsupportedDescriptions(): array
|
|
||||||
{
|
|
||||||
return array_map(fn (array $case): array => [$case[0]], array_filter(self::descriptionCases(), fn (array $case): bool => ! $case[1]));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_compatible_descriptions_are_stored_verbatim_and_criteria_remain_separate_and_null_clears(): void
|
|
||||||
{
|
|
||||||
$description = TicketDescriptionFormat::contract()['example'];
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, [
|
|
||||||
...$this->createPayload($description), 'acceptance_criteria' => [['text' => 'Separate criterion']],
|
|
||||||
])->assertOk();
|
|
||||||
$ticket = $this->project->tickets()->sole();
|
|
||||||
$this->assertSame($description, $ticket->description);
|
|
||||||
$this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text);
|
|
||||||
$this->assertSame([], TicketDescriptionFormat::unsupportedContent($description));
|
|
||||||
$this->withoutVite();
|
|
||||||
$this->actingAs($this->user)->get(route('projects.tickets.show', [$this->project, $ticket]))
|
|
||||||
->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description)->where('ticket.acceptance_criteria.0.text', 'Separate criterion'));
|
|
||||||
$process = new Process(['node', 'tests/Fixtures/ticket-description.mjs', '--stored-description'], base_path());
|
|
||||||
$process->setInput(json_encode(['description' => $ticket->description, 'criteria' => $ticket->acceptanceCriteria()->get()->toArray()], JSON_THROW_ON_ERROR));
|
|
||||||
$process->setTimeout(60);
|
|
||||||
$process->run();
|
|
||||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
|
||||||
foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) {
|
|
||||||
$updated = "> Quote\n\n**Updated**\n\n```markdown\n- [x] literal example\n```";
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => $updated])->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $updated)->etc());
|
|
||||||
$this->assertSame($updated, $ticket->fresh()->description);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => null])->assertOk();
|
|
||||||
$this->assertNull($ticket->fresh()->description);
|
|
||||||
$this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_updates_without_description_preserve_legacy_source_and_checklist_identity(): void
|
|
||||||
{
|
|
||||||
$ticket = $this->ticket();
|
|
||||||
$legacy = "## Legacy\n\n- [x] Keep verbatim\n\n<table></table>";
|
|
||||||
$ticket->update(['description' => $legacy]);
|
|
||||||
$criterion = $ticket->acceptanceCriteria()->sole();
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $ticket->id, 'title' => 'Updated title'])
|
|
||||||
->assertOk();
|
|
||||||
$this->assertSame($legacy, $ticket->fresh()->description);
|
|
||||||
$this->assertSame($criterion->id, $ticket->acceptanceCriteria()->sole()->id);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(UpdateTicketDescription::class, ['ticket_id' => $ticket->id])->assertOk();
|
|
||||||
$this->assertSame($legacy, $ticket->fresh()->description);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_id' => $ticket->id])->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $legacy)->etc());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createPayload(?string $description): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'project_id' => $this->project->id, 'title' => 'Formatted ticket', 'description' => $description,
|
|
||||||
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
|
|
||||||
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
|
|
||||||
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ticket(): Ticket
|
|
||||||
{
|
|
||||||
$ticket = Ticket::factory()->create([
|
|
||||||
...collect($this->createPayload('Original description'))->except('project_id')->all(),
|
|
||||||
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1,
|
|
||||||
]);
|
|
||||||
$ticket->acceptanceCriteria()->create(['text' => 'Separate criterion', 'checked' => false, 'indent' => 0, 'position' => 1]);
|
|
||||||
|
|
||||||
return $ticket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Mcp;
|
|
||||||
|
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
|
||||||
use App\Mcp\Servers\KanbanGitServer;
|
|
||||||
use App\Mcp\Tools\GetProjectConfiguration;
|
|
||||||
use App\Mcp\Tools\GetTicket;
|
|
||||||
use App\Mcp\Tools\ListProjects;
|
|
||||||
use App\Mcp\Tools\SearchTickets;
|
|
||||||
use App\Models\BoardColumn;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Team;
|
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Models\TicketFieldDefinition;
|
|
||||||
use App\Models\TicketStatus;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Testing\Fluent\AssertableJson;
|
|
||||||
use Illuminate\Testing\TestResponse;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TicketDiscoveryTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private User $user;
|
|
||||||
|
|
||||||
private Project $project;
|
|
||||||
|
|
||||||
private Project $hidden;
|
|
||||||
|
|
||||||
private Ticket $ticket;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
$this->user = User::factory()->create();
|
|
||||||
$this->project = Project::factory()->create(['key' => 'CORE', 'name' => 'Visible']);
|
|
||||||
$this->hidden = Project::factory()->create(['key' => 'HIDE']);
|
|
||||||
app(InitializeProjectTicketMetadata::class)->handle($this->project);
|
|
||||||
app(InitializeProjectTicketMetadata::class)->handle($this->hidden);
|
|
||||||
$this->syncProjectPermissions($this->project, $this->user, ['view']);
|
|
||||||
$this->ticket = $this->newTicket($this->project, 1, ['title' => 'Special needle', 'description' => 'First description', 'assignee_user_id' => $this->user->id]);
|
|
||||||
$this->newTicket($this->project, 2, ['title' => 'Other title', 'description' => 'Needle in description', 'parent_ticket_id' => $this->ticket->id]);
|
|
||||||
$this->newTicket($this->hidden, 1, ['title' => 'Hidden needle']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_exact_key_and_existing_id_lookup_work_and_hidden_tickets_are_denied(): void
|
|
||||||
{
|
|
||||||
foreach ([['ticket_id' => $this->ticket->id], ['ticket_key' => 'CORE-1']] as $input) {
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.id', $this->ticket->id)->etc());
|
|
||||||
}
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'HIDE-1'])->assertHasErrors(['cannot view']);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-99'])->assertHasErrors(['not found']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('invalidSelectors')]
|
|
||||||
public function test_key_lookup_requires_exactly_one_valid_selector(array $input): void
|
|
||||||
{
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertHasErrors();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function invalidSelectors(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
[[]], [['ticket_key' => 'CORE']], [['ticket_key' => 'core-1']], [['ticket_key' => 'CORE-0']],
|
|
||||||
[['ticket_key' => 'CORE-1', 'ticket_id' => 1]], [['ticket_key' => null]],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_search_combines_filters_and_paginates_only_visible_tickets_with_compact_payloads(): void
|
|
||||||
{
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'per_page' => 1])
|
|
||||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
|
|
||||||
->where('data.0.id', $this->ticket->id)->count('data', 1)
|
|
||||||
->where('data.0.status.id', $this->ticket->ticket_status_id)
|
|
||||||
->missing('data.0.description')->missing('data.0.project')
|
|
||||||
->where('meta.total', 2)->where('meta.last_page', 2)->where('meta.has_more_pages', true)->etc());
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'page' => 2, 'per_page' => 1])
|
|
||||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
|
|
||||||
->where('data.0.ticket_key', 'CORE-2')->where('meta.current_page', 2)->where('meta.has_more_pages', false)->etc());
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, [
|
|
||||||
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'ticket_status_id' => $this->ticket->ticket_status_id,
|
|
||||||
'ticket_type_id' => $this->ticket->ticket_type_id, 'assignee_user_id' => $this->user->id, 'parent_ticket_id' => null,
|
|
||||||
])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['parent_ticket_id' => $this->ticket->id, 'assignee_user_id' => null])
|
|
||||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.ticket_key', 'CORE-2')->etc());
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_type_id' => $this->hidden->ticketTypes()->firstOrFail()->id])
|
|
||||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->etc());
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['page' => 10])->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 2)->etc());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_search_returns_tickets_without_a_status(): void
|
|
||||||
{
|
|
||||||
$this->ticket->update(['ticket_status_id' => null]);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => 'CORE-1'])->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)
|
|
||||||
->where('data.0.ticket_status_id', null)->where('data.0.status', null)->etc());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_literal_search_does_not_expand_sql_wildcards(): void
|
|
||||||
{
|
|
||||||
$this->ticket->update(['title' => 'A 50%_! literal']);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => '%_!'])
|
|
||||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_project_list_is_compact_paginated_and_independent_of_assignments_with_team_and_global_access(): void
|
|
||||||
{
|
|
||||||
$this->ticket->update(['assignee_user_id' => null]);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class)->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.key', 'CORE')->missing('data.0.template_state')->where('meta.total', 1)->etc());
|
|
||||||
$team = Team::factory()->create();
|
|
||||||
$team->users()->attach($this->user);
|
|
||||||
$this->syncProjectPermissions($this->hidden, $team, ['view']);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class, ['per_page' => 1, 'page' => 2])->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->hidden->id)->where('meta.total', 2)->etc());
|
|
||||||
$global = User::factory()->create();
|
|
||||||
$this->syncGlobalPermissions($global, ['admin']);
|
|
||||||
KanbanGitServer::actingAs($global)->tool(SearchTickets::class)->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.total', 3)->etc());
|
|
||||||
KanbanGitServer::actingAs(User::factory()->create())->tool(ListProjects::class)->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 0)->etc());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_configuration_returns_current_workflow_and_allowed_people_without_full_resources(): void
|
|
||||||
{
|
|
||||||
$first = $this->project->ticketStatuses()->firstOrFail();
|
|
||||||
$column = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
|
|
||||||
$second = TicketStatus::factory()->for($this->project)->for($column)->create(['position' => 1]);
|
|
||||||
TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 1]);
|
|
||||||
$first->allowedTargetStatuses()->sync([$second->id]);
|
|
||||||
$first->update(['name' => 'Fresh workflow']);
|
|
||||||
$otherViewer = User::factory()->create();
|
|
||||||
$this->syncProjectPermissions($this->project, $otherViewer, ['view']);
|
|
||||||
User::factory()->create();
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 1])
|
|
||||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
|
|
||||||
->where('data.project.id', $this->project->id)->missing('data.project.template_state')
|
|
||||||
->where('data.permissions.can_write', false)->where('data.permissions.can_change_author', false)
|
|
||||||
->where('data.ticket_statuses.0.id', $first->id)->where('data.ticket_statuses.0.slug', 'fresh-workflow')
|
|
||||||
->where('data.ticket_statuses.0.allowed_target_status_ids', [$second->id])
|
|
||||||
->where('data.ticket_statuses.0.board_column_id', $first->board_column_id)
|
|
||||||
->has('data.ticket_field_definitions.0.field_type')->has('data.ticket_types.0.is_parent')->has('data.ticket_priorities.0.id')->has('data.board_columns.0.position')
|
|
||||||
->count('data.people', 1)->missing('data.people.0.email')->missing('data.people.0.avatar')
|
|
||||||
->where('meta.people.total', 2)->etc());
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_page' => 2, 'people_per_page' => 1])
|
|
||||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.people.0.id', $otherViewer->id)->etc());
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_numeric_project_keys_and_configured_column_order_are_supported(): void
|
|
||||||
{
|
|
||||||
$this->project->update(['key' => '42']);
|
|
||||||
$this->ticket->update(['ticket_key' => '42-1']);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => '42-1'])->assertOk();
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => '42-1'])->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)->etc());
|
|
||||||
$later = BoardColumn::factory()->for($this->project)->create(['position' => 2]);
|
|
||||||
$earlier = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
|
|
||||||
$laterStatus = TicketStatus::factory()->for($this->project)->for($later)->create(['position' => 1]);
|
|
||||||
$earlierStatus = TicketStatus::factory()->for($this->project)->for($earlier)->create(['position' => 1]);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id])->assertOk()
|
|
||||||
->assertStructuredContent(fn (AssertableJson $json) => $json
|
|
||||||
->where('data.board_columns.0.id', $earlier->id)
|
|
||||||
->where('data.ticket_statuses.1.id', $earlierStatus->id)
|
|
||||||
->where('data.ticket_statuses.2.id', $laterStatus->id)->etc());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_pagination_limits_and_missing_projects_are_validated(): void
|
|
||||||
{
|
|
||||||
foreach ([ListProjects::class, SearchTickets::class] as $tool) {
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool($tool, ['per_page' => 101])->assertHasErrors();
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool($tool, ['page' => 0])->assertHasErrors();
|
|
||||||
}
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => 999])->assertHasErrors(['not found']);
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 101])->assertHasErrors();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_application_key_discovers_new_schemas_and_completes_refinement_without_browser_login(): void
|
|
||||||
{
|
|
||||||
$this->syncProjectPermissions($this->project, $this->user, ['write']);
|
|
||||||
$this->withApplicationKey($this->user);
|
|
||||||
$response = $this->postJson('/mcp/kanbangit', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => []])->assertOk();
|
|
||||||
$tools = collect($response->json('result.tools'))->keyBy('name');
|
|
||||||
foreach (['list_projects', 'search_tickets', 'get_project_configuration'] as $name) {
|
|
||||||
$this->assertTrue($tools[$name]['annotations']['readOnlyHint']);
|
|
||||||
}
|
|
||||||
$this->assertSame(['ticket_id'], $tools['update_ticket']['inputSchema']['required']);
|
|
||||||
$this->assertArrayHasKey('ticket_key', $tools['get_ticket']['inputSchema']['properties']);
|
|
||||||
$this->assertFalse($tools['update_ticket']['annotations']['destructiveHint']);
|
|
||||||
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'title' => 'Refined via MCP', 'description' => 'Refinement', 'acceptance_criteria' => [['text' => 'Real criterion']]]);
|
|
||||||
$ticket = $this->rpc('get_ticket', ['ticket_key' => 'CORE-1'])->json('result.structuredContent.data.ticket');
|
|
||||||
$this->assertSame('Refined via MCP', $ticket['title']);
|
|
||||||
$this->assertSame('Real criterion', $ticket['acceptance_criteria'][0]['text']);
|
|
||||||
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'acceptance_criteria' => [['id' => $ticket['acceptance_criteria'][0]['id'], 'text' => 'Criterion checked', 'checked' => true]]]);
|
|
||||||
$target = TicketStatus::factory()->for($this->project)->create(['position' => 2]);
|
|
||||||
$this->ticket->ticketStatus->allowedTargetStatuses()->sync([$target->id]);
|
|
||||||
$this->rpc('move_ticket_status', ['ticket_id' => $this->ticket->id, 'ticket_status_id' => $target->id]);
|
|
||||||
$this->assertSame($target->id, $this->ticket->fresh()->ticket_status_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function rpc(string $name, array $arguments): TestResponse
|
|
||||||
{
|
|
||||||
return $this->postJson('/mcp/kanbangit', [
|
|
||||||
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => $name, 'arguments' => $arguments],
|
|
||||||
])->assertOk()->assertJsonPath('result.isError', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function newTicket(Project $project, int $sequence, array $attributes = []): Ticket
|
|
||||||
{
|
|
||||||
return Ticket::factory()->create([
|
|
||||||
'project_id' => $project->id, 'ticket_key' => "{$project->key}-{$sequence}", 'sequence_number' => $sequence,
|
|
||||||
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
|
|
||||||
'ticket_type_id' => $project->ticketTypes()->where('is_parent', $sequence === 1)->firstOrFail()->id,
|
|
||||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
|
||||||
...$attributes,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Mcp;
|
|
||||||
|
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
|
||||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
|
||||||
use App\Actions\Tickets\SyncProjectTicketFieldValues;
|
|
||||||
use App\Mcp\Servers\KanbanGitServer;
|
|
||||||
use App\Mcp\Tools\GetTicket;
|
|
||||||
use App\Mcp\Tools\UpdateTicket;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Models\TicketFieldDefinition;
|
|
||||||
use App\Models\TicketPriority;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Testing\Fluent\AssertableJson;
|
|
||||||
use Laravel\Mcp\Server\Testing\TestResponse;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use RuntimeException;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class UpdateTicketTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private User $user;
|
|
||||||
|
|
||||||
private Project $project;
|
|
||||||
|
|
||||||
private Ticket $ticket;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
$this->user = User::factory()->create();
|
|
||||||
$this->project = Project::factory()->create(['key' => 'CORE']);
|
|
||||||
app(InitializeProjectTicketMetadata::class)->handle($this->project);
|
|
||||||
$this->syncProjectPermissions($this->project, $this->user, ['admin']);
|
|
||||||
$this->ticket = $this->newTicket(['description' => 'Original', 'story_points' => 5, 'author_user_id' => $this->user->id]);
|
|
||||||
AcceptanceCriteriaChecklistMapper::syncTicket($this->ticket, [
|
|
||||||
['text' => 'First', 'checked' => false, 'indent' => 0],
|
|
||||||
['text' => 'Second', 'checked' => true, 'indent' => 0],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_partial_update_and_no_op_preserve_checklist_ids_and_unrelated_fields(): void
|
|
||||||
{
|
|
||||||
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
|
|
||||||
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
|
|
||||||
->where('data.ticket.description', 'Original')->where('data.ticket.story_points', 5)
|
|
||||||
->where('data.ticket.acceptance_criteria', $before)->where('meta.updated', true)->etc());
|
|
||||||
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
|
|
||||||
$this->assertSame(1, $this->ticket->worklogEntries()->count());
|
|
||||||
$this->assertSame('Refined', $this->ticket->fresh()->title);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_checklist_replacement_adds_reorders_indents_checks_and_removes_by_identity(): void
|
|
||||||
{
|
|
||||||
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
|
|
||||||
$replacement = [
|
|
||||||
['text' => 'New root', 'checked' => false, 'indent' => 0],
|
|
||||||
['id' => $before[1]['id'], 'text' => 'Second edited', 'checked' => false, 'indent' => 1],
|
|
||||||
['id' => $before[0]['id'], 'text' => 'First', 'checked' => true, 'indent' => 0],
|
|
||||||
];
|
|
||||||
$this->updateTicket(['acceptance_criteria' => $replacement])->assertOk();
|
|
||||||
$after = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
|
|
||||||
$this->assertNotContains($after[0]['id'], array_column($before, 'id'));
|
|
||||||
$this->assertSame($replacement[1], ['id' => $after[1]['id'], 'text' => $after[1]['text'], 'checked' => $after[1]['checked'], 'indent' => $after[1]['indent']]);
|
|
||||||
$this->assertSame($before[0]['id'], $after[2]['id']);
|
|
||||||
$this->updateTicket(['acceptance_criteria' => $after])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
|
|
||||||
$this->updateTicket(['acceptance_criteria' => [$after[2]]])->assertOk();
|
|
||||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-1'])
|
|
||||||
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data.ticket.acceptance_criteria', 1)->where('data.ticket.acceptance_criteria.0.id', $before[0]['id'])->etc());
|
|
||||||
$this->updateTicket(['acceptance_criteria' => []])->assertOk();
|
|
||||||
$this->assertSame(0, $this->ticket->acceptanceCriteria()->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_scalar_null_and_custom_field_replacement_have_explicit_clear_semantics(): void
|
|
||||||
{
|
|
||||||
$number = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']);
|
|
||||||
$person = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]);
|
|
||||||
$this->updateTicket(['field_values' => [
|
|
||||||
['ticket_field_definition_id' => $number->id, 'value_number' => 0],
|
|
||||||
['ticket_field_definition_id' => $person->id, 'value_user_id' => $this->user->id],
|
|
||||||
]])->assertOk();
|
|
||||||
$this->updateTicket(['description' => null, 'story_points' => null, 'assignee_user_id' => null, 'author_user_id' => null])->assertOk();
|
|
||||||
$this->assertSame(2, $this->ticket->fieldValues()->count());
|
|
||||||
$this->assertNull($this->ticket->fresh()->description);
|
|
||||||
$this->assertNull($this->ticket->fresh()->story_points);
|
|
||||||
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $number->id, 'value_number' => 3]]])->assertOk();
|
|
||||||
$this->assertSame(1, $this->ticket->fieldValues()->count());
|
|
||||||
$this->updateTicket(['field_values' => []])->assertOk();
|
|
||||||
$this->assertSame(0, $this->ticket->fieldValues()->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('invalidInputs')]
|
|
||||||
public function test_invalid_payload_does_not_partially_change_ticket(array $input): void
|
|
||||||
{
|
|
||||||
$before = $this->ticket->fresh()->getAttributes();
|
|
||||||
$this->updateTicket(['title' => 'Must not change', ...$input])->assertHasErrors();
|
|
||||||
$this->assertSame($before, $this->ticket->fresh()->getAttributes());
|
|
||||||
$this->assertSame(0, $this->ticket->worklogEntries()->count());
|
|
||||||
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function invalidInputs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'null title' => [['title' => null]],
|
|
||||||
'null type' => [['ticket_type_id' => null]],
|
|
||||||
'null priority' => [['ticket_priority_id' => null]],
|
|
||||||
'null blocked state' => [['is_blocked' => null]],
|
|
||||||
'invalid criterion text' => [['acceptance_criteria' => [['text' => []]]]],
|
|
||||||
'null criteria' => [['acceptance_criteria' => null]],
|
|
||||||
'null fields' => [['field_values' => null]],
|
|
||||||
'invalid criterion' => [['acceptance_criteria' => [['id' => 99999, 'text' => 'Foreign']]]],
|
|
||||||
'blank criterion' => [['acceptance_criteria' => [['text' => ' ']]]],
|
|
||||||
'skipped indent' => [['acceptance_criteria' => [['text' => 'Child', 'indent' => 1]]]],
|
|
||||||
'negative points' => [['story_points' => -1]],
|
|
||||||
'missing blocker' => [['is_blocked' => true]],
|
|
||||||
'status not editable' => [['ticket_status_id' => 999]],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_foreign_and_duplicate_criterion_ids_are_rejected_atomically(): void
|
|
||||||
{
|
|
||||||
$other = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
|
|
||||||
AcceptanceCriteriaChecklistMapper::syncTicket($other, [['text' => 'Other', 'checked' => false, 'indent' => 0]]);
|
|
||||||
$foreign = $other->acceptanceCriteria()->firstOrFail()->id;
|
|
||||||
$this->updateTicket(['title' => 'Rejected', 'acceptance_criteria' => [['id' => $foreign, 'text' => 'Steal']]])->assertHasErrors();
|
|
||||||
$id = $this->ticket->acceptanceCriteria()->firstOrFail()->id;
|
|
||||||
$this->updateTicket(['acceptance_criteria' => [['id' => $id, 'text' => 'One'], ['id' => $id, 'text' => 'Two']]])->assertHasErrors();
|
|
||||||
$this->assertSame('Original', $this->ticket->fresh()->description);
|
|
||||||
$this->assertSame('Other', $other->acceptanceCriteria()->firstOrFail()->text);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_project_read_and_write_permissions_and_author_rules_are_enforced(): void
|
|
||||||
{
|
|
||||||
$this->syncProjectPermissions($this->project, $this->user, ['view']);
|
|
||||||
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
|
|
||||||
$this->syncProjectPermissions($this->project, $this->user, ['write']);
|
|
||||||
$this->updateTicket(['title' => 'Allowed'])->assertOk();
|
|
||||||
$this->updateTicket(['author_user_id' => null])->assertHasErrors(['Only project admins']);
|
|
||||||
$this->syncProjectPermissions($this->project, $this->user, []);
|
|
||||||
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
|
|
||||||
$this->assertSame('Allowed', $this->ticket->fresh()->title);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_invalid_people_foreign_metadata_custom_types_and_parent_relations_are_rejected(): void
|
|
||||||
{
|
|
||||||
$hiddenUser = User::factory()->create();
|
|
||||||
$this->updateTicket(['assignee_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
|
|
||||||
$this->updateTicket(['author_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
|
|
||||||
$otherProject = Project::factory()->create();
|
|
||||||
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
|
||||||
$this->updateTicket(['ticket_type_id' => $otherProject->ticketTypes()->firstOrFail()->id])->assertHasErrors();
|
|
||||||
$this->updateTicket(['ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id])->assertHasErrors();
|
|
||||||
$foreignTicket = Ticket::factory()->for($otherProject)->create();
|
|
||||||
$this->updateTicket(['parent_ticket_id' => $foreignTicket->id])->assertHasErrors();
|
|
||||||
$field = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user']);
|
|
||||||
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_user_id' => $hiddenUser->id]]])->assertHasErrors(['person']);
|
|
||||||
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_number' => 2]]])->assertHasErrors(['field type']);
|
|
||||||
$this->updateTicket(['parent_ticket_id' => $this->ticket->id])->assertHasErrors(['own parent']);
|
|
||||||
$task = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
|
|
||||||
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['parent-enabled']);
|
|
||||||
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
|
|
||||||
$this->updateTicket(['ticket_type_id' => $parentType->id])->assertOk();
|
|
||||||
$task->update(['parent_ticket_id' => $this->ticket->id, 'ticket_type_id' => $parentType->id]);
|
|
||||||
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['descendants']);
|
|
||||||
$this->updateTicket(['ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id])->assertHasErrors(['child tickets']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_all_editable_scalar_fields_accept_valid_project_values(): void
|
|
||||||
{
|
|
||||||
$person = User::factory()->create();
|
|
||||||
$this->syncProjectPermissions($this->project, $person, ['view']);
|
|
||||||
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
|
|
||||||
$parent = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2, 'ticket_type_id' => $parentType->id]);
|
|
||||||
$priority = TicketPriority::factory()->for($this->project)->create(['position' => 2]);
|
|
||||||
$input = [
|
|
||||||
'title' => 'All scalar fields', 'description' => 'Updated description',
|
|
||||||
'ticket_type_id' => $parentType->id, 'ticket_priority_id' => $priority->id,
|
|
||||||
'assignee_user_id' => $person->id, 'author_user_id' => $person->id,
|
|
||||||
'parent_ticket_id' => $parent->id, 'story_points' => 0,
|
|
||||||
'is_blocked' => true, 'blocked_comment' => 'Dependency',
|
|
||||||
];
|
|
||||||
$this->updateTicket($input)->assertOk();
|
|
||||||
foreach ($input as $field => $value) {
|
|
||||||
$this->assertSame($value, $this->ticket->fresh()->getAttribute($field));
|
|
||||||
}
|
|
||||||
$this->assertSame(1, $this->ticket->worklogEntries()->count());
|
|
||||||
$this->updateTicket(['parent_ticket_id' => null])->assertOk();
|
|
||||||
$this->assertNull($this->ticket->fresh()->parent_ticket_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_blocker_validation_uses_preserved_state(): void
|
|
||||||
{
|
|
||||||
$this->updateTicket(['is_blocked' => true, 'blocked_comment' => 'Waiting'])->assertOk();
|
|
||||||
$this->updateTicket(['title' => 'Still blocked'])->assertOk();
|
|
||||||
$this->updateTicket(['blocked_comment' => null])->assertHasErrors(['blocker comment']);
|
|
||||||
$this->updateTicket(['is_blocked' => false])->assertOk();
|
|
||||||
$this->assertNull($this->ticket->fresh()->blocked_comment);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_action_failure_rolls_back_scalars_checklist_and_worklog(): void
|
|
||||||
{
|
|
||||||
$this->mock(SyncProjectTicketFieldValues::class)->shouldReceive('handle')->once()->andThrow(new RuntimeException('Sync failed'));
|
|
||||||
try {
|
|
||||||
$this->updateTicket(['title' => 'Rollback', 'acceptance_criteria' => []]);
|
|
||||||
$this->fail('Expected action failure.');
|
|
||||||
} catch (RuntimeException $exception) {
|
|
||||||
$this->assertSame('Sync failed', $exception->getMessage());
|
|
||||||
}
|
|
||||||
$this->assertNotSame('Rollback', $this->ticket->fresh()->title);
|
|
||||||
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
|
|
||||||
$this->assertSame(0, $this->ticket->worklogEntries()->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function updateTicket(array $input): TestResponse
|
|
||||||
{
|
|
||||||
return KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $this->ticket->id, ...$input]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function newTicket(array $attributes = []): Ticket
|
|
||||||
{
|
|
||||||
return Ticket::factory()->create([
|
|
||||||
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1,
|
|
||||||
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
|
|
||||||
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
|
|
||||||
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
|
|
||||||
...$attributes,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -805,9 +805,6 @@ 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,
|
||||||
]);
|
]);
|
||||||
@@ -820,75 +817,6 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -913,18 +841,16 @@ class ProjectControllerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure users without project administration cannot delete projects.
|
* Ensure non-admin users cannot delete projects.
|
||||||
*/
|
*/
|
||||||
public function test_project_readers_cannot_delete_projects(): void
|
public function test_non_admin_users_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($otherProject, $user, ['admin']);
|
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||||
$this->syncProjectPermissions($project, $user, ['view']);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->delete(route('projects.destroy', $project), [
|
->delete(route('projects.destroy', $project), [
|
||||||
|
|||||||
@@ -83,74 +83,6 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -212,15 +144,6 @@ 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import TicketDescriptionEditor from '../../resources/js/components/tickets/Ticke
|
|||||||
import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue';
|
import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue';
|
||||||
import { createLocalization } from '../../resources/js/lib/localization';
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
import '../../resources/css/app.css';
|
import '../../resources/css/app.css';
|
||||||
import descriptionFormat from '../../resources/js/lib/ticket-description-format.json';
|
|
||||||
|
|
||||||
const original = 'First line\nSecond line\n\n**Existing bold**';
|
const original = 'First line\nSecond line\n\n**Existing bold**';
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
@@ -200,33 +199,6 @@ try {
|
|||||||
state.value =
|
state.value =
|
||||||
'# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.';
|
'# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.';
|
||||||
await tick();
|
await tick();
|
||||||
state.value = descriptionFormat.example;
|
|
||||||
await tick();
|
|
||||||
assert(
|
|
||||||
document.querySelector('#description h2')?.textContent === 'Goal',
|
|
||||||
'MCP example opens formatted in WYSIWYG',
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
document.querySelector('#description strong')?.textContent ===
|
|
||||||
'context',
|
|
||||||
'MCP emphasis is formatted',
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
document.querySelectorAll('#description li').length === 2,
|
|
||||||
'MCP ordinary lists render',
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
!document.querySelector('textarea#description'),
|
|
||||||
'MCP example avoids source fallback',
|
|
||||||
);
|
|
||||||
state.value = '## Legacy\n\n- [ ] Criterion';
|
|
||||||
await tick();
|
|
||||||
assert(
|
|
||||||
document.querySelector('textarea#description')?.value === state.value,
|
|
||||||
'Legacy task checkboxes retain original source',
|
|
||||||
);
|
|
||||||
state.value = descriptionFormat.example;
|
|
||||||
await tick();
|
|
||||||
state.result =
|
state.result =
|
||||||
'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.';
|
'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"name": "plain text",
|
|
||||||
"value": "München 🚀 & details\nSecond line",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "formatting",
|
|
||||||
"value": "# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n<tag>\n```\n\n[Link](https://example.test/path)\n\n---",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "setext heading",
|
|
||||||
"value": "Title\n===\n\nText",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "safe links",
|
|
||||||
"value": "[http](http://example.test) [mail](mailto:test@example.test) [root](/projects/1) [fragment](#part)",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "reference link",
|
|
||||||
"value": "[Guide][ref]\n\n[ref]: https://example.test",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "autolinks",
|
|
||||||
"value": "<https://example.test> and https://example.test and test@example.test",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "literal code",
|
|
||||||
"value": "```markdown\n- [x] Done\n<table></table>\n\n[a](javascript:bad)\n```\n\n`- [ ] task`\n\n <script>example</script>",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "escaped text",
|
|
||||||
"value": "\\<tag> and \\\n\n- \\[x] ordinary list",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "entities",
|
|
||||||
"value": "<img src=x onerror=alert(1)>",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "empty",
|
|
||||||
"value": "",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "task",
|
|
||||||
"value": "## Plan\n\n- [ ] Criteria",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "checked",
|
|
||||||
"value": "- [x] Done",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "nested task",
|
|
||||||
"value": "> 1. [X] Done",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "table",
|
|
||||||
"value": "| A | B |\n| - | - |\n| a | b |",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "html",
|
|
||||||
"value": "<script>alert(1)</script>",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "inline html",
|
|
||||||
"value": "Text <b>bold</b>",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "html comment",
|
|
||||||
"value": "<!-- comment -->",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "image",
|
|
||||||
"value": "",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "reference image",
|
|
||||||
"value": "![alt][img]\n\n[img]: https://example.test/image.png",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "javascript",
|
|
||||||
"value": "[unsafe](javascript:alert%281%29)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "data",
|
|
||||||
"value": "[unsafe](data:text/html,test)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "protocol relative",
|
|
||||||
"value": "[unsafe](//evil.test)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "relative",
|
|
||||||
"value": "[unsafe](other/page)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "unsafe reference",
|
|
||||||
"value": "[unsafe][ref]\n\n[ref]: javascript:alert(1)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "malformed https",
|
|
||||||
"value": "[unsafe](https://)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "backslash",
|
|
||||||
"value": "[unsafe](https://example.test/a\\\\b)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "invalid ipv6",
|
|
||||||
"value": "[unsafe](https://[invalid]/)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "encoded backslash",
|
|
||||||
"value": "[unsafe](https://example.test/%5cpath)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "encoded whitespace",
|
|
||||||
"value": "[unsafe](https://example.test/a%20b)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "invalid encoding",
|
|
||||||
"value": "[unsafe](https://example.test/%ff)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "too large port",
|
|
||||||
"value": "[unsafe](https://example.test:99999)",
|
|
||||||
"supported": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ipv6",
|
|
||||||
"value": "[safe](https://[::1]/path)",
|
|
||||||
"supported": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "international domain",
|
|
||||||
"value": "[safe](https://münchen.example/🚀)",
|
|
||||||
"supported": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
@@ -12,24 +11,7 @@ const serve = process.argv.includes('--serve');
|
|||||||
const server = await createServer({
|
const server = await createServer({
|
||||||
configFile: false,
|
configFile: false,
|
||||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
optimizeDeps: {
|
|
||||||
include: [
|
|
||||||
'@tiptap/core',
|
|
||||||
'@tiptap/pm/state',
|
|
||||||
'@tiptap/vue-3',
|
|
||||||
'@tiptap/markdown',
|
|
||||||
'@tiptap/starter-kit',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: [
|
|
||||||
'vue',
|
|
||||||
'@tiptap/core',
|
|
||||||
'@tiptap/pm',
|
|
||||||
'prosemirror-state',
|
|
||||||
'prosemirror-model',
|
|
||||||
'prosemirror-view',
|
|
||||||
],
|
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
},
|
},
|
||||||
@@ -74,58 +56,6 @@ if (serve) {
|
|||||||
renderToString(
|
renderToString(
|
||||||
createSSRApp({ render: () => h(Description, { description }) }),
|
createSSRApp({ render: () => h(Description, { description }) }),
|
||||||
);
|
);
|
||||||
const cases = JSON.parse(
|
|
||||||
readFileSync(
|
|
||||||
new URL('./ticket-description-cases.json', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const contract = JSON.parse(
|
|
||||||
readFileSync(
|
|
||||||
new URL(
|
|
||||||
'../../resources/js/lib/ticket-description-format.json',
|
|
||||||
import.meta.url,
|
|
||||||
),
|
|
||||||
'utf8',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
cases.push({
|
|
||||||
name: 'MCP contract example',
|
|
||||||
value: contract.example,
|
|
||||||
supported: true,
|
|
||||||
});
|
|
||||||
for (const item of cases) {
|
|
||||||
assert.equal(
|
|
||||||
supportsVisualDescription(item.value),
|
|
||||||
item.supported,
|
|
||||||
item.name,
|
|
||||||
);
|
|
||||||
if (item.supported) {
|
|
||||||
assert.ok(parseDescription(item.value));
|
|
||||||
await render(item.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (process.argv.includes('--stored-description')) {
|
|
||||||
const saved = JSON.parse(readFileSync(0, 'utf8'));
|
|
||||||
assert.equal(supportsVisualDescription(saved.description), true);
|
|
||||||
const html = await render(saved.description);
|
|
||||||
assert.match(html, /<h2>Goal<\/h2>/);
|
|
||||||
assert.match(html, /<strong>context<\/strong>/);
|
|
||||||
assert.equal(
|
|
||||||
parseDescription(saved.description).content[0].type,
|
|
||||||
'heading',
|
|
||||||
);
|
|
||||||
for (const criterion of saved.criteria) {
|
|
||||||
assert.ok(
|
|
||||||
!html.includes(criterion.text),
|
|
||||||
'Criteria stay in the separate checklist',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const exampleHtml = await render(contract.example);
|
|
||||||
assert.match(exampleHtml, /<h2>Goal<\/h2>/);
|
|
||||||
assert.match(exampleHtml, /<strong>context<\/strong>/);
|
|
||||||
assert.doesNotMatch(exampleHtml, /acceptance_criteria|task checkboxes/);
|
|
||||||
const markdown =
|
const markdown =
|
||||||
'# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n<tag>\n```\n\n[Link](https://example.test/path)\n\n---';
|
'# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n<tag>\n```\n\n[Link](https://example.test/path)\n\n---';
|
||||||
assert.equal(supportsVisualDescription(markdown), true);
|
assert.equal(supportsVisualDescription(markdown), true);
|
||||||
|
|||||||
@@ -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, reactive, ref } from 'vue';
|
import { createSSRApp, h } 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,11 +398,6 @@ 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(
|
||||||
@@ -463,40 +458,6 @@ 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',
|
||||||
);
|
);
|
||||||
@@ -525,24 +486,6 @@ 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.',
|
||||||
@@ -620,9 +563,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.doesNotMatch(html, /Outgoing/);
|
assert.match(html, /Outgoing/);
|
||||||
const incoming = await render('?incoming');
|
const incoming = await render('?incoming');
|
||||||
assert.doesNotMatch(incoming, /Incoming/);
|
assert.match(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,6 +41,8 @@ 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()
|
||||||
@@ -50,13 +52,6 @@ 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',
|
||||||
});
|
});
|
||||||
@@ -73,7 +68,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('Action').focus();
|
await control('Link type').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(
|
||||||
@@ -123,13 +118,9 @@ 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()
|
await section().getByText('Outgoing', { exact: true }).waitFor();
|
||||||
.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 === '',
|
||||||
);
|
);
|
||||||
@@ -150,27 +141,6 @@ 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();
|
||||||
@@ -178,7 +148,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 ['Action', 'Linked ticket']) {
|
for (const label of ['Link type', '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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user