Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed8d0c6cc | ||
|
|
ba12e65e81 | ||
|
|
85b9409e3c | ||
|
|
f862a6a226 | ||
|
|
c8f494fe7c | ||
|
|
f7c3873c19 | ||
|
|
d8e1659f4b |
@@ -14,6 +14,7 @@ class DeleteProject
|
|||||||
DB::transaction(function () use ($project): void {
|
DB::transaction(function () use ($project): void {
|
||||||
$baseTemplate = $project->baseTemplate()->first();
|
$baseTemplate = $project->baseTemplate()->first();
|
||||||
|
|
||||||
|
$project->ticketStatuses()->delete();
|
||||||
$project->delete();
|
$project->delete();
|
||||||
$baseTemplate?->delete();
|
$baseTemplate?->delete();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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): void
|
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
|
||||||
{
|
{
|
||||||
$normalized = self::normalize($items);
|
$normalized = self::normalize($items);
|
||||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||||
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
|
|||||||
: $existingCriteria->get($criterionId);
|
: $existingCriteria->get($criterionId);
|
||||||
|
|
||||||
while (
|
while (
|
||||||
$criterion === null
|
$reuseUnidentifiedCriteria
|
||||||
|
&& $criterion === null
|
||||||
&& $fallbackIndex < $fallbackCriteria->count()
|
&& $fallbackIndex < $fallbackCriteria->count()
|
||||||
) {
|
) {
|
||||||
/** @var AcceptanceCriterion $candidate */
|
/** @var AcceptanceCriterion $candidate */
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ 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(
|
||||||
@@ -58,7 +59,7 @@ class UpdateProjectTicket
|
|||||||
);
|
);
|
||||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
||||||
|
|
||||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
|
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
|
||||||
if (! $ticketAttributes['is_blocked']) {
|
if (! $ticketAttributes['is_blocked']) {
|
||||||
$ticketAttributes['blocked_comment'] = null;
|
$ticketAttributes['blocked_comment'] = null;
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,9 @@ 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 = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
$currentAcceptanceCriteria = $replaceCriteriaWithIds
|
||||||
|
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
|
||||||
|
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||||
|
|
||||||
$changes = [];
|
$changes = [];
|
||||||
$summaryLabels = [];
|
$summaryLabels = [];
|
||||||
@@ -209,7 +212,7 @@ class UpdateProjectTicket
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
namespace App\Mcp\Servers;
|
namespace App\Mcp\Servers;
|
||||||
|
|
||||||
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;
|
||||||
@@ -16,8 +20,8 @@ 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.1.0')]
|
#[Version('0.2.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.')]
|
#[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 for compact current project metadata. Get tickets by exact ticket_key or ticket_id, or search_tickets with pagination. Use update_ticket for partial edits; omitted fields are preserved, nullable scalar null clears, and supplied lists replace the entire list. Use move_ticket_status for allowed workflow transitions. 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 array $tools = [
|
protected array $tools = [
|
||||||
@@ -29,6 +33,10 @@ 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 = [
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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;
|
||||||
@@ -78,6 +79,17 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -201,6 +213,18 @@ 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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?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 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['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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?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 with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
#[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.')]
|
||||||
#[IsReadOnly]
|
#[IsReadOnly]
|
||||||
class GetTicket extends Tool
|
class GetTicket extends Tool
|
||||||
{
|
{
|
||||||
@@ -25,11 +25,14 @@ 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', 'integer'],
|
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', '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 = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
|
$ticket = isset($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),
|
||||||
@@ -43,8 +46,8 @@ class GetTicket extends Tool
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'ticket_id' => $schema->integer()
|
'ticket_id' => $schema->integer()
|
||||||
->description('Ticket ID.')
|
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
|
||||||
->required(),
|
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?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.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?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.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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.'),
|
||||||
|
'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.'),
|
||||||
|
'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.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inertiajs/core": "2.3.18",
|
"@inertiajs/core": "2.3.18",
|
||||||
"@inertiajs/vue3": "^2.3.7",
|
"@inertiajs/vue3": "^2.3.7",
|
||||||
|
"@tiptap/core": "^3.31.3",
|
||||||
|
"@tiptap/markdown": "^3.31.3",
|
||||||
|
"@tiptap/pm": "^3.31.3",
|
||||||
|
"@tiptap/starter-kit": "^3.31.3",
|
||||||
|
"@tiptap/vue-3": "^3.31.3",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Generated
+481
@@ -221,6 +221,21 @@ importers:
|
|||||||
'@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.30(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':
|
||||||
|
specifier: ^3.31.3
|
||||||
|
version: 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/markdown':
|
||||||
|
specifier: ^3.31.3
|
||||||
|
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm':
|
||||||
|
specifier: ^3.31.3
|
||||||
|
version: 3.31.3
|
||||||
|
'@tiptap/starter-kit':
|
||||||
|
specifier: ^3.31.3
|
||||||
|
version: 3.31.3
|
||||||
|
'@tiptap/vue-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.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)
|
||||||
@@ -733,6 +748,160 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^2.7.0 || ^3.0.0
|
vue: ^2.7.0 || ^3.0.0
|
||||||
|
|
||||||
|
'@tiptap/core@3.31.3':
|
||||||
|
resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-blockquote@3.31.3':
|
||||||
|
resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-bold@3.31.3':
|
||||||
|
resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-bubble-menu@3.31.3':
|
||||||
|
resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-bullet-list@3.31.3':
|
||||||
|
resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-code-block@3.31.3':
|
||||||
|
resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-code@3.31.3':
|
||||||
|
resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-document@3.31.3':
|
||||||
|
resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-dropcursor@3.31.3':
|
||||||
|
resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extensions': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-floating-menu@3.31.3':
|
||||||
|
resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@floating-ui/dom': ^1.0.0
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-gapcursor@3.31.3':
|
||||||
|
resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extensions': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-hard-break@3.31.3':
|
||||||
|
resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-heading@3.31.3':
|
||||||
|
resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-horizontal-rule@3.31.3':
|
||||||
|
resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-italic@3.31.3':
|
||||||
|
resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-link@3.31.3':
|
||||||
|
resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-list-item@3.31.3':
|
||||||
|
resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-list-keymap@3.31.3':
|
||||||
|
resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-list@3.31.3':
|
||||||
|
resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-ordered-list@3.31.3':
|
||||||
|
resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-paragraph@3.31.3':
|
||||||
|
resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-strike@3.31.3':
|
||||||
|
resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-text@3.31.3':
|
||||||
|
resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-underline@3.31.3':
|
||||||
|
resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extensions@3.31.3':
|
||||||
|
resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/markdown@3.31.3':
|
||||||
|
resolution: {integrity: sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/pm@3.31.3':
|
||||||
|
resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==}
|
||||||
|
|
||||||
|
'@tiptap/starter-kit@3.31.3':
|
||||||
|
resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}
|
||||||
|
|
||||||
|
'@tiptap/vue-3@3.31.3':
|
||||||
|
resolution: {integrity: sha512-PNt0+rm7BXzhe/U+xlNnXBeJ6t9x2mE3902VXeIeEBxu5CrYLCJ0a5yHrof1jU3KwJrLOe/BtlGWjbq4qnTTfQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@floating-ui/dom': ^1.0.0
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
vue: ^3.0.0
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -1922,6 +2091,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
linkifyjs@4.3.3:
|
||||||
|
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1944,6 +2116,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
marked@17.0.6:
|
||||||
|
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2031,6 +2208,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
orderedmap@2.1.1:
|
||||||
|
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2158,6 +2338,45 @@ packages:
|
|||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
prosemirror-changeset@2.4.3:
|
||||||
|
resolution: {integrity: sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ==}
|
||||||
|
|
||||||
|
prosemirror-commands@1.7.2:
|
||||||
|
resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==}
|
||||||
|
|
||||||
|
prosemirror-dropcursor@1.8.3:
|
||||||
|
resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==}
|
||||||
|
|
||||||
|
prosemirror-gapcursor@1.4.1:
|
||||||
|
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||||
|
|
||||||
|
prosemirror-history@1.5.0:
|
||||||
|
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||||
|
|
||||||
|
prosemirror-inputrules@1.5.1:
|
||||||
|
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||||
|
|
||||||
|
prosemirror-keymap@1.2.3:
|
||||||
|
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||||
|
|
||||||
|
prosemirror-model@1.25.12:
|
||||||
|
resolution: {integrity: sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==}
|
||||||
|
|
||||||
|
prosemirror-schema-list@1.5.1:
|
||||||
|
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||||
|
|
||||||
|
prosemirror-state@1.4.4:
|
||||||
|
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||||
|
|
||||||
|
prosemirror-tables@1.8.5:
|
||||||
|
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||||
|
|
||||||
|
prosemirror-transform@1.12.1:
|
||||||
|
resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==}
|
||||||
|
|
||||||
|
prosemirror-view@1.42.5:
|
||||||
|
resolution: {integrity: sha512-7qcRaCWM48NC4utNQnx5Obvvs48/jMHXXvOXHH6WlBojmiK15eyxoHYlv5aJARHJHiFccSuWZ7ULW2SPrFAS/g==}
|
||||||
|
|
||||||
proxy-from-env@2.1.0:
|
proxy-from-env@2.1.0:
|
||||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2211,6 +2430,9 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rope-sequence@1.3.4:
|
||||||
|
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
@@ -2514,6 +2736,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8:
|
||||||
|
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||||
|
|
||||||
which-boxed-primitive@1.1.1:
|
which-boxed-primitive@1.1.1:
|
||||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2896,6 +3121,178 @@ snapshots:
|
|||||||
'@tanstack/virtual-core': 3.13.23
|
'@tanstack/virtual-core': 3.13.23
|
||||||
vue: 3.5.30(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 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)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extensions': 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)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
linkifyjs: 4.3.3
|
||||||
|
|
||||||
|
'@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 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)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/markdown@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
marked: 17.0.6
|
||||||
|
|
||||||
|
'@tiptap/pm@3.31.3':
|
||||||
|
dependencies:
|
||||||
|
prosemirror-changeset: 2.4.3
|
||||||
|
prosemirror-commands: 1.7.2
|
||||||
|
prosemirror-dropcursor: 1.8.3
|
||||||
|
prosemirror-gapcursor: 1.4.1
|
||||||
|
prosemirror-history: 1.5.0
|
||||||
|
prosemirror-inputrules: 1.5.1
|
||||||
|
prosemirror-keymap: 1.2.3
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-schema-list: 1.5.1
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-tables: 1.8.5
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
'@tiptap/starter-kit@3.31.3':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-underline': 3.31.3(@tiptap/core@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/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:
|
||||||
|
'@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)
|
||||||
|
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-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)
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -4179,6 +4576,8 @@ snapshots:
|
|||||||
lightningcss-win32-arm64-msvc: 1.33.0
|
lightningcss-win32-arm64-msvc: 1.33.0
|
||||||
lightningcss-win32-x64-msvc: 1.33.0
|
lightningcss-win32-x64-msvc: 1.33.0
|
||||||
|
|
||||||
|
linkifyjs@4.3.3: {}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
@@ -4197,6 +4596,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@17.0.6: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
@@ -4284,6 +4685,8 @@ snapshots:
|
|||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
word-wrap: 1.2.5
|
word-wrap: 1.2.5
|
||||||
|
|
||||||
|
orderedmap@2.1.1: {}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
@@ -4341,6 +4744,80 @@ snapshots:
|
|||||||
|
|
||||||
prettier@3.8.2: {}
|
prettier@3.8.2: {}
|
||||||
|
|
||||||
|
prosemirror-changeset@2.4.3:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
|
prosemirror-commands@1.7.2:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
|
prosemirror-dropcursor@1.8.3:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
prosemirror-gapcursor@1.4.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-keymap: 1.2.3
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
prosemirror-history@1.5.0:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
rope-sequence: 1.3.4
|
||||||
|
|
||||||
|
prosemirror-inputrules@1.5.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
|
prosemirror-keymap@1.2.3:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
w3c-keyname: 2.2.8
|
||||||
|
|
||||||
|
prosemirror-model@1.25.12:
|
||||||
|
dependencies:
|
||||||
|
orderedmap: 2.1.1
|
||||||
|
|
||||||
|
prosemirror-schema-list@1.5.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
|
prosemirror-state@1.4.4:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
prosemirror-tables@1.8.5:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-keymap: 1.2.3
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
prosemirror-transform@1.12.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
|
||||||
|
prosemirror-view@1.42.5:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
proxy-from-env@2.1.0: {}
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
@@ -4423,6 +4900,8 @@ snapshots:
|
|||||||
'@rolldown/binding-win32-arm64-msvc': 1.2.5
|
'@rolldown/binding-win32-arm64-msvc': 1.2.5
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.2.5
|
'@rolldown/binding-win32-x64-msvc': 1.2.5
|
||||||
|
|
||||||
|
rope-sequence@1.3.4: {}
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
@@ -4768,6 +5247,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
which-boxed-primitive@1.1.1:
|
which-boxed-primitive@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-bigint: 1.1.0
|
is-bigint: 1.1.0
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import './theme.css';
|
@import './theme.css';
|
||||||
|
@import './ticket-description.css';
|
||||||
|
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.ticket-description {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
.ticket-description > * + * {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.ticket-description h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ticket-description h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ticket-description h3,
|
||||||
|
.ticket-description h4,
|
||||||
|
.ticket-description h5,
|
||||||
|
.ticket-description h6 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ticket-description ul {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.ticket-description ol {
|
||||||
|
list-style: decimal;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.ticket-description li > ul,
|
||||||
|
.ticket-description li > ol {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.ticket-description blockquote {
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
|
padding-left: 1rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ticket-description a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.ticket-description code {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.ticket-description pre {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
padding: 0.75rem;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.ticket-description pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.ticket-description hr {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
margin-block: 1rem;
|
||||||
|
}
|
||||||
|
.ticket-description:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineComponent, h } from 'vue';
|
||||||
|
import {
|
||||||
|
parseDescription,
|
||||||
|
renderDescriptionNode,
|
||||||
|
supportsVisualDescription,
|
||||||
|
} from '@/lib/ticket-description';
|
||||||
|
|
||||||
|
const props = defineProps<{ description: string }>();
|
||||||
|
const document = computed(() =>
|
||||||
|
supportsVisualDescription(props.description)
|
||||||
|
? parseDescription(props.description)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
const Content = defineComponent(
|
||||||
|
() => () =>
|
||||||
|
document.value
|
||||||
|
? h('div', { class: 'ticket-description' }, [
|
||||||
|
renderDescriptionNode(document.value),
|
||||||
|
])
|
||||||
|
: h(
|
||||||
|
'p',
|
||||||
|
{
|
||||||
|
class: 'wrap-anywhere whitespace-pre-wrap text-sm leading-7',
|
||||||
|
},
|
||||||
|
props.description,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Content />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Strikethrough,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
SquareCode,
|
||||||
|
Link,
|
||||||
|
Unlink,
|
||||||
|
Undo2,
|
||||||
|
Redo2,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
descriptionExtensions,
|
||||||
|
parseDescription,
|
||||||
|
safeDescriptionLink,
|
||||||
|
supportsVisualDescription,
|
||||||
|
} from '@/lib/ticket-description';
|
||||||
|
import type {
|
||||||
|
DescriptionToolbarAction,
|
||||||
|
TicketDescriptionEditorProps,
|
||||||
|
} from '@/types/ticket-description';
|
||||||
|
|
||||||
|
const props = defineProps<TicketDescriptionEditorProps>();
|
||||||
|
const model = defineModel<string>({ required: true });
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const sourceMode = ref(!supportsVisualDescription(model.value));
|
||||||
|
const supported = computed(() => supportsVisualDescription(model.value));
|
||||||
|
const visual = computed(() => !sourceMode.value && supported.value);
|
||||||
|
const linkOpen = ref(false);
|
||||||
|
const linkUrl = ref('');
|
||||||
|
const linkError = ref('');
|
||||||
|
const linkInput = ref<InstanceType<typeof Input> | null>(null);
|
||||||
|
let currentMarkdown = model.value;
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: descriptionExtensions(),
|
||||||
|
content: supported.value ? parseDescription(model.value) : '',
|
||||||
|
editable: !props.disabled,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
id: props.id,
|
||||||
|
role: 'textbox',
|
||||||
|
'aria-multiline': 'true',
|
||||||
|
'aria-labelledby': `${props.id}-label`,
|
||||||
|
'aria-describedby': `${props.id}-hint ${props.id}-error`,
|
||||||
|
'aria-invalid': String(Boolean(props.error)),
|
||||||
|
'aria-disabled': String(Boolean(props.disabled)),
|
||||||
|
class: 'ticket-description min-h-48 px-3 py-3 outline-none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor: instance }) => {
|
||||||
|
currentMarkdown = instance.isEmpty ? '' : instance.getMarkdown();
|
||||||
|
model.value = currentMarkdown;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(model, (value) => {
|
||||||
|
if (value === currentMarkdown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentMarkdown = value;
|
||||||
|
if (supportsVisualDescription(value)) {
|
||||||
|
editor.value?.commands.setContent(parseDescription(value), {
|
||||||
|
emitUpdate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
(disabled) => {
|
||||||
|
editor.value?.setEditable(!disabled, false);
|
||||||
|
if (disabled) {
|
||||||
|
linkOpen.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
watch([() => props.error, () => props.disabled], () => {
|
||||||
|
editor.value?.setOptions({
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
...editor.value.options.editorProps.attributes,
|
||||||
|
'aria-invalid': String(Boolean(props.error)),
|
||||||
|
'aria-disabled': String(Boolean(props.disabled)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions: DescriptionToolbarAction[] = [
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.bold',
|
||||||
|
icon: Bold,
|
||||||
|
active: 'bold',
|
||||||
|
run: (c) => c.toggleBold(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.italic',
|
||||||
|
icon: Italic,
|
||||||
|
active: 'italic',
|
||||||
|
run: (c) => c.toggleItalic(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.strike',
|
||||||
|
icon: Strikethrough,
|
||||||
|
active: 'strike',
|
||||||
|
run: (c) => c.toggleStrike(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.heading1',
|
||||||
|
icon: Heading1,
|
||||||
|
active: 'heading',
|
||||||
|
attributes: { level: 1 },
|
||||||
|
run: (c) => c.toggleHeading({ level: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.heading2',
|
||||||
|
icon: Heading2,
|
||||||
|
active: 'heading',
|
||||||
|
attributes: { level: 2 },
|
||||||
|
run: (c) => c.toggleHeading({ level: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.heading3',
|
||||||
|
icon: Heading3,
|
||||||
|
active: 'heading',
|
||||||
|
attributes: { level: 3 },
|
||||||
|
run: (c) => c.toggleHeading({ level: 3 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.bulletList',
|
||||||
|
icon: List,
|
||||||
|
active: 'bulletList',
|
||||||
|
run: (c) => c.toggleBulletList(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.orderedList',
|
||||||
|
icon: ListOrdered,
|
||||||
|
active: 'orderedList',
|
||||||
|
run: (c) => c.toggleOrderedList(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.quote',
|
||||||
|
icon: Quote,
|
||||||
|
active: 'blockquote',
|
||||||
|
run: (c) => c.toggleBlockquote(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.code',
|
||||||
|
icon: Code,
|
||||||
|
active: 'code',
|
||||||
|
run: (c) => c.toggleCode(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.codeBlock',
|
||||||
|
icon: SquareCode,
|
||||||
|
active: 'codeBlock',
|
||||||
|
run: (c) => c.toggleCodeBlock(),
|
||||||
|
},
|
||||||
|
{ label: 'descriptionEditor.undo', icon: Undo2, run: (c) => c.undo() },
|
||||||
|
{ label: 'descriptionEditor.redo', icon: Redo2, run: (c) => c.redo() },
|
||||||
|
];
|
||||||
|
function runAction(action: DescriptionToolbarAction): void {
|
||||||
|
if (editor.value && !props.disabled) {
|
||||||
|
action.run(editor.value.chain().focus()).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function switchMode(): void {
|
||||||
|
linkOpen.value = false;
|
||||||
|
sourceMode.value = !sourceMode.value;
|
||||||
|
}
|
||||||
|
async function editLink(): Promise<void> {
|
||||||
|
linkUrl.value = editor.value?.getAttributes('link').href ?? '';
|
||||||
|
linkError.value = '';
|
||||||
|
linkOpen.value = true;
|
||||||
|
await nextTick();
|
||||||
|
linkInput.value?.$el?.focus();
|
||||||
|
}
|
||||||
|
function saveLink(): void {
|
||||||
|
if (!editor.value || props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const href = safeDescriptionLink(linkUrl.value.trim());
|
||||||
|
if (!href) {
|
||||||
|
linkError.value = t('descriptionEditor.invalidLink');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chain = editor.value.chain().focus().extendMarkRange('link');
|
||||||
|
if (editor.value.state.selection.empty && !editor.value.isActive('link')) {
|
||||||
|
chain
|
||||||
|
.insertContent({
|
||||||
|
type: 'text',
|
||||||
|
text: href,
|
||||||
|
marks: [{ type: 'link', attrs: { href } }],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
chain.setLink({ href }).run();
|
||||||
|
}
|
||||||
|
linkOpen.value = false;
|
||||||
|
}
|
||||||
|
function closeLink(): void {
|
||||||
|
linkOpen.value = false;
|
||||||
|
editor.value?.commands.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Label
|
||||||
|
:id="`${id}-label`"
|
||||||
|
:for="id"
|
||||||
|
@click="visual && editor?.commands.focus()"
|
||||||
|
>{{ label }}</Label
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="disabled || (!supported && !visual)"
|
||||||
|
:aria-pressed="sourceMode"
|
||||||
|
@click="switchMode"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
visual
|
||||||
|
? t('descriptionEditor.source')
|
||||||
|
: t('descriptionEditor.visual')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="border-input bg-card focus-within:border-ring focus-within:ring-ring/35 shadow-xs min-w-0 overflow-hidden rounded-md border focus-within:ring-2"
|
||||||
|
:class="{ 'border-destructive': error, 'opacity-60': disabled }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="visual"
|
||||||
|
role="group"
|
||||||
|
:aria-label="t('descriptionEditor.formatting')"
|
||||||
|
class="bg-muted/30 flex flex-wrap gap-1 border-b p-2"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-for="action in actions"
|
||||||
|
:key="action.label"
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
:variant="
|
||||||
|
action.active &&
|
||||||
|
editor?.isActive(action.active, action.attributes)
|
||||||
|
? 'secondary'
|
||||||
|
: 'ghost'
|
||||||
|
"
|
||||||
|
:title="t(action.label)"
|
||||||
|
:aria-label="t(action.label)"
|
||||||
|
:aria-pressed="
|
||||||
|
action.active
|
||||||
|
? Boolean(
|
||||||
|
editor?.isActive(
|
||||||
|
action.active,
|
||||||
|
action.attributes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
!editor ||
|
||||||
|
!action.run(editor.can().chain()).run()
|
||||||
|
"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="runAction(action)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="action.icon"
|
||||||
|
class="size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('descriptionEditor.link')"
|
||||||
|
:aria-label="t('descriptionEditor.link')"
|
||||||
|
:aria-pressed="Boolean(editor?.isActive('link'))"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
!editor ||
|
||||||
|
!editor.can().setLink({ href: 'https://example.com' })
|
||||||
|
"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="editLink"
|
||||||
|
><Link class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('descriptionEditor.unlink')"
|
||||||
|
:aria-label="t('descriptionEditor.unlink')"
|
||||||
|
:disabled="disabled || !editor?.isActive('link')"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="
|
||||||
|
editor
|
||||||
|
?.chain()
|
||||||
|
.focus()
|
||||||
|
.extendMarkRange('link')
|
||||||
|
.unsetLink()
|
||||||
|
.run()
|
||||||
|
"
|
||||||
|
><Unlink class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="linkOpen && visual"
|
||||||
|
class="grid gap-2 border-b p-3"
|
||||||
|
@keydown.esc.prevent.stop="closeLink"
|
||||||
|
>
|
||||||
|
<Label :for="`${id}-link`">{{
|
||||||
|
t('descriptionEditor.linkUrl')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
:id="`${id}-link`"
|
||||||
|
ref="linkInput"
|
||||||
|
v-model="linkUrl"
|
||||||
|
type="text"
|
||||||
|
:disabled="disabled"
|
||||||
|
:invalid="Boolean(linkError)"
|
||||||
|
:aria-describedby="`${id}-link-error`"
|
||||||
|
placeholder="https://"
|
||||||
|
@keydown.enter.prevent.stop="saveLink"
|
||||||
|
/>
|
||||||
|
<InputError :id="`${id}-link-error`" :message="linkError" />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="saveLink"
|
||||||
|
>{{ t('descriptionEditor.applyLink') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
@click="closeLink"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditorContent v-if="visual && editor" :editor="editor" />
|
||||||
|
<textarea
|
||||||
|
v-else
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:disabled="disabled"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:aria-labelledby="`${id}-label`"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
class="min-h-48 w-full resize-y bg-transparent px-3 py-3 font-mono text-sm outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
|
||||||
|
{{
|
||||||
|
supported
|
||||||
|
? t('descriptionEditor.hint')
|
||||||
|
: t('descriptionEditor.unsupported')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError :id="`${id}-error`" :message="error" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import TicketDescriptionEditor from '@/components/tickets/TicketDescriptionEditor.vue';
|
||||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
@@ -72,10 +73,9 @@ const statusOptions = computed(
|
|||||||
:placeholder="t('ticketForm.titlePlaceholder')"
|
:placeholder="t('ticketForm.titlePlaceholder')"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<TicketFormField
|
<TicketDescriptionEditor
|
||||||
id="description"
|
id="description"
|
||||||
v-model="description"
|
v-model="description"
|
||||||
kind="textarea"
|
|
||||||
:label="t('ticketDetail.description')"
|
:label="t('ticketDetail.description')"
|
||||||
:error="errors.description"
|
:error="errors.description"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
watch,
|
watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
|
import TicketDescription from '@/components/tickets/TicketDescription.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
@@ -272,16 +273,12 @@ const submit = (): void => {
|
|||||||
</h2></CardHeader
|
</h2></CardHeader
|
||||||
>
|
>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p
|
<TicketDescription
|
||||||
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
|
v-if="ticket.description"
|
||||||
:class="{
|
:description="ticket.description"
|
||||||
'text-muted-foreground': !ticket.description,
|
/>
|
||||||
}"
|
<p v-else class="text-muted-foreground text-sm leading-7">
|
||||||
>
|
{{ t('ticketDetail.noDescription') }}
|
||||||
{{
|
|
||||||
ticket.description ||
|
|
||||||
t('ticketDetail.noDescription')
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
import type {
|
||||||
|
TicketDetailRelationshipsProps,
|
||||||
|
TicketLinkFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
|
||||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
defineEmits<{ editParent: [] }>();
|
defineEmits<{ editParent: [] }>();
|
||||||
@@ -33,7 +37,7 @@ const canAddLink = computed(
|
|||||||
props.ticketLinkTypes.length > 0 &&
|
props.ticketLinkTypes.length > 0 &&
|
||||||
props.ticketLinkOptions.length > 0,
|
props.ticketLinkOptions.length > 0,
|
||||||
);
|
);
|
||||||
const form = useForm({
|
const form = useForm<TicketLinkFormData>({
|
||||||
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||||
link_direction: 'source',
|
link_direction: 'source',
|
||||||
linked_ticket_id: '',
|
linked_ticket_id: '',
|
||||||
@@ -41,10 +45,9 @@ const form = useForm({
|
|||||||
const protection = useFormProtection(form, {
|
const protection = useFormProtection(form, {
|
||||||
isDirty: () => props.canEditTicket && form.isDirty,
|
isDirty: () => props.canEditTicket && form.isDirty,
|
||||||
});
|
});
|
||||||
const selectedType = computed(() =>
|
const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
|
||||||
props.ticketLinkTypes.find(
|
() => props.ticketLinkTypes,
|
||||||
(type) => String(type.id) === String(form.ticket_link_type_id),
|
form,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const selectedTicket = computed(() =>
|
const selectedTicket = computed(() =>
|
||||||
props.ticketLinkOptions.find(
|
props.ticketLinkOptions.find(
|
||||||
@@ -221,40 +224,18 @@ function submit(): void {
|
|||||||
/>
|
/>
|
||||||
<fieldset
|
<fieldset
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
class="grid min-w-0 gap-4 sm:grid-cols-3"
|
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
|
||||||
>
|
>
|
||||||
<TicketFormField
|
<TicketFormField
|
||||||
id="ticket_link_type_id"
|
id="ticket_link_type_id"
|
||||||
v-model="form.ticket_link_type_id"
|
v-model="selectedAction"
|
||||||
kind="select"
|
kind="select"
|
||||||
:label="t('ticketDetail.linkType')"
|
:label="t('ticketDetail.linkAction')"
|
||||||
:options="
|
:options="actions"
|
||||||
ticketLinkTypes.map((type) => ({
|
:error="
|
||||||
value: type.id,
|
form.errors.ticket_link_type_id ||
|
||||||
label: `${type.label} / ${type.counterpart_label}`,
|
form.errors.link_direction
|
||||||
}))
|
|
||||||
"
|
"
|
||||||
:error="form.errors.ticket_link_type_id"
|
|
||||||
:disabled="form.processing"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<TicketFormField
|
|
||||||
id="link_direction"
|
|
||||||
v-model="form.link_direction"
|
|
||||||
kind="select"
|
|
||||||
:label="t('ticketRelationships.direction')"
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
value: 'source',
|
|
||||||
label: selectedType?.label ?? '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'counterpart',
|
|
||||||
label:
|
|
||||||
selectedType?.counterpart_label ?? '',
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
:error="form.errors.link_direction"
|
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -281,26 +262,19 @@ function submit(): void {
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p
|
<p
|
||||||
v-if="selectedType && selectedTicket"
|
v-if="selectedActionLabel && selectedTicket"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-xs">{{
|
<span class="font-mono text-xs">{{
|
||||||
ticket.ticket_key
|
ticket.ticket_key
|
||||||
}}</span
|
}}</span
|
||||||
><span>{{
|
><span>{{ selectedActionLabel }}</span
|
||||||
form.link_direction === 'source'
|
|
||||||
? selectedType.label
|
|
||||||
: selectedType.counterpart_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,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, useForm } from '@inertiajs/vue3';
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
import { Unlink } from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
|
||||||
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
@@ -20,7 +18,6 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{ removed: [] }>();
|
const emit = defineEmits<{ removed: [] }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
|
||||||
const form = useForm({});
|
const form = useForm({});
|
||||||
const protection = useFormProtection(form);
|
const protection = useFormProtection(form);
|
||||||
function remove(): void {
|
function remove(): void {
|
||||||
@@ -47,27 +44,6 @@ function remove(): void {
|
|||||||
:data-ticket-link="link.id"
|
:data-ticket-link="link.id"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="text-muted-foreground max-w-full whitespace-normal"
|
|
||||||
>
|
|
||||||
<ArrowUpRight
|
|
||||||
v-if="outgoing"
|
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/><ArrowDownLeft
|
|
||||||
v-else
|
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
outgoing
|
|
||||||
? 'ticketRelationships.outgoing'
|
|
||||||
: 'ticketRelationships.incoming',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
<span class="text-muted-foreground font-mono text-xs">{{
|
<span class="text-muted-foreground font-mono text-xs">{{
|
||||||
ticketKey
|
ticketKey
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { computed, toValue } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter } from 'vue';
|
||||||
|
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||||
|
import type {
|
||||||
|
TicketLinkActionOption,
|
||||||
|
TicketLinkFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
export function useTicketLinkActions(
|
||||||
|
ticketLinkTypes: MaybeRefOrGetter<TicketLinkTypeModel[]>,
|
||||||
|
form: TicketLinkFormData,
|
||||||
|
) {
|
||||||
|
const actions = computed<TicketLinkActionOption[]>(() =>
|
||||||
|
toValue(ticketLinkTypes).flatMap((type) => {
|
||||||
|
const choices: TicketLinkActionOption[] = [
|
||||||
|
{
|
||||||
|
value: `${type.id}:source`,
|
||||||
|
label: type.label,
|
||||||
|
ticketLinkTypeId: type.id,
|
||||||
|
direction: 'source',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
type.counterpart_label &&
|
||||||
|
type.counterpart_label !== type.label
|
||||||
|
) {
|
||||||
|
choices.push({
|
||||||
|
value: `${type.id}:counterpart`,
|
||||||
|
label: type.counterpart_label,
|
||||||
|
ticketLinkTypeId: type.id,
|
||||||
|
direction: 'counterpart',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return choices;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const selectedAction = computed({
|
||||||
|
get: () => `${form.ticket_link_type_id}:${form.link_direction}`,
|
||||||
|
set: (value: string | number | null) => {
|
||||||
|
const action = actions.value.find(
|
||||||
|
(option) => option.value === value,
|
||||||
|
);
|
||||||
|
if (action) {
|
||||||
|
form.ticket_link_type_id = action.ticketLinkTypeId;
|
||||||
|
form.link_direction = action.direction;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const selectedActionLabel = computed(
|
||||||
|
() =>
|
||||||
|
actions.value.find(
|
||||||
|
(action) => action.value === selectedAction.value,
|
||||||
|
)?.label,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { actions, selectedAction, selectedActionLabel };
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { JSONContent } from '@tiptap/core';
|
||||||
|
import { Markdown, MarkdownManager } from '@tiptap/markdown';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { h } from 'vue';
|
||||||
|
import type { VNodeChild } from 'vue';
|
||||||
|
|
||||||
|
export function safeDescriptionLink(value: unknown): string | null {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
/[\s\\]/u.test(value) ||
|
||||||
|
[...value].some(
|
||||||
|
(character) =>
|
||||||
|
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (/^(https?:\/\/|mailto:)/i.test(value)) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return ['http:', 'https:', 'mailto:'].includes(url.protocol)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return /^(\/(?!\/)|#)/.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descriptionExtensions() {
|
||||||
|
return [
|
||||||
|
StarterKit.configure({
|
||||||
|
underline: false,
|
||||||
|
trailingNode: false,
|
||||||
|
link: {
|
||||||
|
openOnClick: false,
|
||||||
|
autolink: false,
|
||||||
|
isAllowedUri: (url) => safeDescriptionLink(url) !== null,
|
||||||
|
HTMLAttributes: {
|
||||||
|
rel: 'noopener noreferrer',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = new MarkdownManager({
|
||||||
|
extensions: descriptionExtensions(),
|
||||||
|
markedOptions: { gfm: true, breaks: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
|
||||||
|
export function supportsVisualDescription(value: string): boolean {
|
||||||
|
let supported = true;
|
||||||
|
markdown.instance.walkTokens(markdown.instance.lexer(value), (token) => {
|
||||||
|
if (
|
||||||
|
['html', 'table', 'image'].includes(token.type) ||
|
||||||
|
(token.type === 'list_item' && token.task) ||
|
||||||
|
(token.type === 'link' && !safeDescriptionLink(token.href))
|
||||||
|
) {
|
||||||
|
supported = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDescription(value: string): JSONContent {
|
||||||
|
return markdown.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build Vue nodes from an explicit schema; never insert user-provided HTML. */
|
||||||
|
export function renderDescriptionNode(node: JSONContent): VNodeChild {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
let content: VNodeChild = node.text ?? '';
|
||||||
|
for (const mark of node.marks ?? []) {
|
||||||
|
const tag = {
|
||||||
|
bold: 'strong',
|
||||||
|
italic: 'em',
|
||||||
|
strike: 's',
|
||||||
|
code: 'code',
|
||||||
|
}[mark.type];
|
||||||
|
if (tag) {
|
||||||
|
content = h(tag, {}, [content]);
|
||||||
|
} else if (mark.type === 'link') {
|
||||||
|
const href = safeDescriptionLink(mark.attrs?.href);
|
||||||
|
if (href) {
|
||||||
|
content = h(
|
||||||
|
'a',
|
||||||
|
{ href, target: '_blank', rel: 'noopener noreferrer' },
|
||||||
|
[content],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
const children = (node.content ?? []).map(renderDescriptionNode);
|
||||||
|
switch (node.type) {
|
||||||
|
case 'doc':
|
||||||
|
return children;
|
||||||
|
case 'paragraph':
|
||||||
|
return h('p', {}, children);
|
||||||
|
case 'heading':
|
||||||
|
return h(
|
||||||
|
`h${Math.min(6, Math.max(1, Number(node.attrs?.level) || 1))}`,
|
||||||
|
{},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
case 'bulletList':
|
||||||
|
return h('ul', {}, children);
|
||||||
|
case 'orderedList':
|
||||||
|
return h('ol', { start: Number(node.attrs?.start) || 1 }, children);
|
||||||
|
case 'listItem':
|
||||||
|
return h('li', {}, children);
|
||||||
|
case 'blockquote':
|
||||||
|
return h('blockquote', {}, children);
|
||||||
|
case 'codeBlock':
|
||||||
|
return h('pre', {}, [h('code', {}, children)]);
|
||||||
|
case 'hardBreak':
|
||||||
|
return h('br');
|
||||||
|
case 'horizontalRule':
|
||||||
|
return h('hr');
|
||||||
|
default:
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,31 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'descriptionEditor.bold': 'Bold',
|
||||||
|
'descriptionEditor.italic': 'Italic',
|
||||||
|
'descriptionEditor.strike': 'Strikethrough',
|
||||||
|
'descriptionEditor.heading1': 'Heading 1',
|
||||||
|
'descriptionEditor.heading2': 'Heading 2',
|
||||||
|
'descriptionEditor.heading3': 'Heading 3',
|
||||||
|
'descriptionEditor.bulletList': 'Bullet list',
|
||||||
|
'descriptionEditor.orderedList': 'Numbered list',
|
||||||
|
'descriptionEditor.quote': 'Quote',
|
||||||
|
'descriptionEditor.code': 'Inline code',
|
||||||
|
'descriptionEditor.codeBlock': 'Code block',
|
||||||
|
'descriptionEditor.undo': 'Undo',
|
||||||
|
'descriptionEditor.redo': 'Redo',
|
||||||
|
'descriptionEditor.link': 'Add or edit link',
|
||||||
|
'descriptionEditor.unlink': 'Remove link',
|
||||||
|
'descriptionEditor.linkUrl': 'Link address',
|
||||||
|
'descriptionEditor.applyLink': 'Apply link',
|
||||||
|
'descriptionEditor.invalidLink':
|
||||||
|
'Enter a valid https://, http://, mailto:, /path or #anchor address without spaces.',
|
||||||
|
'descriptionEditor.formatting': 'Description formatting',
|
||||||
|
'descriptionEditor.source': 'Markdown',
|
||||||
|
'descriptionEditor.visual': 'Visual editor',
|
||||||
|
'descriptionEditor.hint':
|
||||||
|
'Format your description using the toolbar or edit its Markdown source.',
|
||||||
|
'descriptionEditor.unsupported':
|
||||||
|
'This description contains formatting that requires Markdown editing. Its original content is preserved.',
|
||||||
|
|
||||||
'nativeApp.title': 'App',
|
'nativeApp.title': 'App',
|
||||||
'nativeApp.description': 'Manage the KanbanGit app on this device.',
|
'nativeApp.description': 'Manage the KanbanGit app on this device.',
|
||||||
'nativeApp.workspace': 'Workspace',
|
'nativeApp.workspace': 'Workspace',
|
||||||
@@ -1186,7 +1213,7 @@ export const en = {
|
|||||||
'ticketDetail.linkedTickets': 'Linked tickets',
|
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||||
'ticketDetail.linkedTicketsDescription':
|
'ticketDetail.linkedTicketsDescription':
|
||||||
'Track cross-ticket relationships with project-defined link labels.',
|
'Track cross-ticket relationships with project-defined link labels.',
|
||||||
'ticketDetail.linkType': 'Link type',
|
'ticketDetail.linkAction': 'Action',
|
||||||
'ticketDetail.linkedTicket': 'Linked ticket',
|
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||||
'ticketDetail.saveLink': 'Save link',
|
'ticketDetail.saveLink': 'Save link',
|
||||||
'ticketDetail.noLinks':
|
'ticketDetail.noLinks':
|
||||||
@@ -1200,11 +1227,6 @@ export const en = {
|
|||||||
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||||
'ticketRelationships.addLink': 'Add or update a link',
|
'ticketRelationships.addLink': 'Add or update a link',
|
||||||
'ticketRelationships.selectTicket': 'Select a ticket',
|
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||||
'ticketRelationships.direction': 'This ticket',
|
|
||||||
'ticketRelationships.directionHelp':
|
|
||||||
'Choose how this ticket relates to the linked ticket. The reverse label appears on the other ticket. Saving an existing pair updates its type and direction.',
|
|
||||||
'ticketRelationships.outgoing': 'Outgoing',
|
|
||||||
'ticketRelationships.incoming': 'Incoming',
|
|
||||||
'ticketRelationships.removeLink': 'Remove link to {key}',
|
'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.',
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ChainedCommands } from '@tiptap/core';
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
import type { TranslationKey } from '@/types/localization';
|
||||||
|
|
||||||
|
export type TicketDescriptionEditorProps = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DescriptionToolbarAction = {
|
||||||
|
label: TranslationKey;
|
||||||
|
icon: Component;
|
||||||
|
active?: string;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
run: (chain: ChainedCommands) => ChainedCommands;
|
||||||
|
};
|
||||||
@@ -67,3 +67,14 @@ export type TicketBranchFormData = {
|
|||||||
branch_name: string;
|
branch_name: string;
|
||||||
repository_connection_id: string;
|
repository_connection_id: string;
|
||||||
};
|
};
|
||||||
|
export type TicketLinkFormData = {
|
||||||
|
ticket_link_type_id: number | string;
|
||||||
|
link_direction: 'source' | 'counterpart';
|
||||||
|
linked_ticket_id: number | string;
|
||||||
|
};
|
||||||
|
export type TicketLinkActionOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
ticketLinkTypeId: number;
|
||||||
|
direction: TicketLinkFormData['link_direction'];
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<?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,6 +805,9 @@ class ProjectControllerTest extends TestCase
|
|||||||
$this->assertDatabaseMissing('board_columns', [
|
$this->assertDatabaseMissing('board_columns', [
|
||||||
'id' => $boardColumn->id,
|
'id' => $boardColumn->id,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseMissing('ticket_statuses', [
|
||||||
|
'id' => $ticketStatus->id,
|
||||||
|
]);
|
||||||
$this->assertDatabaseMissing('tickets', [
|
$this->assertDatabaseMissing('tickets', [
|
||||||
'id' => $ticket->id,
|
'id' => $ticket->id,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TicketDescriptionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_description_formatting_and_safe_rendering(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/ticket-description.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Ticket description checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_markdown_descriptions_can_be_created_read_updated_and_cleared(): void
|
||||||
|
{
|
||||||
|
$this->withoutVite();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['view', 'write']);
|
||||||
|
$description = "## Plan\n\n**Bold** and *italic*\n\n- First\n- [Second](https://example.test)\n\n```php\necho '<tag>';\n```";
|
||||||
|
$payload = [
|
||||||
|
'title' => 'Formatted description',
|
||||||
|
'description' => $description,
|
||||||
|
'ticket_type_id' => $project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
|
||||||
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
|
'ticket_status_id' => $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail()->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('projects.tickets.store', $project), $payload)
|
||||||
|
->assertSessionHasNoErrors()->assertRedirect();
|
||||||
|
$ticket = $project->tickets()->sole();
|
||||||
|
$this->assertSame($description, $ticket->description);
|
||||||
|
$this->get(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description));
|
||||||
|
|
||||||
|
$updated = "> Updated quote\n\n1. One\n2. Two\n\n`inline`";
|
||||||
|
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
...$payload, 'description' => $updated, 'is_blocked' => false,
|
||||||
|
])->assertSessionHasNoErrors()->assertRedirect();
|
||||||
|
$this->assertSame($updated, $ticket->fresh()->description);
|
||||||
|
|
||||||
|
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
...$payload, 'description' => ['invalid'], 'is_blocked' => false,
|
||||||
|
])->assertSessionHasErrors('description');
|
||||||
|
$this->assertSame($updated, $ticket->fresh()->description);
|
||||||
|
|
||||||
|
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
...$payload, 'description' => '', 'is_blocked' => false,
|
||||||
|
])->assertSessionHasNoErrors()->assertRedirect();
|
||||||
|
$this->assertNull($ticket->fresh()->description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { createApp, h, nextTick, reactive, ref } from 'vue';
|
||||||
|
import TicketDescriptionEditor from '../../resources/js/components/tickets/TicketDescriptionEditor.vue';
|
||||||
|
import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
|
||||||
|
const original = 'First line\nSecond line\n\n**Existing bold**';
|
||||||
|
const state = reactive({
|
||||||
|
value: original,
|
||||||
|
disabled: false,
|
||||||
|
error: '',
|
||||||
|
result: 'Running browser checks…',
|
||||||
|
updates: 0,
|
||||||
|
});
|
||||||
|
const component = ref(null);
|
||||||
|
const app = createApp({
|
||||||
|
render: () =>
|
||||||
|
h('main', { class: 'mx-auto max-w-3xl space-y-6 p-6' }, [
|
||||||
|
h(
|
||||||
|
'h1',
|
||||||
|
{ class: 'text-xl font-semibold' },
|
||||||
|
'Ticket description editor',
|
||||||
|
),
|
||||||
|
h('p', { id: 'test-result', role: 'status' }, state.result),
|
||||||
|
h(TicketDescriptionEditor, {
|
||||||
|
ref: component,
|
||||||
|
id: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
modelValue: state.value,
|
||||||
|
disabled: state.disabled,
|
||||||
|
error: state.error,
|
||||||
|
'onUpdate:modelValue': (value) => {
|
||||||
|
state.value = value;
|
||||||
|
state.updates++;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
h('h2', { class: 'font-semibold' }, 'Saved description preview'),
|
||||||
|
h(TicketDescription, { description: state.value }),
|
||||||
|
]),
|
||||||
|
}).use(createLocalization());
|
||||||
|
app.mount('#app');
|
||||||
|
const tick = async () => {
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
};
|
||||||
|
const assert = (condition, message) => {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const button = (name) =>
|
||||||
|
[...document.querySelectorAll('button')].find(
|
||||||
|
(element) =>
|
||||||
|
element.getAttribute('aria-label') === name ||
|
||||||
|
element.textContent.trim() === name,
|
||||||
|
);
|
||||||
|
const click = async (name) => {
|
||||||
|
const element = button(name);
|
||||||
|
assert(element && !element.disabled, `Button available: ${name}`);
|
||||||
|
element.click();
|
||||||
|
await tick();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await tick();
|
||||||
|
const editor = component.value.$.setupState.editor;
|
||||||
|
assert(editor, 'Editor mounted');
|
||||||
|
assert(
|
||||||
|
state.value === original && state.updates === 0,
|
||||||
|
'Mount preserves exact source',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
document
|
||||||
|
.querySelector('#description')
|
||||||
|
.getAttribute('contenteditable') === 'true',
|
||||||
|
'Visual textbox is editable',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
editor.getText().includes('First line\nSecond line'),
|
||||||
|
'Legacy newlines retained',
|
||||||
|
);
|
||||||
|
state.value = 'Format this';
|
||||||
|
await tick();
|
||||||
|
editor.commands.selectAll();
|
||||||
|
for (const name of ['Bold', 'Italic', 'Strikethrough']) {
|
||||||
|
await click(name);
|
||||||
|
assert(
|
||||||
|
button(name).getAttribute('aria-pressed') === 'true',
|
||||||
|
`${name} active`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
state.value.includes('**') && state.value.includes('~~'),
|
||||||
|
'Formatting emits Markdown',
|
||||||
|
);
|
||||||
|
await click('Undo');
|
||||||
|
assert(!editor.isActive('strike'), 'Undo restores prior state');
|
||||||
|
await click('Redo');
|
||||||
|
assert(editor.isActive('strike'), 'Redo restores formatting');
|
||||||
|
await click('Markdown');
|
||||||
|
const source = document.querySelector('textarea#description');
|
||||||
|
assert(source.value === state.value, 'Source matches the editor output');
|
||||||
|
source.value = '## From source\n\n- One\n- Two';
|
||||||
|
source.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
await click('Visual editor');
|
||||||
|
assert(
|
||||||
|
document.querySelector('#description h2')?.textContent ===
|
||||||
|
'From source',
|
||||||
|
'Source updates visual editor',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
document.querySelectorAll('#description li').length === 2,
|
||||||
|
'Lists render after source switch',
|
||||||
|
);
|
||||||
|
for (const [name, selector] of [
|
||||||
|
['Bullet list', 'ul li'],
|
||||||
|
['Numbered list', 'ol li'],
|
||||||
|
['Quote', 'blockquote'],
|
||||||
|
['Inline code', 'code'],
|
||||||
|
['Code block', 'pre code'],
|
||||||
|
['Heading 2', 'h2'],
|
||||||
|
['Heading 3', 'h3'],
|
||||||
|
]) {
|
||||||
|
state.value = `Check ${name}`;
|
||||||
|
await tick();
|
||||||
|
editor.commands.selectAll();
|
||||||
|
await click(name);
|
||||||
|
assert(
|
||||||
|
document.querySelector(`#description ${selector}`),
|
||||||
|
`${name} renders`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.value = 'Link text';
|
||||||
|
await tick();
|
||||||
|
editor.commands.selectAll();
|
||||||
|
await click('Add or edit link');
|
||||||
|
assert(
|
||||||
|
document.activeElement.id === 'description-link',
|
||||||
|
'Link field receives focus',
|
||||||
|
);
|
||||||
|
const input = document.querySelector('#description-link');
|
||||||
|
input.value = 'javascript:alert(1)';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
await click('Apply link');
|
||||||
|
assert(
|
||||||
|
document
|
||||||
|
.querySelector('#description-link-error')
|
||||||
|
.textContent.includes('Enter a valid'),
|
||||||
|
'Unsafe link rejected',
|
||||||
|
);
|
||||||
|
input.value = 'https://example.test/docs';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
await click('Apply link');
|
||||||
|
assert(
|
||||||
|
state.value === '[Link text](https://example.test/docs)',
|
||||||
|
'Selection becomes a Markdown link',
|
||||||
|
);
|
||||||
|
await click('Remove link');
|
||||||
|
assert(state.value === 'Link text', 'Link can be removed');
|
||||||
|
state.disabled = true;
|
||||||
|
state.error = 'Description rejected.';
|
||||||
|
await tick();
|
||||||
|
assert(
|
||||||
|
document
|
||||||
|
.querySelector('#description')
|
||||||
|
.getAttribute('contenteditable') === 'false',
|
||||||
|
'Processing disables editing',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
document.querySelector('#description').getAttribute('aria-invalid') ===
|
||||||
|
'true',
|
||||||
|
'Server error reflected on editor',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
button('Bold').disabled && button('Markdown').disabled,
|
||||||
|
'Processing disables controls',
|
||||||
|
);
|
||||||
|
assert(state.value === 'Link text', 'Validation retains draft');
|
||||||
|
state.disabled = false;
|
||||||
|
state.error = '';
|
||||||
|
state.value = 'Reset draft';
|
||||||
|
await tick();
|
||||||
|
assert(editor.getText() === 'Reset draft', 'External reset syncs editor');
|
||||||
|
editor.commands.selectAll();
|
||||||
|
await click('Heading 1');
|
||||||
|
assert(state.value.startsWith('# '), 'Heading command saves Markdown');
|
||||||
|
editor.commands.clearContent();
|
||||||
|
await tick();
|
||||||
|
assert(state.value === '', 'Clearing editor saves an empty description');
|
||||||
|
state.value = '| A | B |\n| - | - |\n| a | b |';
|
||||||
|
await tick();
|
||||||
|
assert(
|
||||||
|
document.querySelector('textarea#description').value === state.value,
|
||||||
|
'Unsupported content retained as source',
|
||||||
|
);
|
||||||
|
assert(button('Visual editor').disabled, 'Lossy conversion is unavailable');
|
||||||
|
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.';
|
||||||
|
await tick();
|
||||||
|
state.result =
|
||||||
|
'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.';
|
||||||
|
} catch (error) {
|
||||||
|
state.result = `FAILED: ${error.message}`;
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
window.addEventListener('pagehide', () => app.unmount(), { once: true });
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
if (serve) {
|
||||||
|
server.middlewares.use(async (request, response) => {
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app"></div><script type="module" src="/tests/Fixtures/ticket-description-browser.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Description fixture ready on port 4186');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
safeDescriptionLink,
|
||||||
|
supportsVisualDescription,
|
||||||
|
parseDescription,
|
||||||
|
renderDescriptionNode,
|
||||||
|
} = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/ticket-description.ts',
|
||||||
|
);
|
||||||
|
const { default: Description } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/tickets/TicketDescription.vue',
|
||||||
|
);
|
||||||
|
const { default: Editor } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/tickets/TicketDescriptionEditor.vue',
|
||||||
|
);
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const render = (description) =>
|
||||||
|
renderToString(
|
||||||
|
createSSRApp({ render: () => h(Description, { description }) }),
|
||||||
|
);
|
||||||
|
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---';
|
||||||
|
assert.equal(supportsVisualDescription(markdown), true);
|
||||||
|
const rendered = await render(markdown);
|
||||||
|
for (const snippet of [
|
||||||
|
'<h1>Title</h1>',
|
||||||
|
'<strong>Bold</strong>',
|
||||||
|
'<em>italic</em>',
|
||||||
|
'<s>strike</s>',
|
||||||
|
'<ul>',
|
||||||
|
'Nested',
|
||||||
|
'<ol start="3">',
|
||||||
|
'<blockquote>',
|
||||||
|
'<code>inline</code>',
|
||||||
|
'<tag>',
|
||||||
|
'rel="noopener noreferrer"',
|
||||||
|
'<hr>',
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
rendered.includes(snippet),
|
||||||
|
`Missing formatted output: ${snippet}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(await render('First\nSecond'), /First<br>Second/);
|
||||||
|
assert.match(
|
||||||
|
await render('München 🚀 & details'),
|
||||||
|
/München 🚀 & details/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render('<img src=x onerror=alert(1)>'),
|
||||||
|
/<img/,
|
||||||
|
);
|
||||||
|
for (const value of [
|
||||||
|
'https://example.test/a?b=1&c=2',
|
||||||
|
'http://example.test',
|
||||||
|
'mailto:test@example.test',
|
||||||
|
'/projects/1',
|
||||||
|
'#section',
|
||||||
|
]) {
|
||||||
|
assert.equal(safeDescriptionLink(value), value);
|
||||||
|
}
|
||||||
|
for (const value of [
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'JaVaScRiPt:alert(1)',
|
||||||
|
'data:text/html,test',
|
||||||
|
'vbscript:test',
|
||||||
|
'//evil.test',
|
||||||
|
'/\\evil.test',
|
||||||
|
'\u0000https://example.test',
|
||||||
|
'https://',
|
||||||
|
'https://exa mple.test',
|
||||||
|
'java\nscript:alert(1)',
|
||||||
|
null,
|
||||||
|
]) {
|
||||||
|
assert.equal(safeDescriptionLink(value), null);
|
||||||
|
}
|
||||||
|
for (const value of [
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
'<img src=x onerror=alert(1)>',
|
||||||
|
'| A | B |\n| - | - |\n| a | b |',
|
||||||
|
'',
|
||||||
|
'- [x] Done',
|
||||||
|
'[unsafe](javascript:alert%281%29)',
|
||||||
|
]) {
|
||||||
|
assert.equal(supportsVisualDescription(value), false, value);
|
||||||
|
const html = await render(value);
|
||||||
|
assert.doesNotMatch(html, /<script|<img|href="javascript:/);
|
||||||
|
assert.ok(
|
||||||
|
html.includes(
|
||||||
|
value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>'),
|
||||||
|
),
|
||||||
|
'Original source is retained',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const maliciousNode = {
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '<script>bad</script>',
|
||||||
|
marks: [
|
||||||
|
{ type: 'link', attrs: { href: 'javascript:bad()' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const safe = await renderToString(
|
||||||
|
createSSRApp({
|
||||||
|
render: () =>
|
||||||
|
h('div', {}, [renderDescriptionNode(maliciousNode)]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(safe, /<script|<a/);
|
||||||
|
assert.match(safe, /<script>/);
|
||||||
|
assert.deepEqual(parseDescription(''), { type: 'doc', content: [] });
|
||||||
|
let updates = 0;
|
||||||
|
const initial = await renderToString(
|
||||||
|
createSSRApp({
|
||||||
|
render: () =>
|
||||||
|
h(Editor, {
|
||||||
|
id: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
modelValue: markdown,
|
||||||
|
error: 'Invalid description.',
|
||||||
|
disabled: true,
|
||||||
|
'onUpdate:modelValue': () => updates++,
|
||||||
|
}),
|
||||||
|
}).use(createLocalization()),
|
||||||
|
);
|
||||||
|
assert.match(initial, /id="description"/);
|
||||||
|
assert.match(initial, /aria-invalid="true"/);
|
||||||
|
assert.match(initial, /id="description-error"/);
|
||||||
|
assert.match(initial, /disabled/);
|
||||||
|
assert.equal(
|
||||||
|
updates,
|
||||||
|
0,
|
||||||
|
'Mounting must not rewrite existing descriptions',
|
||||||
|
);
|
||||||
|
console.log('Ticket description checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import tailwind from '@tailwindcss/vite';
|
|||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import { createInertiaApp } from '@inertiajs/vue3';
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
import { createServer } from 'vite';
|
import { createServer } from 'vite';
|
||||||
import { createSSRApp, h } from 'vue';
|
import { createSSRApp, h, reactive, ref } from 'vue';
|
||||||
import { renderToString } from 'vue/server-renderer';
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
const serve = process.argv.includes('--serve');
|
const serve = process.argv.includes('--serve');
|
||||||
@@ -463,6 +463,40 @@ if (serve) {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
const { useTicketLinkActions } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useTicketLinkActions.ts',
|
||||||
|
);
|
||||||
|
const linkTypes = ref([
|
||||||
|
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
|
||||||
|
{ id: 2, label: 'Relates to', counterpart_label: 'Relates to' },
|
||||||
|
]);
|
||||||
|
const linkForm = reactive({
|
||||||
|
ticket_link_type_id: 1,
|
||||||
|
link_direction: 'source',
|
||||||
|
linked_ticket_id: 41,
|
||||||
|
});
|
||||||
|
const { actions, selectedAction, selectedActionLabel } =
|
||||||
|
useTicketLinkActions(linkTypes, linkForm);
|
||||||
|
assert.deepEqual(
|
||||||
|
actions.value.map((action) => action.label),
|
||||||
|
['Blocks', 'Blocked by', 'Relates to'],
|
||||||
|
);
|
||||||
|
assert.equal(selectedActionLabel.value, 'Blocks');
|
||||||
|
selectedAction.value = '1:counterpart';
|
||||||
|
assert.equal(selectedActionLabel.value, 'Blocked by');
|
||||||
|
assert.deepEqual(
|
||||||
|
{ ...linkForm },
|
||||||
|
{
|
||||||
|
ticket_link_type_id: 1,
|
||||||
|
link_direction: 'counterpart',
|
||||||
|
linked_ticket_id: 41,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
selectedAction.value = '2:source';
|
||||||
|
assert.equal(linkForm.ticket_link_type_id, 2);
|
||||||
|
assert.equal(linkForm.link_direction, 'source');
|
||||||
|
selectedAction.value = 'unknown';
|
||||||
|
assert.equal(selectedActionLabel.value, 'Relates to');
|
||||||
const { createLocalization } = await server.ssrLoadModule(
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
'/resources/js/lib/localization.ts',
|
'/resources/js/lib/localization.ts',
|
||||||
);
|
);
|
||||||
@@ -491,8 +525,24 @@ if (serve) {
|
|||||||
return result.body.split('id="main-content"')[1];
|
return result.body.split('id="main-content"')[1];
|
||||||
};
|
};
|
||||||
const html = await render();
|
const html = await render();
|
||||||
assert.ok(html.includes('id="link_direction"'));
|
assert.ok(!html.includes('id="link_direction"'));
|
||||||
assert.ok(html.includes('Blocked by'));
|
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.',
|
||||||
@@ -570,9 +620,9 @@ if (serve) {
|
|||||||
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
||||||
);
|
);
|
||||||
assert.match(html, /Save link/);
|
assert.match(html, /Save link/);
|
||||||
assert.match(html, /Outgoing/);
|
assert.doesNotMatch(html, /Outgoing/);
|
||||||
const incoming = await render('?incoming');
|
const incoming = await render('?incoming');
|
||||||
assert.match(incoming, /Incoming/);
|
assert.doesNotMatch(incoming, /Incoming/);
|
||||||
assert.match(incoming, /Blocked by/);
|
assert.match(incoming, /Blocked by/);
|
||||||
assert.match(await render('?noTypes'), /No link types are configured/);
|
assert.match(await render('?noTypes'), /No link types are configured/);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ try {
|
|||||||
'Platform delivery',
|
'Platform delivery',
|
||||||
'Child tickets',
|
'Child tickets',
|
||||||
'Nested child with a long title that must wrap on mobile',
|
'Nested child with a long title that must wrap on mobile',
|
||||||
'Incoming',
|
|
||||||
'Blocked by',
|
|
||||||
]) {
|
]) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await section()
|
await section()
|
||||||
@@ -52,6 +50,13 @@ try {
|
|||||||
text,
|
text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert.equal(
|
||||||
|
await section()
|
||||||
|
.locator('[data-ticket-link]')
|
||||||
|
.getByText('Blocked by', { exact: true })
|
||||||
|
.isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
const collapse = section().getByRole('button', {
|
const collapse = section().getByRole('button', {
|
||||||
name: 'Collapse children of ENG-41',
|
name: 'Collapse children of ENG-41',
|
||||||
});
|
});
|
||||||
@@ -68,16 +73,7 @@ try {
|
|||||||
);
|
);
|
||||||
await page.keyboard.press('Space');
|
await page.keyboard.press('Space');
|
||||||
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||||
await control('Link type').focus();
|
await control('Action').focus();
|
||||||
await page.keyboard.press('Tab');
|
|
||||||
assert.equal(
|
|
||||||
await control('This ticket').evaluate(
|
|
||||||
(el) =>
|
|
||||||
el === document.activeElement &&
|
|
||||||
el.matches(':focus-visible'),
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await control('Linked ticket').evaluate(
|
await control('Linked ticket').evaluate(
|
||||||
@@ -130,7 +126,10 @@ try {
|
|||||||
link_direction: 'source',
|
link_direction: 'source',
|
||||||
linked_ticket_id: 41,
|
linked_ticket_id: 41,
|
||||||
});
|
});
|
||||||
await section().getByText('Outgoing', { exact: true }).waitFor();
|
await section()
|
||||||
|
.locator('[data-ticket-link]')
|
||||||
|
.getByText('Blocks', { exact: true })
|
||||||
|
.waitFor();
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.querySelector('#linked_ticket_id')?.value === '',
|
() => document.querySelector('#linked_ticket_id')?.value === '',
|
||||||
);
|
);
|
||||||
@@ -152,11 +151,10 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await visit('?noLinks');
|
await visit('?noLinks');
|
||||||
await control('This ticket').selectOption('counterpart');
|
await control('Action').selectOption('1:counterpart');
|
||||||
await control('Linked ticket').selectOption('41');
|
await control('Linked ticket').selectOption('41');
|
||||||
await section()
|
await section()
|
||||||
.locator('form')
|
.locator('p[aria-live="polite"]')
|
||||||
.last()
|
|
||||||
.getByText('Blocked by', { exact: true })
|
.getByText('Blocked by', { exact: true })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
request = page.waitForRequest(
|
request = page.waitForRequest(
|
||||||
@@ -168,7 +166,10 @@ try {
|
|||||||
link_direction: 'counterpart',
|
link_direction: 'counterpart',
|
||||||
linked_ticket_id: 41,
|
linked_ticket_id: 41,
|
||||||
});
|
});
|
||||||
await section().getByText('Incoming', { exact: true }).waitFor();
|
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');
|
||||||
@@ -177,7 +178,7 @@ try {
|
|||||||
.getByText('Choose another ticket.', { exact: true })
|
.getByText('Choose another ticket.', { exact: true })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
assert.equal(await control('Linked ticket').inputValue(), '41');
|
assert.equal(await control('Linked ticket').inputValue(), '41');
|
||||||
for (const label of ['Link type', 'Linked ticket']) {
|
for (const label of ['Action', 'Linked ticket']) {
|
||||||
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
|
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
|
||||||
}
|
}
|
||||||
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
|
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
|
||||||
|
|||||||
Reference in New Issue
Block a user