Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1291a0fcad | ||
|
|
5769d84526 | ||
|
|
99264c7bc8 | ||
|
|
1b448e363c | ||
|
|
65e9df6d6d | ||
|
|
7d7b723f69 | ||
|
|
7a656de7ce | ||
|
|
811dbf7fa4 | ||
|
|
5567eea06f | ||
|
|
ca1f243234 | ||
|
|
9ed8d0c6cc | ||
|
|
ba12e65e81 | ||
|
|
85b9409e3c | ||
|
|
f862a6a226 | ||
|
|
c8f494fe7c |
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions
|
||||
'position' => $index + 1,
|
||||
])->saveQuietly();
|
||||
});
|
||||
app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses');
|
||||
}
|
||||
|
||||
private function statusContextLabel(TicketStatus $ticketStatus): string
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -50,5 +51,9 @@ trait ReordersBoardColumns
|
||||
'position' => $index + 1,
|
||||
])->saveQuietly();
|
||||
});
|
||||
$project = $columns->first()?->project;
|
||||
if ($project !== null) {
|
||||
app(RefreshProjectBaseTemplate::class)->handle($project, 'board_columns');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace App\Actions\ProjectBoard;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateTicketStatusTransitions
|
||||
{
|
||||
@@ -20,22 +22,25 @@ class UpdateTicketStatusTransitions
|
||||
*/
|
||||
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
|
||||
{
|
||||
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
|
||||
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus {
|
||||
Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail();
|
||||
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
|
||||
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
|
||||
return $ticketStatus->fresh();
|
||||
return $ticketStatus->fresh();
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateProjectTicketMetadataSettings
|
||||
{
|
||||
@@ -20,19 +21,22 @@ class UpdateProjectTicketMetadataSettings
|
||||
*/
|
||||
public function handle(Project $project, array $validated): Project
|
||||
{
|
||||
$project->update([
|
||||
'show_author' => Arr::get($validated, 'show_author', false),
|
||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||
]);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
return DB::transaction(function () use ($project, $validated): Project {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$project->update([
|
||||
'show_author' => Arr::get($validated, 'show_author', false),
|
||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||
]);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
|
||||
return $project->fresh();
|
||||
return $project->fresh();
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class CreateProject
|
||||
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||
$sourceTemplate = ProjectTemplate::query()
|
||||
->published()
|
||||
->lockForUpdate()
|
||||
->findOrFail((int) $sourceTemplateId);
|
||||
|
||||
$project->forceFill([
|
||||
@@ -72,6 +73,7 @@ class CreateProject
|
||||
$this->duplicateProjectConfiguration->apply(
|
||||
$project->fresh() ?? $project,
|
||||
$sourceTemplate->configuration_snapshot ?? [],
|
||||
adoptExisting: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,18 @@ class CreateProjectBaseTemplate
|
||||
{
|
||||
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
|
||||
|
||||
$slug = strtolower($project->key).'-base-template';
|
||||
$suffix = 1;
|
||||
$candidate = $slug;
|
||||
while (ProjectTemplate::query()->where('slug', $candidate)->exists()) {
|
||||
$candidate = $slug.'-'.++$suffix;
|
||||
}
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'project_id' => $project->id,
|
||||
'source_project_id' => $project->id,
|
||||
'name' => "{$project->name} base template",
|
||||
'slug' => strtolower($project->key).'-base-template',
|
||||
'slug' => $candidate,
|
||||
'is_published' => false,
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
|
||||
|
||||
@@ -5,28 +5,61 @@ namespace App\Actions\Projects;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CreateProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $attributes */
|
||||
public function empty(array $attributes): ProjectTemplate
|
||||
{
|
||||
$areas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'name' => Arr::get($attributes, 'name'),
|
||||
'slug' => Arr::get($attributes, 'slug'),
|
||||
'is_published' => true,
|
||||
'copy_areas' => $areas,
|
||||
'configuration_snapshot' => collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
Arr::get($attributes, 'configuration_snapshot', $this->projectConfigurationIdentity->defaults()),
|
||||
))->only($areas)->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
|
||||
*/
|
||||
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
|
||||
{
|
||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||
return DB::transaction(function () use ($sourceProject, $attributes): ProjectTemplate {
|
||||
$sourceProject = Project::query()->whereKey($sourceProject->id)->lockForUpdate()->firstOrFail();
|
||||
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
|
||||
$template = $sourceProject->projectTemplate;
|
||||
if ($template === null || $template->is_published || $template->project_id !== $sourceProject->id) {
|
||||
$template = new ProjectTemplate;
|
||||
$template->slug = Arr::get($attributes, 'slug');
|
||||
}
|
||||
$template->forceFill([
|
||||
'project_id' => null,
|
||||
'source_project_id' => $sourceProject->id,
|
||||
'name' => Arr::get($attributes, 'name'),
|
||||
'is_published' => true,
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
|
||||
])->save();
|
||||
$sourceProject->forceFill([
|
||||
'project_template_id' => $template->id,
|
||||
'template_overrides' => array_values(array_diff(DuplicateProjectConfiguration::copyableAreas(), $copyAreas)),
|
||||
])->saveQuietly();
|
||||
foreach ($sourceProject->configurationRelations() as $relation) {
|
||||
$sourceProject->{$relation}()->update(['project_template_id' => $template->id]);
|
||||
}
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'project_id' => null,
|
||||
'source_project_id' => $sourceProject->id,
|
||||
'name' => $attributes['name'],
|
||||
'slug' => $attributes['slug'],
|
||||
'is_published' => true,
|
||||
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
|
||||
]);
|
||||
return $template;
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class DeleteProject
|
||||
DB::transaction(function () use ($project): void {
|
||||
$baseTemplate = $project->baseTemplate()->first();
|
||||
|
||||
$project->ticketStatuses()->delete();
|
||||
$project->delete();
|
||||
$baseTemplate?->delete();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
) {}
|
||||
|
||||
public function handle(ProjectTemplate $template): void
|
||||
{
|
||||
DB::transaction(function () use ($template): void {
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$projects = $template->linkedProjects()->orderBy('id')->lockForUpdate()->get();
|
||||
$template->delete();
|
||||
foreach ($projects as $project) {
|
||||
$base = $project->baseTemplate()->first() ?? $this->createProjectBaseTemplate->handle($project);
|
||||
$base->forceFill(['configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, DuplicateProjectConfiguration::copyableAreas())])->saveQuietly();
|
||||
$project->forceFill(['project_template_id' => $base->id, 'template_overrides' => []])->saveQuietly();
|
||||
foreach ($project->configurationRelations() as $relation) {
|
||||
$project->{$relation}()->update(['project_template_id' => $base->id]);
|
||||
}
|
||||
}
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Actions\Notifications\NormalizeNotificationFilters;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class DuplicateProjectConfiguration
|
||||
{
|
||||
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
|
||||
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
|
||||
) {}
|
||||
|
||||
/** @return list<string> */
|
||||
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
|
||||
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
return $this->projectConfigurationIdentity->normalize($configuration);
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas @return array<string, mixed> */
|
||||
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
|
||||
];
|
||||
}
|
||||
|
||||
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||
static fn ($column, int $index): array => [$column->id => $index],
|
||||
$columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||
static fn ($column): array => [$column->id => $column->configuration_key],
|
||||
);
|
||||
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||
static fn ($status, int $index): array => [$status->id => $index],
|
||||
$statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||
static fn ($status): array => [$status->id => $status->configuration_key],
|
||||
);
|
||||
|
||||
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
||||
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
|
||||
static fn ($column): array => ['configuration_key' => $column->configuration_key, 'name' => $column->name, 'position' => $column->position],
|
||||
)->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => [
|
||||
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
|
||||
'configuration_key' => $status->configuration_key,
|
||||
'board_column_key' => $status->board_column_id === null ? null : $columnKeys->get($status->board_column_id),
|
||||
'name' => $status->name,
|
||||
'position' => $status->position,
|
||||
],
|
||||
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
|
||||
|
||||
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
|
||||
static fn (int $id): ?int => $statusIndexes->get($id),
|
||||
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
|
||||
static fn ($status): array => [
|
||||
'status_key' => $status->configuration_key,
|
||||
'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
|
||||
static fn (int $id): ?string => $statusKeys->get($id),
|
||||
)->filter()->sort()->values()->all(),
|
||||
],
|
||||
)->all();
|
||||
}
|
||||
|
||||
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
|
||||
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
||||
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
||||
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
||||
'filters' => $item->filters ?? [],
|
||||
'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
|
||||
],
|
||||
];
|
||||
$relations = [
|
||||
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
|
||||
|
||||
foreach ($simpleAreas as $area => $mapper) {
|
||||
if (in_array($area, $selectedAreas, true)) {
|
||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
|
||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map(
|
||||
static fn ($item): array => ['configuration_key' => $item->configuration_key, ...$mapper($item)],
|
||||
)->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,95 +211,124 @@ class DuplicateProjectConfiguration
|
||||
/**
|
||||
* @param array<string, mixed> $configuration
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
* @return list<string> areas which retained referenced local values
|
||||
* @return list<string> Areas which retain referenced project configuration.
|
||||
*/
|
||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
|
||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = [], bool $adoptExisting = false): array
|
||||
{
|
||||
return DB::transaction(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
|
||||
$targetProject = Project::query()->whereKey($targetProject->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
return $this->applyLocked($targetProject, $configuration, $statusReplacements, $adoptExisting);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @param array<int|string, int|string> $statusReplacements @return list<string> */
|
||||
private function applyLocked(Project $targetProject, array $configuration, array $statusReplacements, bool $adoptExisting): array
|
||||
{
|
||||
$configuration = $this->normalizeConfiguration($configuration);
|
||||
|
||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
|
||||
$blockedAreas = [];
|
||||
|
||||
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
||||
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->boardColumns()->get(),
|
||||
$configuration[self::COPY_BOARD_COLUMNS],
|
||||
fn (array $item) => $targetProject->boardColumns()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
|
||||
$currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
|
||||
$nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
|
||||
$columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
|
||||
$statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
|
||||
static fn ($status): bool => $nextStatuses->has($status->configuration_key)
|
||||
|| $status->tickets_count === 0 || array_key_exists($status->id, $statusReplacements),
|
||||
);
|
||||
if ($blocked) {
|
||||
$canDeleteColumn = static fn (Model $column): bool => $currentStatuses->where('board_column_id', $column->id)->every(
|
||||
static function ($status) use ($column, $statusesCanChange, $nextStatuses, $columnKeys): bool {
|
||||
if (! $statusesCanChange) {
|
||||
return false;
|
||||
}
|
||||
$next = $nextStatuses->get($status->configuration_key);
|
||||
$nextColumn = $next['board_column_key'] ?? null;
|
||||
|
||||
return $next === null || ($nextColumn !== $column->configuration_key
|
||||
&& ($nextColumn === null || in_array($nextColumn, $columnKeys, true)));
|
||||
},
|
||||
);
|
||||
if ($this->syncModels($targetProject, self::COPY_BOARD_COLUMNS, $configuration[self::COPY_BOARD_COLUMNS],
|
||||
$canDeleteColumn, $adoptExisting)) {
|
||||
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
||||
$columns = $targetProject->boardColumns()->get()->values();
|
||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
|
||||
$columnIndex = $item['board_column_index'] ?? null;
|
||||
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
|
||||
unset($item['board_column_index']);
|
||||
$columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
|
||||
$existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
|
||||
$columnKey = $item['board_column_key'] ?? null;
|
||||
$item['board_column_id'] = $columnKey === null
|
||||
? null
|
||||
: ($columns->get($columnKey)?->id ?? $existingStatuses->get($item['configuration_key'])?->board_column_id);
|
||||
$item['slug'] = Str::slug($item['name']);
|
||||
unset($item['board_column_key']);
|
||||
|
||||
return $item;
|
||||
})->all();
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->ticketStatuses()->get(),
|
||||
$items,
|
||||
fn (array $item) => $targetProject->ticketStatuses()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
function (Model $status) use ($statusReplacements): bool {
|
||||
if (! $status->tickets()->exists()) {
|
||||
return true;
|
||||
}
|
||||
$replacementId = $statusReplacements[$status->id] ?? null;
|
||||
if ($replacementId === null) {
|
||||
return false;
|
||||
}
|
||||
$status->tickets()->update(['ticket_status_id' => $replacementId]);
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
if ($blocked) {
|
||||
$templateKeys = array_column($items, 'configuration_key');
|
||||
$canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
|
||||
|| array_key_exists($status->id, $statusReplacements);
|
||||
$beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
|
||||
$replacement = $statusReplacements[$status->id] ?? null;
|
||||
if ($replacement === null) {
|
||||
return;
|
||||
}
|
||||
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
|
||||
->where('configuration_key', (string) $replacement)->first();
|
||||
if ($replacementStatus === null && is_numeric($replacement)) {
|
||||
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
|
||||
}
|
||||
if ($replacementStatus === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
|
||||
]);
|
||||
}
|
||||
$status->tickets()->update(['ticket_status_id' => $replacementStatus->id]);
|
||||
$this->transferStatusTransitions($targetProject, $status, $replacementStatus, $statusReplacements, $templateKeys);
|
||||
};
|
||||
if ($this->syncModels($targetProject, self::COPY_TICKET_STATUSES, $items, $canDelete, $adoptExisting, $beforeDelete)) {
|
||||
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
||||
$statuses = $targetProject->ticketStatuses()->get()->values();
|
||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
|
||||
$status = $statuses->get($index);
|
||||
$statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
|
||||
$status = $statuses->get($transition['status_key']);
|
||||
if ($status !== null) {
|
||||
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
|
||||
$status->allowedTargetStatuses()->sync(collect($transition['target_keys'])->map(
|
||||
static fn (string $key): ?int => $statuses->get($key)?->id,
|
||||
)->filter()->all());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$relations = [
|
||||
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
|
||||
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
|
||||
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
|
||||
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
|
||||
$guards = [
|
||||
self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||
self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||
self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
|
||||
self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
|
||||
self::COPY_REPOSITORY_CONNECTIONS => null,
|
||||
self::COPY_NOTIFICATION_ACTIONS => null,
|
||||
];
|
||||
|
||||
foreach ($relations as $area => [$relation, $canDelete]) {
|
||||
foreach ($guards as $area => $canDelete) {
|
||||
if (! isset($configuration[$area])) {
|
||||
continue;
|
||||
}
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->{$relation}()->get(),
|
||||
$configuration[$area],
|
||||
fn (array $item) => $targetProject->{$relation}()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
$canDelete,
|
||||
);
|
||||
if ($blocked) {
|
||||
$items = $configuration[$area];
|
||||
if ($area === self::COPY_NOTIFICATION_ACTIONS) {
|
||||
$statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
|
||||
$items = array_map(fn (array $item): array => [
|
||||
...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
|
||||
], $items);
|
||||
}
|
||||
if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
|
||||
$blockedAreas[] = $area;
|
||||
}
|
||||
}
|
||||
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
|
||||
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||
}
|
||||
|
||||
return array_values(array_unique($blockedAreas));
|
||||
return $blockedAreas;
|
||||
});
|
||||
|
||||
foreach ($blockedAreas as $area) {
|
||||
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||
}
|
||||
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
|
||||
/** @param list<string> $copyAreas */
|
||||
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||
{
|
||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas), adoptExisting: true);
|
||||
}
|
||||
|
||||
/** @param list<array<string, mixed>> $filters @param array<int|string, int|string> $mapping @return list<array<string, mixed>> */
|
||||
public function mapNotificationFilters(array $filters, array $mapping): array
|
||||
{
|
||||
return array_map(static function (array $filter) use ($mapping): array {
|
||||
if (! in_array($filter['attribute'], ['status.from.id', 'status.to.id'], true)) {
|
||||
return $filter;
|
||||
}
|
||||
$map = static fn (string $value): string => (string) ($mapping[$value] ?? $value);
|
||||
$filter['value'] = is_array($filter['value'])
|
||||
? array_map($map, $filter['value'])
|
||||
: ($filter['value'] === null ? null : $map($filter['value']));
|
||||
|
||||
return $filter;
|
||||
}, app(NormalizeNotificationFilters::class)->handle($filters));
|
||||
}
|
||||
|
||||
/** @param array<int|string, int|string> $replacements @param list<string> $templateKeys */
|
||||
private function transferStatusTransitions(Project $project, TicketStatus $removed, TicketStatus $replacement, array $replacements, array $templateKeys): void
|
||||
{
|
||||
$statuses = $project->ticketStatuses()->get();
|
||||
$targets = $statuses->whereIn('configuration_key', $templateKeys);
|
||||
$mapping = [];
|
||||
foreach ($statuses as $status) {
|
||||
if (in_array($status->configuration_key, $templateKeys, true)) {
|
||||
$mapping[$status->id] = $status->id;
|
||||
|
||||
continue;
|
||||
}
|
||||
$target = $replacements[$status->id] ?? null;
|
||||
if (is_scalar($target)) {
|
||||
$mapping[$status->id] = $targets->firstWhere('configuration_key', (string) $target)?->id
|
||||
?? (is_numeric($target) ? $targets->firstWhere('id', (int) $target)?->id : null);
|
||||
}
|
||||
}
|
||||
$mapping[$removed->id] = $replacement->id;
|
||||
$edges = DB::table('ticket_status_transitions')->where('from_ticket_status_id', $removed->id)
|
||||
->orWhere('to_ticket_status_id', $removed->id)->get();
|
||||
foreach ($edges as $edge) {
|
||||
$from = $mapping[$edge->from_ticket_status_id] ?? null;
|
||||
$to = $mapping[$edge->to_ticket_status_id] ?? null;
|
||||
if ($from !== null && $to !== null && $from !== $to) {
|
||||
$statuses->firstWhere('id', $from)->allowedTargetStatuses()->syncWithoutDetaching([$to]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, Model> $models
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @param list<array<string, mixed>> $items
|
||||
*/
|
||||
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
|
||||
private function syncModels(Project $project, string $area, array $items, ?callable $canDelete, bool $adoptExisting, ?callable $beforeDelete = null): bool
|
||||
{
|
||||
foreach ($items as $index => $item) {
|
||||
$model = $models->get($index);
|
||||
$model === null ? $create($item) : $update($model, $item);
|
||||
$relation = ProjectConfigurationIdentity::relations()[$area];
|
||||
$models = $project->{$relation}()->get();
|
||||
$byKey = $models->keyBy('configuration_key');
|
||||
$matched = [];
|
||||
$assignments = [];
|
||||
foreach ($items as $item) {
|
||||
$model = $byKey->get($item['configuration_key']);
|
||||
if ($model === null && $adoptExisting) {
|
||||
$candidates = $models->filter(fn (Model $candidate): bool => ! in_array($candidate->id, $matched, true)
|
||||
&& $this->projectConfigurationIdentity->signature($area, $candidate->attributesToArray()) === $this->projectConfigurationIdentity->signature($area, $item));
|
||||
$model = $candidates->count() === 1 ? $candidates->first() : null;
|
||||
}
|
||||
if ($model !== null) {
|
||||
$matched[] = $model->id;
|
||||
if ($area === self::COPY_TICKET_FIELDS && $model->field_type !== $item['field_type'] && $model->ticketFieldValues()->exists()) {
|
||||
return true;
|
||||
}
|
||||
if ($area === self::COPY_TICKET_TYPES && $model->is_parent && ! ($item['is_parent'] ?? false)
|
||||
&& $model->tickets()->whereHas('childTickets')->exists()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$assignments[] = [$model, $item];
|
||||
}
|
||||
$removed = $models->reject(static fn (Model $model): bool => in_array($model->id, $matched, true));
|
||||
foreach ($removed as $model) {
|
||||
if ($canDelete !== null && ! $canDelete($model)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
foreach ($models->slice(count($items)) as $model) {
|
||||
if ($canDelete !== null && ! $canDelete($model)) {
|
||||
$blocked = true;
|
||||
if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
|
||||
foreach ($models as $model) {
|
||||
$model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
if ($models->isNotEmpty() && $models->first()->getAttribute('position') !== null) {
|
||||
$offset = max((int) $models->max('position'), (int) collect($items)->max('position')) + $models->count() + count($items) + 1;
|
||||
foreach ($models->values() as $index => $model) {
|
||||
$model->forceFill(['position' => $offset + $index])->saveQuietly();
|
||||
}
|
||||
}
|
||||
foreach ($assignments as [$model, $item]) {
|
||||
$model === null ? $project->{$relation}()->create($item) : $model->forceFill($item)->saveQuietly();
|
||||
}
|
||||
foreach ($removed as $model) {
|
||||
if ($beforeDelete !== null) {
|
||||
$beforeDelete($model);
|
||||
}
|
||||
$model->deleteQuietly();
|
||||
}
|
||||
|
||||
return $blocked;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
class ProjectConfigurationIdentity
|
||||
{
|
||||
/** @return array<string, string> */
|
||||
public static function relations(): array
|
||||
{
|
||||
return [
|
||||
'board_columns' => 'boardColumns', 'ticket_statuses' => 'ticketStatuses',
|
||||
'ticket_types' => 'ticketTypes', 'ticket_priorities' => 'ticketPriorities',
|
||||
'ticket_fields' => 'ticketFieldDefinitions', 'ticket_link_types' => 'ticketLinkTypes',
|
||||
'repository_connections' => 'repositoryConnections', 'notification_actions' => 'notificationActions',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||
public function normalize(array $configuration): array
|
||||
{
|
||||
foreach (self::relations() as $area => $relation) {
|
||||
if (! array_key_exists($area, $configuration)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($configuration[$area] as $index => &$item) {
|
||||
$item['configuration_key'] ??= (string) Uuid::uuid5(
|
||||
Uuid::NAMESPACE_URL, 'kanbangit:'.$area.':'.$index.':'.$this->signature($area, $item),
|
||||
);
|
||||
}
|
||||
unset($item);
|
||||
}
|
||||
|
||||
$columns = array_column($configuration['board_columns'] ?? [], 'configuration_key');
|
||||
foreach ($configuration['ticket_statuses'] ?? [] as $index => $status) {
|
||||
if (! array_key_exists('board_column_key', $status)) {
|
||||
$columnIndex = $status['board_column_index'] ?? null;
|
||||
$status['board_column_key'] = $columnIndex === null ? null : ($columns[$columnIndex] ?? null);
|
||||
}
|
||||
unset($status['board_column_index']);
|
||||
$configuration['ticket_statuses'][$index] = $status;
|
||||
}
|
||||
|
||||
$statuses = array_column($configuration['ticket_statuses'] ?? [], 'configuration_key');
|
||||
foreach ($configuration['status_transitions'] ?? [] as $index => $transition) {
|
||||
if (array_key_exists('status_key', $transition)) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($statuses[$index])) {
|
||||
continue;
|
||||
}
|
||||
$configuration['status_transitions'][$index] = [
|
||||
'status_key' => $statuses[$index],
|
||||
'target_keys' => array_values(array_filter(array_map(
|
||||
static fn (int $target): ?string => $statuses[$target] ?? null, $transition,
|
||||
))),
|
||||
];
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $item */
|
||||
public function signature(string $area, array $item): string
|
||||
{
|
||||
return $area === 'repository_connections'
|
||||
? ($item['provider'] ?? '').':'.($item['base_url'] ?? '').':'.($item['repository_full_name'] ?? '')
|
||||
: (string) ($item['name'] ?? $item['label'] ?? '');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function defaults(): array
|
||||
{
|
||||
$configuration = array_fill_keys(DuplicateProjectConfiguration::copyableAreas(), []);
|
||||
$configuration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
|
||||
$configuration['ticket_statuses'] = [[
|
||||
'configuration_key' => (string) Str::uuid(), 'name' => 'Backlog',
|
||||
'position' => 1, 'board_column_key' => null,
|
||||
]];
|
||||
$configuration['ticket_types'] = [
|
||||
['configuration_key' => (string) Str::uuid(), 'name' => 'Task', 'position' => 1, 'is_parent' => false],
|
||||
['configuration_key' => (string) Str::uuid(), 'name' => 'Release', 'position' => 2, 'is_parent' => true],
|
||||
];
|
||||
$configuration['ticket_priorities'] = [[
|
||||
'configuration_key' => (string) Str::uuid(), 'name' => 'Medium', 'position' => 1,
|
||||
]];
|
||||
$configuration['status_transitions'] = [[
|
||||
'status_key' => $configuration['ticket_statuses'][0]['configuration_key'], 'target_keys' => [],
|
||||
]];
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectTemplateOverrideManager
|
||||
{
|
||||
@@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$overrides = array_values(array_unique([
|
||||
...($project->template_overrides ?? []),
|
||||
$area,
|
||||
]));
|
||||
|
||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||
DB::transaction(function () use ($project, $area): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$project->load('projectTemplate');
|
||||
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
$project->forceFill(['template_overrides' => array_values(array_unique([
|
||||
...($project->template_overrides ?? []), $area,
|
||||
]))])->saveQuietly();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager
|
||||
*/
|
||||
public function clear(Project $project, array $areas): void
|
||||
{
|
||||
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
|
||||
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
|
||||
DB::transaction(function () use ($project, $areas): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$project->forceFill(['template_overrides' => array_values(array_diff($project->template_overrides ?? [], $areas))])->saveQuietly();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function withoutRecording(callable $callback): mixed
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RefreshProjectBaseTemplate
|
||||
{
|
||||
@@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate
|
||||
|
||||
public function handle(Project $project, string $area): void
|
||||
{
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$project->projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
|
||||
$project->projectTemplate->forceFill([
|
||||
'configuration_snapshot' => array_replace(
|
||||
$snapshot,
|
||||
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
|
||||
),
|
||||
])->saveQuietly();
|
||||
DB::transaction(function () use ($project, $area): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$template = ProjectTemplate::query()->find($project->project_template_id);
|
||||
if ($template === null || $template->is_published) {
|
||||
return;
|
||||
}
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$template->forceFill(['configuration_snapshot' => array_replace(
|
||||
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
|
||||
)])->saveQuietly();
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@ class ResolveProjectTemplateState
|
||||
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
|
||||
|
||||
return [
|
||||
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
|
||||
'effectiveConfiguration' => $currentConfiguration,
|
||||
'localConfiguration' => array_diff_key($currentConfiguration, $templateConfiguration),
|
||||
'localAreas' => collect(array_diff(array_keys($currentConfiguration), array_keys($templateConfiguration)))->map(fn (string $area): array => ['label' => $this->labelForArea($area), 'value' => $area])->values()->all(),
|
||||
'inheritedAreas' => $inheritedAreas,
|
||||
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
|
||||
'inheritsFromTemplate' => $projectTemplate->is_published,
|
||||
@@ -96,24 +98,20 @@ class ResolveProjectTemplateState
|
||||
return [];
|
||||
}
|
||||
|
||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
|
||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null;
|
||||
if (! is_array($templateStatuses)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$replacementOptions = $statuses->take(count($templateStatuses))->map(
|
||||
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||
$templateKeys = array_column($templateStatuses, 'configuration_key');
|
||||
$replacementOptions = collect($templateStatuses)->map(
|
||||
static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']],
|
||||
)->all();
|
||||
$removedStatuses = $statuses->slice(count($templateStatuses))
|
||||
->filter(static fn ($status): bool => $status->tickets_count > 0)
|
||||
->map(static fn ($status): array => [
|
||||
'id' => $status->id,
|
||||
'name' => $status->name,
|
||||
'ticketCount' => $status->tickets_count,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
$removedStatuses = $statuses
|
||||
->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0)
|
||||
->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count])
|
||||
->values()->all();
|
||||
|
||||
return $removedStatuses === [] ? [] : [
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
||||
|
||||
@@ -19,37 +19,34 @@ class UpdateProjectTemplate
|
||||
*/
|
||||
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||
{
|
||||
$projectTemplate->loadMissing('sourceProject');
|
||||
|
||||
$sourceProject = $projectTemplate->sourceProject;
|
||||
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
|
||||
|
||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
|
||||
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
||||
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
||||
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate {
|
||||
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []);
|
||||
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? []));
|
||||
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
Arr::get($attributes, 'configuration_snapshot', $previousConfiguration),
|
||||
))->only($copyAreas)->all();
|
||||
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||
->values()->all();
|
||||
$projectTemplate->forceFill([
|
||||
'name' => $attributes['name'],
|
||||
'copy_areas' => $normalizedCopyAreas,
|
||||
'name' => Arr::get($attributes, 'name'),
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $nextConfiguration,
|
||||
])->save();
|
||||
|
||||
Project::query()
|
||||
->where('project_template_id', $projectTemplate->id)
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||
->chunkById(100, function ($linkedProjects) use ($projectTemplate, $affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||
foreach ($linkedProjects as $linkedProject) {
|
||||
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
|
||||
$linkedProject = Project::query()
|
||||
->whereKey($linkedProject->id)
|
||||
->where('project_template_id', $projectTemplate->id)
|
||||
->lockForUpdate()->first();
|
||||
if ($linkedProject === null) {
|
||||
continue;
|
||||
}
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
||||
$overrides = $linkedProject->template_overrides ?? [];
|
||||
$replacementConfiguration = [];
|
||||
@@ -81,8 +78,8 @@ class UpdateProjectTemplate
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Actions\Projects;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UpdateProjectTemplateLink
|
||||
{
|
||||
@@ -16,6 +17,7 @@ class UpdateProjectTemplateLink
|
||||
public function switch(Project $project, ProjectTemplate $template): void
|
||||
{
|
||||
DB::transaction(function () use ($project, $template): void {
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
@@ -42,19 +44,27 @@ class UpdateProjectTemplateLink
|
||||
* @param list<string> $areas
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
*/
|
||||
public function reset(Project $project, array $areas, array $statusReplacements = []): void
|
||||
public function reset(Project $project, array $areas, array $statusReplacements = []): array
|
||||
{
|
||||
$template = $project->projectTemplate;
|
||||
|
||||
if ($template === null) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
|
||||
return DB::transaction(function () use ($project, $template, $areas, $statusReplacements): array {
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
))->only($areas)->all();
|
||||
if ($project->project_template_id !== $template->id) {
|
||||
throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']);
|
||||
}
|
||||
$baseline = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$configuration = collect($baseline)->only($areas)->all();
|
||||
if (in_array('ticket_statuses', $areas, true)
|
||||
&& ! in_array('status_transitions', $project->template_overrides ?? [], true)
|
||||
&& isset($baseline['status_transitions'])) {
|
||||
$configuration['status_transitions'] = $baseline['status_transitions'];
|
||||
}
|
||||
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
||||
$project,
|
||||
$configuration,
|
||||
@@ -64,6 +74,8 @@ class UpdateProjectTemplateLink
|
||||
$project->fresh(),
|
||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||
);
|
||||
});
|
||||
|
||||
return $blockedAreas;
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
|
||||
/**
|
||||
* @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);
|
||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
|
||||
: $existingCriteria->get($criterionId);
|
||||
|
||||
while (
|
||||
$criterion === null
|
||||
$reuseUnidentifiedCriteria
|
||||
&& $criterion === null
|
||||
&& $fallbackIndex < $fallbackCriteria->count()
|
||||
) {
|
||||
/** @var AcceptanceCriterion $candidate */
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets\Support;
|
||||
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\Extension\Table\Table;
|
||||
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
|
||||
use League\CommonMark\Parser\MarkdownParser;
|
||||
use League\Uri\Uri;
|
||||
|
||||
class TicketDescriptionFormat
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public static function contract(): array
|
||||
{
|
||||
return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public static function instructions(): string
|
||||
{
|
||||
$contract = self::contract();
|
||||
|
||||
return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria']))
|
||||
." Valid description example (Markdown string):\n".$contract['example'];
|
||||
}
|
||||
|
||||
public static function criteriaInstructions(): string
|
||||
{
|
||||
return self::contract()['criteria'];
|
||||
}
|
||||
|
||||
public static function safeLink(string $value): bool
|
||||
{
|
||||
$contract = self::contract();
|
||||
$decoded = rawurldecode($value);
|
||||
if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded)
|
||||
|| ! preg_match('~'.$contract['link_prefix'].'~i', $value)) {
|
||||
return false;
|
||||
}
|
||||
if (str_starts_with($value, '/') || str_starts_with($value, '#')) {
|
||||
return true;
|
||||
}
|
||||
$url = Uri::tryNew($decoded);
|
||||
|
||||
return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function unsupportedContent(string $value): array
|
||||
{
|
||||
$environment = new Environment;
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||||
$document = (new MarkdownParser($environment))->parse($value);
|
||||
$unsupported = [];
|
||||
foreach ($document->iterator() as $node) {
|
||||
$type = match (true) {
|
||||
$node instanceof HtmlBlock, $node instanceof HtmlInline => 'html',
|
||||
$node instanceof Table => 'table',
|
||||
$node instanceof Image => 'image',
|
||||
$node instanceof TaskListItemMarker => 'task',
|
||||
$node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link',
|
||||
default => null,
|
||||
};
|
||||
if ($type !== null) {
|
||||
$unsupported[] = self::contract()['unsupported_tokens'][$type];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($unsupported));
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ class UpdateProjectTicket
|
||||
Ticket $ticket,
|
||||
User $actor,
|
||||
array $validated,
|
||||
bool $replaceCriteriaWithIds = false,
|
||||
): bool {
|
||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||
@@ -58,7 +59,7 @@ class UpdateProjectTicket
|
||||
);
|
||||
$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']) {
|
||||
$ticketAttributes['blocked_comment'] = null;
|
||||
}
|
||||
@@ -91,7 +92,9 @@ class UpdateProjectTicket
|
||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||
$existingFieldValues = $ticket->fieldValues
|
||||
->keyBy('ticket_field_definition_id');
|
||||
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||
$currentAcceptanceCriteria = $replaceCriteriaWithIds
|
||||
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
|
||||
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||
|
||||
$changes = [];
|
||||
$summaryLabels = [];
|
||||
@@ -209,7 +212,7 @@ class UpdateProjectTicket
|
||||
}
|
||||
|
||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
|
||||
}
|
||||
|
||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||
|
||||
@@ -161,7 +161,7 @@ class ProjectController extends Controller
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
$this->updateProjectTemplateLink->reset(
|
||||
$blockedAreas = $this->updateProjectTemplateLink->reset(
|
||||
$project->load('projectTemplate'),
|
||||
$areas,
|
||||
$statusReplacements,
|
||||
@@ -171,6 +171,10 @@ class ProjectController extends Controller
|
||||
? "Selected settings were reset to the {$template->name} template."
|
||||
: "{$project->name} was reset to the {$template->name} template baseline.";
|
||||
|
||||
if ($blockedAreas !== []) {
|
||||
$status = 'Some settings remain overridden because tickets or other configuration still use them.';
|
||||
}
|
||||
|
||||
return back()->with('status', $status);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Projects\CreateProjectTemplate;
|
||||
use App\Actions\Projects\DeleteProjectTemplate;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||
use App\Actions\Projects\UpdateProjectTemplate;
|
||||
use App\Http\Requests\StoreProjectTemplateRequest;
|
||||
use App\Http\Requests\StoreStandaloneProjectTemplateRequest;
|
||||
use App\Http\Requests\UpdateProjectTemplateRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\ProjectTemplateResource;
|
||||
@@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectTemplate $createProjectTemplate,
|
||||
private readonly DeleteProjectTemplate $deleteProjectTemplate,
|
||||
private readonly UpdateProjectTemplate $updateProjectTemplate,
|
||||
) {}
|
||||
|
||||
@@ -38,6 +42,22 @@ class ProjectTemplateController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function createEmpty(ProjectConfigurationIdentity $identity): Response
|
||||
{
|
||||
return Inertia::render('project-templates/Create', [
|
||||
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||
'project' => null,
|
||||
'initialConfiguration' => $identity->defaults(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeEmpty(StoreStandaloneProjectTemplateRequest $request): RedirectResponse
|
||||
{
|
||||
$template = $this->createProjectTemplate->empty($request->validated());
|
||||
|
||||
return to_route('project-templates.edit', $template)->with('status', "Template {$template->name} was created.");
|
||||
}
|
||||
|
||||
public function edit(ProjectTemplate $projectTemplate): Response
|
||||
{
|
||||
abort_if(! $projectTemplate->is_published, 404);
|
||||
@@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller
|
||||
abort_if(! $projectTemplate->is_published, 404);
|
||||
|
||||
$templateName = $projectTemplate->name;
|
||||
$projectTemplate->delete();
|
||||
$this->deleteProjectTemplate->handle($projectTemplate);
|
||||
|
||||
return to_route('project-templates.index')
|
||||
->with('status', "Template {$templateName} was deleted.");
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Concerns;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Enums\NotificationFilterAttribute;
|
||||
use App\Enums\NotificationFilterOperator;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
trait ValidatesProjectTemplateConfiguration
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
protected function configurationRules(): array
|
||||
{
|
||||
$rules = ['configuration_snapshot' => ['sometimes', 'array:'.implode(',', DuplicateProjectConfiguration::copyableAreas())]];
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
$path = 'configuration_snapshot.'.$area;
|
||||
$rules[$path] = ['sometimes', 'array', 'max:200'];
|
||||
$rules[$path.'.*.configuration_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||
}
|
||||
$rules['configuration_snapshot.project_settings'] = ['sometimes', 'array:show_author,show_story_points'];
|
||||
foreach (['show_author', 'show_story_points'] as $field) {
|
||||
$rules['configuration_snapshot.project_settings.'.$field] = ['required_with:configuration_snapshot.project_settings', 'boolean'];
|
||||
}
|
||||
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities'] as $area) {
|
||||
$rules["configuration_snapshot.{$area}.*.name"] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_fields', 'ticket_link_types'] as $area) {
|
||||
$rules["configuration_snapshot.{$area}.*.position"] = ['required', 'integer', 'min:1', 'max:10000'];
|
||||
if ($area !== 'ticket_statuses') {
|
||||
$rules["configuration_snapshot.{$area}.*.position"][] = 'distinct:strict';
|
||||
}
|
||||
}
|
||||
$fields = [
|
||||
'board_columns' => 'configuration_key,name,position',
|
||||
'ticket_statuses' => 'configuration_key,name,position,board_column_key',
|
||||
'ticket_types' => 'configuration_key,name,position,is_parent',
|
||||
'ticket_priorities' => 'configuration_key,name,position',
|
||||
'ticket_fields' => 'configuration_key,label,position,field_type',
|
||||
'ticket_link_types' => 'configuration_key,label,counterpart_label,position',
|
||||
'repository_connections' => 'configuration_key,provider,base_url,provider_repository_id,owner_name,repository_name,repository_full_name,repository_url',
|
||||
'notification_actions' => 'configuration_key,name,enabled,event_type,channel,channel_configuration,message_template,delivery_url,filters',
|
||||
];
|
||||
foreach ($fields as $area => $allowed) {
|
||||
$rules["configuration_snapshot.{$area}.*"] = ['array:'.$allowed];
|
||||
}
|
||||
$rules['configuration_snapshot.ticket_statuses.*.board_column_key'] = ['nullable', 'uuid'];
|
||||
$rules['configuration_snapshot.ticket_types.*.is_parent'] = ['required', 'boolean'];
|
||||
$rules['configuration_snapshot.ticket_fields.*.field_type'] = ['required', Rule::in(['number', 'user'])];
|
||||
$rules['configuration_snapshot.ticket_fields.*.label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.ticket_link_types.*.label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.ticket_link_types.*.counterpart_label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.status_transitions'] = ['sometimes', 'array', 'max:200'];
|
||||
$rules['configuration_snapshot.status_transitions.*'] = ['array:status_key,target_keys'];
|
||||
$rules['configuration_snapshot.status_transitions.*.status_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||
$rules['configuration_snapshot.status_transitions.*.target_keys'] = ['present', 'array', 'max:200'];
|
||||
$rules['configuration_snapshot.status_transitions.*.target_keys.*'] = ['uuid'];
|
||||
foreach (['provider_repository_id', 'owner_name', 'repository_name', 'repository_full_name'] as $field) {
|
||||
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
$rules['configuration_snapshot.repository_connections.*.provider'] = ['required', Rule::in(['gitea'])];
|
||||
foreach (['base_url', 'repository_url'] as $field) {
|
||||
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'url:http,https', 'max:255'];
|
||||
}
|
||||
$prefix = 'configuration_snapshot.notification_actions.*.';
|
||||
$rules[$prefix.'name'] = ['required', 'string', 'max:255'];
|
||||
$rules[$prefix.'enabled'] = ['required', 'boolean'];
|
||||
$rules[$prefix.'event_type'] = ['required', Rule::in(NotificationEventType::values())];
|
||||
$rules[$prefix.'channel'] = ['required', Rule::in(NotificationChannel::values())];
|
||||
$rules[$prefix.'delivery_url'] = ['required', 'url:http,https', 'max:2048'];
|
||||
$rules[$prefix.'message_template'] = ['nullable', 'string', 'max:2000'];
|
||||
$rules[$prefix.'channel_configuration'] = ['sometimes', 'array:allowed_mentions'];
|
||||
$rules[$prefix.'channel_configuration.allowed_mentions'] = ['sometimes', 'array'];
|
||||
$rules[$prefix.'channel_configuration.allowed_mentions.*'] = [Rule::in(['everyone', 'roles', 'users'])];
|
||||
$rules[$prefix.'filters'] = ['sometimes', 'array', 'max:25'];
|
||||
$rules[$prefix.'filters.*'] = ['array:attribute,operator,value'];
|
||||
$rules[$prefix.'filters.*.attribute'] = ['required', Rule::in(NotificationFilterAttribute::values())];
|
||||
$rules[$prefix.'filters.*.operator'] = ['required', Rule::in(NotificationFilterOperator::values())];
|
||||
$rules[$prefix.'filters.*.value'] = ['nullable'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** @return array<int, callable(Validator): void> */
|
||||
public function after(): array
|
||||
{
|
||||
return [function (Validator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
$validated = $validator->validated();
|
||||
$configuration = Arr::get($validated, 'configuration_snapshot', []);
|
||||
$areas = Arr::get($validated, 'copy_areas', []);
|
||||
$columns = array_column(Arr::get($configuration, 'board_columns', []), 'configuration_key');
|
||||
$statuses = array_column(Arr::get($configuration, 'ticket_statuses', []), 'configuration_key');
|
||||
foreach (Arr::get($configuration, 'ticket_statuses', []) as $index => $status) {
|
||||
$key = Arr::get($status, 'board_column_key');
|
||||
if ($key !== null && in_array('board_columns', $areas, true) && ! in_array($key, $columns, true)) {
|
||||
$validator->errors()->add("configuration_snapshot.ticket_statuses.{$index}.board_column_key", 'Choose a column from this template.');
|
||||
}
|
||||
}
|
||||
foreach (Arr::get($configuration, 'status_transitions', []) as $index => $transition) {
|
||||
if (! in_array(Arr::get($transition, 'status_key'), $statuses, true)
|
||||
|| array_diff(Arr::get($transition, 'target_keys', []), $statuses) !== []) {
|
||||
$validator->errors()->add("configuration_snapshot.status_transitions.{$index}", 'Choose statuses from this template.');
|
||||
}
|
||||
if (in_array('status_transitions', $areas, true) && ! in_array('ticket_statuses', $areas, true)
|
||||
&& Arr::get($transition, 'target_keys', []) !== []) {
|
||||
$validator->errors()->add('copy_areas', 'Include ticket statuses when defining template transitions.');
|
||||
}
|
||||
}
|
||||
$repositoryIdentifiers = [];
|
||||
foreach (Arr::get($configuration, 'repository_connections', []) as $index => $connection) {
|
||||
$identifier = serialize([$connection['provider'], $connection['base_url'], $connection['provider_repository_id']]);
|
||||
if (isset($repositoryIdentifiers[$identifier])) {
|
||||
$validator->errors()->add("configuration_snapshot.repository_connections.{$index}.provider_repository_id", 'This repository is already included in the template.');
|
||||
}
|
||||
$repositoryIdentifiers[$identifier] = true;
|
||||
}
|
||||
foreach (Arr::get($configuration, 'notification_actions', []) as $index => $action) {
|
||||
if (Arr::get($action, 'channel') === 'discord' && blank(Arr::get($action, 'message_template'))) {
|
||||
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.message_template", 'A Discord message is required.');
|
||||
}
|
||||
foreach (Arr::get($action, 'filters', []) as $filterIndex => $filter) {
|
||||
$operator = Arr::get($filter, 'operator');
|
||||
$value = Arr::get($filter, 'value');
|
||||
$valid = is_string($value) || $value === null || (is_array($value) && collect($value)->every(static fn ($item): bool => is_string($item)));
|
||||
if (! $valid || (in_array($operator, ['equals', 'not_equals', 'in'], true) && blank($value))
|
||||
|| (in_array($operator, ['exists', 'not_exists'], true) && filled($value))) {
|
||||
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.filters.{$filterIndex}.value", 'Choose a value that matches the filter operator.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,7 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
Rule::in([$project->key]),
|
||||
],
|
||||
'status_replacements' => ['sometimes', 'array'],
|
||||
'status_replacements.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
'status_replacements.*' => ['required'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$validated = $validator->validated();
|
||||
$areas = array_values(Arr::get(
|
||||
$validated,
|
||||
'areas',
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
));
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
foreach ($areas as $area) {
|
||||
if (! array_key_exists($area, $configuration)) {
|
||||
$validator->errors()->add('areas', 'This template does not define the selected settings area.');
|
||||
}
|
||||
}
|
||||
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
|
||||
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||
);
|
||||
$templateStatuses = Arr::get(
|
||||
$configuration,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||
[],
|
||||
);
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
|
||||
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||
$allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
|
||||
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
||||
if ($status->tickets_count === 0) {
|
||||
foreach ($replacements as $id => $replacement) {
|
||||
if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
|
||||
|| ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||
$validator->errors()->add("status_replacements.{$id}", 'Choose a replacement from this template for a status in this project.');
|
||||
}
|
||||
}
|
||||
foreach ($statuses as $status) {
|
||||
if (in_array($status->configuration_key, $templateKeys, true) || $status->tickets_count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
|
||||
if (! $allowedReplacementIds->contains($replacementId)) {
|
||||
$validator->errors()->add(
|
||||
"status_replacements.{$status->id}",
|
||||
"Choose a replacement status for {$status->name}.",
|
||||
);
|
||||
$replacement = Arr::get($replacements, (string) $status->id);
|
||||
if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||
$validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ class StoreProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
return ($this->user()?->can('create-project') ?? false)
|
||||
&& ($this->route('project') === null || $this->user()->can('admin-project', $this->route('project')));
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$project = $this->route('project');
|
||||
$baseTemplate = $project?->projectTemplate;
|
||||
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))],
|
||||
'copy_areas' => ['present', 'array'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||
|
||||
class StoreStandaloneProjectTemplateRequest extends StoreProjectTemplateRequest
|
||||
{
|
||||
use ValidatesProjectTemplateConfiguration;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [...parent::rules(), ...$this->configurationRules()];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
use ValidatesProjectTemplateConfiguration;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
@@ -25,8 +28,9 @@ class UpdateProjectTemplateRequest extends FormRequest
|
||||
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
||||
|
||||
return [
|
||||
...$this->configurationRules(),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas' => ['present', 'array'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||
|
||||
@@ -32,6 +32,8 @@ class ProjectResource extends JsonResource
|
||||
? null
|
||||
: [
|
||||
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
|
||||
'local_configuration' => Arr::get($templateState, 'localConfiguration', []),
|
||||
'local_areas' => Arr::get($templateState, 'localAreas', []),
|
||||
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
|
||||
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
||||
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||
|
||||
@@ -2,24 +2,32 @@
|
||||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Mcp\Tools\CreateTicket;
|
||||
use App\Mcp\Tools\GetProjectConfiguration;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||
use App\Mcp\Tools\ListProjects;
|
||||
use App\Mcp\Tools\ListTicketsByStatus;
|
||||
use App\Mcp\Tools\ListTicketTypes;
|
||||
use App\Mcp\Tools\MoveTicketStatus;
|
||||
use App\Mcp\Tools\SearchTickets;
|
||||
use App\Mcp\Tools\UpdateTicket;
|
||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||
use App\Mcp\Tools\UpdateTicketDescription;
|
||||
use Laravel\Mcp\Server;
|
||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Attributes\Version;
|
||||
|
||||
#[Name('KanbanGit MCP')]
|
||||
#[Version('0.1.0')]
|
||||
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
||||
#[Version('0.2.1')]
|
||||
class KanbanGitServer extends Server
|
||||
{
|
||||
protected function boot(): void
|
||||
{
|
||||
$this->instructions = 'Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
|
||||
}
|
||||
|
||||
protected array $tools = [
|
||||
ListMyAssignedTickets::class,
|
||||
ListTicketsByStatus::class,
|
||||
@@ -29,6 +37,10 @@ class KanbanGitServer extends Server
|
||||
MoveTicketStatus::class,
|
||||
CreateTicket::class,
|
||||
ListTicketTypes::class,
|
||||
ListProjects::class,
|
||||
GetProjectConfiguration::class,
|
||||
SearchTickets::class,
|
||||
UpdateTicket::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Support;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
|
||||
trait DescribesTicketWriting
|
||||
{
|
||||
public function description(): string
|
||||
{
|
||||
return parent::description().' '.TicketDescriptionFormat::instructions();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
@@ -78,6 +79,17 @@ class KanbanGitMcp
|
||||
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
|
||||
*/
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Support;
|
||||
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Http\Requests\UpdateProjectTicketRequest;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\User;
|
||||
use App\Rules\VisualTicketDescription;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
|
||||
class TicketUpdateInput
|
||||
{
|
||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validate(Request $request, Ticket $ticket, User $user): array
|
||||
{
|
||||
$form = new UpdateProjectTicketRequest;
|
||||
$route = new Route('PUT', '/', fn () => null);
|
||||
$route->bind(\Illuminate\Http\Request::create('/', 'PUT'));
|
||||
$route->setParameter('project', $ticket->project);
|
||||
$route->setParameter('ticket', $ticket);
|
||||
$form->setRouteResolver(fn () => $route);
|
||||
$form->setUserResolver(fn () => $user);
|
||||
$rules = $form->rules();
|
||||
foreach ($rules as $key => &$rule) {
|
||||
if (! str_contains($key, '.')) {
|
||||
$rule = ['sometimes', ...$rule];
|
||||
}
|
||||
}
|
||||
unset($rule);
|
||||
$rules['description'][] = new VisualTicketDescription;
|
||||
$rules['acceptance_criteria'] = ['sometimes', 'array', 'list'];
|
||||
$rules['acceptance_criteria.*'] = ['array:id,text,checked,indent'];
|
||||
$rules['acceptance_criteria.*.id'] = ['sometimes', 'integer', 'min:1', 'distinct', Rule::exists('acceptance_criteria', 'id')->where('ticket_id', $ticket->id)];
|
||||
$rules['acceptance_criteria.*.checked'] = ['sometimes', 'boolean'];
|
||||
$rules['acceptance_criteria.*.indent'] = ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL];
|
||||
$rules['field_values'] = ['sometimes', 'array', 'list'];
|
||||
$rules['field_values.*'] = ['array:ticket_field_definition_id,value_number,value_user_id'];
|
||||
$rules['field_values.*.ticket_field_definition_id'][] = 'distinct';
|
||||
$rules['ticket_status_id'] = ['prohibited'];
|
||||
$rules['acceptance_criteria.*.text'][] = function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (is_string($value) && trim($value) === '') {
|
||||
$fail('Criterion text must not be blank.');
|
||||
}
|
||||
};
|
||||
$input = $request->validate($rules);
|
||||
$previousIndent = -1;
|
||||
foreach (Arr::get($input, 'acceptance_criteria', []) as $index => $item) {
|
||||
$indent = Arr::get($item, 'indent', 0);
|
||||
if ($indent > $previousIndent + 1) {
|
||||
throw ValidationException::withMessages(["acceptance_criteria.{$index}.indent" => 'Indentation cannot skip a level; the first item must have indent 0.']);
|
||||
}
|
||||
$previousIndent = $indent;
|
||||
}
|
||||
$merged = array_replace($this->kanbanGitMcp->updatePayload($ticket), $input);
|
||||
$merged['acceptance_criteria'] = Arr::exists($input, 'acceptance_criteria')
|
||||
? AcceptanceCriteriaChecklistMapper::normalize($input['acceptance_criteria'])
|
||||
: AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria()->get());
|
||||
|
||||
$validator = Validator::make($merged, $form->rules());
|
||||
$form->setValidator($validator);
|
||||
$validator->validate();
|
||||
foreach ($form->after() as $callback) {
|
||||
$callback($validator);
|
||||
}
|
||||
|
||||
$people = User::query()->visibleToProject($ticket->project)->pluck('id')->all();
|
||||
foreach (['assignee_user_id', 'author_user_id'] as $field) {
|
||||
if (Arr::exists($input, $field) && $input[$field] !== null && ! in_array((int) $input[$field], $people, true)) {
|
||||
$validator->errors()->add($field, 'The person must be able to view this project.');
|
||||
}
|
||||
}
|
||||
$definitions = $ticket->project->ticketFieldDefinitions()->get()->keyBy('id');
|
||||
foreach (Arr::get($input, 'field_values', []) as $index => $value) {
|
||||
$definition = $definitions->get($value['ticket_field_definition_id']);
|
||||
$incompatible = $definition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
||||
if (Arr::get($value, $incompatible) !== null) {
|
||||
$validator->errors()->add("field_values.{$index}.{$incompatible}", 'The value must match the custom field type.');
|
||||
}
|
||||
if (Arr::get($value, 'value_user_id') !== null && ! in_array((int) $value['value_user_id'], $people, true)) {
|
||||
$validator->errors()->add("field_values.{$index}.value_user_id", 'The person must be able to view this project.');
|
||||
}
|
||||
}
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
throw new ValidationException($validator);
|
||||
}
|
||||
|
||||
if (Arr::exists($input, 'field_values')) {
|
||||
$replacement = $definitions->map(fn (TicketFieldDefinition $definition): array => [
|
||||
'ticket_field_definition_id' => $definition->id,
|
||||
'value_number' => null,
|
||||
'value_user_id' => null,
|
||||
])->all();
|
||||
foreach ($input['field_values'] as $value) {
|
||||
$replacement[$value['ticket_field_definition_id']] = $value;
|
||||
}
|
||||
$merged['field_values'] = array_values($replacement);
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,14 @@ namespace App\Mcp\Tools;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\CreateProjectTicket;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Enums\Permission;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Rules\VisualTicketDescription;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -28,6 +31,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class CreateTicket extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly CreateProjectTicket $createProjectTicket,
|
||||
@@ -44,7 +49,7 @@ class CreateTicket extends Tool
|
||||
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
||||
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
||||
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
||||
@@ -140,7 +145,7 @@ class CreateTicket extends Tool
|
||||
return [
|
||||
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
||||
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
||||
'description' => $schema->string()->nullable()->description('Ticket description.'),
|
||||
'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()),
|
||||
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
||||
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
||||
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
||||
@@ -156,7 +161,7 @@ class CreateTicket extends Tool
|
||||
'checked' => $schema->boolean()->default(false),
|
||||
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
||||
])->withoutAdditionalProperties())
|
||||
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
|
||||
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()),
|
||||
'field_values' => $schema->array()
|
||||
->items($schema->object([
|
||||
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
#[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]
|
||||
class GetTicket extends Tool
|
||||
{
|
||||
@@ -25,11 +25,14 @@ class GetTicket extends Tool
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$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);
|
||||
$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([
|
||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
||||
@@ -43,8 +46,8 @@ class GetTicket extends Tool
|
||||
{
|
||||
return [
|
||||
'ticket_id' => $schema->integer()
|
||||
->description('Ticket ID.')
|
||||
->required(),
|
||||
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
|
||||
'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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\MoveProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class MoveTicketStatus extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly MoveProjectTicket $moveProjectTicket,
|
||||
|
||||
@@ -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,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Mcp\Support\TicketUpdateInput;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[Name('update_ticket')]
|
||||
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
|
||||
#[IsDestructive(false)]
|
||||
class UpdateTicket extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly TicketUpdateInput $ticketUpdateInput,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
|
||||
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
|
||||
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
||||
'meta' => ['updated' => $updated],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, Type> */
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
|
||||
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
|
||||
'description' => $schema->string()->nullable()->description('Description; null clears it. '.TicketDescriptionFormat::instructions()),
|
||||
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
||||
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
|
||||
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
|
||||
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
|
||||
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
|
||||
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
|
||||
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
|
||||
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
|
||||
'acceptance_criteria' => $schema->array()->items($schema->object([
|
||||
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
|
||||
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
|
||||
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
|
||||
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
|
||||
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid. '.TicketDescriptionFormat::criteriaInstructions()),
|
||||
'field_values' => $schema->array()->items($schema->object([
|
||||
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
|
||||
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
|
||||
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
|
||||
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class UpdateTicketAssignee extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Rules\VisualTicketDescription;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
@@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class UpdateTicketDescription extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
@@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'description' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||
'ticket_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
@@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool
|
||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
||||
$payload = [
|
||||
...$this->kanbanGitMcp->updatePayload($ticket),
|
||||
'description' => $validated['description'] ?? null,
|
||||
...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
|
||||
];
|
||||
|
||||
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
||||
@@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool
|
||||
return [
|
||||
'description' => $schema->string()
|
||||
->nullable()
|
||||
->description('New ticket description. Pass null to clear it.'),
|
||||
->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()),
|
||||
'ticket_id' => $schema->integer()
|
||||
->description('Ticket ID.')
|
||||
->required(),
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
|
||||
class BoardColumn extends Model
|
||||
{
|
||||
/** @use HasFactory<BoardColumnFactory> */
|
||||
|
||||
@@ -5,11 +5,44 @@ namespace App\Models\Concerns;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait SyncsProjectTemplateOwnership
|
||||
{
|
||||
/** @param array<string, mixed> $options */
|
||||
public function save(array $options = []): bool
|
||||
{
|
||||
return DB::transaction(function () use ($options): bool {
|
||||
$this->syncProjectTemplateOwnership();
|
||||
if ($this->getAttribute('project_id') !== null) {
|
||||
$project = Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
|
||||
if ($project?->project_template_id !== null) {
|
||||
$this->setAttribute('project_template_id', $project->project_template_id);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::save($options);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function delete(): ?bool
|
||||
{
|
||||
return DB::transaction(function (): ?bool {
|
||||
if ($this->getAttribute('project_id') !== null) {
|
||||
Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
|
||||
}
|
||||
|
||||
return parent::delete();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
protected function syncProjectTemplateOwnership(): void
|
||||
{
|
||||
if ($this->getAttribute('configuration_key') === null) {
|
||||
$this->setAttribute('configuration_key', (string) Str::uuid());
|
||||
}
|
||||
|
||||
$projectTemplateId = $this->getAttribute('project_template_id');
|
||||
$projectId = $this->getAttribute('project_id');
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
#[Fillable(['configuration_key',
|
||||
'channel',
|
||||
'channel_configuration',
|
||||
'delivery_url',
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
#[Fillable(['configuration_key',
|
||||
'base_url',
|
||||
'owner_name',
|
||||
'project_id',
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketFieldDefinition extends Model
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketLinkType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketPriority extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketPriorityFactory> */
|
||||
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||
#[Fillable(['configuration_key', 'board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||
class TicketStatus extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketStatusFactory> */
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||
class TicketType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketTypeFactory> */
|
||||
|
||||
@@ -30,6 +30,9 @@ class ProjectConfigurationObserver
|
||||
public function deleted(Model $model): void
|
||||
{
|
||||
$this->markOverride($model);
|
||||
if ($model instanceof TicketStatus && $model->project !== null) {
|
||||
$this->refreshProjectBaseTemplate->handle($model->project, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS);
|
||||
}
|
||||
}
|
||||
|
||||
private function markOverride(Model $model): void
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class VisualTicketDescription implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return;
|
||||
}
|
||||
$unsupported = TicketDescriptionFormat::unsupportedContent($value);
|
||||
if ($unsupported !== []) {
|
||||
$fail('The description contains unsupported '.implode(', ', $unsupported).'. Remove these contents or show literal examples inside code. Use ordinary Markdown and safe links; put acceptance criteria only in acceptance_criteria. No ticket fields were changed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<BoardColumn>
|
||||
@@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
'project_id' => Project::factory(),
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Enums\NotificationEventType;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<NotificationAction>
|
||||
@@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'channel' => NotificationChannel::Webhook,
|
||||
'channel_configuration' => [],
|
||||
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Actions\Git\GitProviderKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<RepositoryConnection>
|
||||
@@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'base_url' => 'https://git.'.fake()->domainName(),
|
||||
'owner_name' => fake()->userName(),
|
||||
'project_id' => Project::factory(),
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketFieldDefinition>
|
||||
@@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
|
||||
'label' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketLinkType;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketLinkType>
|
||||
@@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory
|
||||
$label = fake()->unique()->words(2, true);
|
||||
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'label' => $label,
|
||||
'counterpart_label' => $label,
|
||||
'position' => 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketPriority;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketPriority>
|
||||
@@ -19,6 +20,7 @@ class TicketPriorityFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
|
||||
'position' => 1,
|
||||
'project_id' => Project::factory(),
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketStatus>
|
||||
@@ -19,6 +20,7 @@ class TicketStatusFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'board_column_id' => null,
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketType>
|
||||
@@ -19,6 +20,7 @@ class TicketTypeFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'is_parent' => false,
|
||||
'name' => fake()->unique()->jobTitle(),
|
||||
'position' => 1,
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TABLES = [
|
||||
'board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities',
|
||||
'ticket_field_definitions', 'ticket_link_types', 'repository_connections', 'notification_actions',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
foreach (self::TABLES as $name) {
|
||||
Schema::table($name, function (Blueprint $table): void {
|
||||
$table->uuid('configuration_key')->nullable()->index();
|
||||
});
|
||||
DB::table($name)->orderBy('id')->chunkById(100, function ($rows) use ($name): void {
|
||||
foreach ($rows as $row) {
|
||||
DB::table($name)->where('id', $row->id)->update(['configuration_key' => (string) Str::uuid()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
$identity = app(ProjectConfigurationIdentity::class);
|
||||
|
||||
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration, $identity): void {
|
||||
$snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$source = $template->sourceProject;
|
||||
if (! $template->is_published && $template->project !== null) {
|
||||
$snapshot = $configuration->export($template->project, DuplicateProjectConfiguration::copyableAreas());
|
||||
} elseif ($source !== null) {
|
||||
$sourceConfiguration = $configuration->export($source, array_keys($snapshot));
|
||||
$replacements = [];
|
||||
$usedKeys = [];
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
foreach ($snapshot[$area] ?? [] as $index => $item) {
|
||||
$matches = collect($sourceConfiguration[$area] ?? [])->filter(
|
||||
fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item),
|
||||
);
|
||||
$baselineMatches = collect($snapshot[$area])->filter(fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item));
|
||||
if ($matches->count() > 1 || $baselineMatches->count() > 1) {
|
||||
$matches = $matches->filter(static fn (array $candidate): bool => ($candidate['position'] ?? null) === ($item['position'] ?? null));
|
||||
if ($area === 'ticket_statuses') {
|
||||
$columnKey = $replacements[$item['board_column_key'] ?? ''] ?? ($item['board_column_key'] ?? null);
|
||||
$matches = $matches->filter(static fn (array $candidate): bool => ($candidate['board_column_key'] ?? null) === $columnKey);
|
||||
}
|
||||
}
|
||||
if ($matches->count() === 1 && ! in_array($matches->first()['configuration_key'], $usedKeys[$area] ?? [], true)) {
|
||||
$usedKeys[$area][] = $matches->first()['configuration_key'];
|
||||
$replacements[$item['configuration_key']] = $matches->first()['configuration_key'];
|
||||
$snapshot[$area][$index]['configuration_key'] = $matches->first()['configuration_key'];
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) {
|
||||
$snapshot['ticket_statuses'][$index]['board_column_key'] = $replacements[$status['board_column_key'] ?? ''] ?? ($status['board_column_key'] ?? null);
|
||||
}
|
||||
foreach ($snapshot['status_transitions'] ?? [] as $index => $transition) {
|
||||
$snapshot['status_transitions'][$index] = [
|
||||
'status_key' => $replacements[$transition['status_key']] ?? $transition['status_key'],
|
||||
'target_keys' => array_map(static fn (string $key): string => $replacements[$key] ?? $key, $transition['target_keys']),
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($source !== null) {
|
||||
$statusKeys = $source->ticketStatuses()->pluck('configuration_key', 'id')->all();
|
||||
foreach ($snapshot['notification_actions'] ?? [] as $index => $action) {
|
||||
$snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $statusKeys);
|
||||
}
|
||||
}
|
||||
$template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly();
|
||||
});
|
||||
|
||||
Project::query()->with('projectTemplate')->eachById(function (Project $project) use ($identity): void {
|
||||
$snapshot = $project->projectTemplate?->configuration_snapshot ?? [];
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
$models = $project->{$relation}()->get();
|
||||
$usedIds = [];
|
||||
$columnKeys = $area === 'ticket_statuses' ? $project->boardColumns()->pluck('configuration_key', 'id')->all() : [];
|
||||
foreach ($snapshot[$area] ?? [] as $item) {
|
||||
$matches = $models->filter(fn ($model): bool => $identity->signature($area, $model->attributesToArray()) === $identity->signature($area, $item));
|
||||
$matches = $matches->reject(static fn ($model): bool => in_array($model->id, $usedIds, true));
|
||||
if ($matches->count() > 1) {
|
||||
$matches = $matches->filter(static fn ($model): bool => $model->position === ($item['position'] ?? null));
|
||||
if ($area === 'ticket_statuses') {
|
||||
$matches = $matches->filter(static fn ($model): bool => ($columnKeys[$model->board_column_id] ?? null) === ($item['board_column_key'] ?? null));
|
||||
}
|
||||
}
|
||||
if ($matches->count() === 1) {
|
||||
$model = $matches->first();
|
||||
$usedIds[] = $model->id;
|
||||
DB::table($model->getTable())->where('id', $model->id)->update(['configuration_key' => $item['configuration_key']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
foreach (self::TABLES as $name) {
|
||||
Schema::table($name, function (Blueprint $table): void {
|
||||
$table->unique(['project_id', 'configuration_key']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
|
||||
$snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$columnIndexes = array_flip(array_column($snapshot['board_columns'] ?? [], 'configuration_key'));
|
||||
$statusIndexes = array_flip(array_column($snapshot['ticket_statuses'] ?? [], 'configuration_key'));
|
||||
if (isset($snapshot['status_transitions'])) {
|
||||
$transitions = array_fill(0, count($statusIndexes), []);
|
||||
foreach ($snapshot['status_transitions'] as $transition) {
|
||||
$index = $statusIndexes[$transition['status_key']] ?? null;
|
||||
if ($index !== null) {
|
||||
$transitions[$index] = array_values(array_filter(array_map(
|
||||
static fn (string $key): ?int => $statusIndexes[$key] ?? null,
|
||||
$transition['target_keys'],
|
||||
), static fn (?int $index): bool => $index !== null));
|
||||
}
|
||||
}
|
||||
$snapshot['status_transitions'] = $transitions;
|
||||
}
|
||||
foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) {
|
||||
$status['board_column_index'] = $columnIndexes[$status['board_column_key'] ?? ''] ?? null;
|
||||
unset($status['board_column_key']);
|
||||
$snapshot['ticket_statuses'][$index] = $status;
|
||||
}
|
||||
$sourceStatusIds = $template->sourceProject?->ticketStatuses()->pluck('id', 'configuration_key')->all() ?? [];
|
||||
foreach ($snapshot['notification_actions'] ?? [] as $index => $action) {
|
||||
$snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $sourceStatusIds);
|
||||
}
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
foreach ($snapshot[$area] ?? [] as $index => $item) {
|
||||
unset($item['configuration_key']);
|
||||
$snapshot[$area][$index] = $item;
|
||||
}
|
||||
}
|
||||
$template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly();
|
||||
});
|
||||
foreach (self::TABLES as $name) {
|
||||
Schema::table($name, function (Blueprint $table): void {
|
||||
$table->dropUnique(['project_id', 'configuration_key']);
|
||||
$table->dropIndex(['configuration_key']);
|
||||
$table->dropColumn('configuration_key');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -25,5 +25,5 @@
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4"
|
||||
},
|
||||
"releaseNotes": "Manage local workspace settings in the web app on macOS and iOS, with shared macOS update controls and native recovery for older or unavailable servers."
|
||||
"releaseNotes": "Fix blocked navigation after changing project creation options or other forms on iOS by displaying native confirmations for unsaved changes. Pending confirmations close safely when changing workspaces."
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.6"
|
||||
version = "0.2.8"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.6"
|
||||
version = "0.2.8"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -43,6 +43,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private var settingsAvailable = false
|
||||
private var toolbarHeight: NSLayoutConstraint!
|
||||
private var appearance: UISegmentedControl!
|
||||
private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
@@ -51,6 +52,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
deinit { javaScriptConfirmation?.completion(false) }
|
||||
|
||||
private func text(_ key: String) -> String {
|
||||
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
|
||||
}
|
||||
@@ -199,6 +202,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
func open(_ url: URL) {
|
||||
if server?.url == url, connection.ready { showWorkspace(); return }
|
||||
completeJavaScriptConfirmation(false)
|
||||
let selected = WorkspaceServer.selecting(url, current: server)
|
||||
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||
defaults.set(data, forKey: serverKey)
|
||||
@@ -265,6 +269,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
completeJavaScriptConfirmation(false)
|
||||
loading.stopAnimating()
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = false
|
||||
@@ -320,6 +325,39 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||
guard webView === workspace, viewIfLoaded?.window != nil,
|
||||
presentedViewController == nil, javaScriptConfirmation == nil else {
|
||||
completionHandler(false)
|
||||
return
|
||||
}
|
||||
let alert = UIAlertController(title: frame.request.url?.host ?? text("appName"),
|
||||
message: message, preferredStyle: .alert)
|
||||
let cancel = UIAlertAction(title: text("cancel"), style: .cancel) { [weak self] _ in
|
||||
self?.completeJavaScriptConfirmation(false)
|
||||
}
|
||||
alert.addAction(cancel)
|
||||
alert.addAction(UIAlertAction(title: text("ok"), style: .default) { [weak self] _ in
|
||||
self?.completeJavaScriptConfirmation(true)
|
||||
})
|
||||
alert.preferredAction = cancel
|
||||
javaScriptConfirmation = (alert, completionHandler)
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
func completeJavaScriptConfirmation(_ accepted: Bool) {
|
||||
guard let confirmation = javaScriptConfirmation else { return }
|
||||
javaScriptConfirmation = nil
|
||||
if confirmation.alert.presentingViewController == nil {
|
||||
confirmation.completion(accepted)
|
||||
} else {
|
||||
confirmation.alert.dismiss(animated: false) {
|
||||
confirmation.completion(accepted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||
guard server?.allows(url) == true else {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
|
||||
"confirmSwitch" = "Switch server";
|
||||
"cancel" = "Cancel";
|
||||
"ok" = "OK";
|
||||
"connecting" = "Connecting to your server…";
|
||||
"connected" = "Your workspace is open. Use Server settings to change servers.";
|
||||
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
|
||||
|
||||
@@ -135,6 +135,94 @@ final class WorkspaceTests: XCTestCase {
|
||||
"Changing workspaces must retain native gesture navigation")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDirtyFormNavigationPresentsNativeConfirmation() async throws {
|
||||
let animationsEnabled = UIView.areAnimationsEnabled
|
||||
UIView.setAnimationsEnabled(false)
|
||||
defer { UIView.setAnimationsEnabled(animationsEnabled) }
|
||||
let name = "ios-confirmation-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true; window.rootViewController = nil }
|
||||
let url = URL(string: "https://example.invalid/")!
|
||||
controller.open(url)
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
webview.stopLoading()
|
||||
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
|
||||
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
|
||||
<a id="back" href="#back">Back</a><script>
|
||||
window.visits = 0;
|
||||
document.querySelector('#back').onclick = event => {
|
||||
event.preventDefault();
|
||||
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
|
||||
};
|
||||
</script></body></html>
|
||||
""")
|
||||
try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'")
|
||||
_ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true")
|
||||
let pristineVisits = try await webview.evaluateJavaScript("window.visits")
|
||||
XCTAssertEqual(pristineVisits as? Int, 1)
|
||||
XCTAssertNil(controller.presentedViewController)
|
||||
_ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true")
|
||||
var expectedVisits = 1
|
||||
for (index, accepted) in [false, true, false, true].enumerated() {
|
||||
let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
|
||||
let alert = try await waitForConfirmation(controller)
|
||||
XCTAssertEqual(alert.title, "example.invalid")
|
||||
XCTAssertEqual(alert.message, "Discard unsaved changes?")
|
||||
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default])
|
||||
XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft")
|
||||
if index == 3 {
|
||||
await withCheckedContinuation { continuation in
|
||||
alert.dismiss(animated: false) { continuation.resume() }
|
||||
}
|
||||
}
|
||||
controller.completeJavaScriptConfirmation(accepted)
|
||||
controller.completeJavaScriptConfirmation(!accepted)
|
||||
_ = try await click.value
|
||||
if accepted { expectedVisits += 1 }
|
||||
let visits = try await webview.evaluateJavaScript("window.visits")
|
||||
XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each")
|
||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||
XCTAssertEqual(draft as? String, "Unsaved project")
|
||||
XCTAssertNil(controller.presentedViewController)
|
||||
}
|
||||
|
||||
let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert)
|
||||
controller.present(otherAlert, animated: false)
|
||||
let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')")
|
||||
XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript")
|
||||
XCTAssertTrue(controller.presentedViewController === otherAlert)
|
||||
await withCheckedContinuation { continuation in
|
||||
otherAlert.dismiss(animated: false) { continuation.resume() }
|
||||
}
|
||||
|
||||
let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
|
||||
_ = try await waitForConfirmation(controller)
|
||||
controller.open(URL(string: "https://other.invalid/")!)
|
||||
_ = try await pendingClick.value
|
||||
XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation")
|
||||
let oldVisits = try await webview.evaluateJavaScript("window.visits")
|
||||
XCTAssertEqual(oldVisits as? Int, expectedVisits)
|
||||
let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')")
|
||||
XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations")
|
||||
XCTAssertNil(controller.presentedViewController)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController {
|
||||
for _ in 0..<100 {
|
||||
if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert }
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
return try XCTUnwrap(controller.presentedViewController as? UIAlertController,
|
||||
"Dirty forms must offer a visible confirmation instead of silently cancelling navigation")
|
||||
}
|
||||
|
||||
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
|
||||
for body in [
|
||||
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
|
||||
remoteInfo = "kanbangit-native_iOS";
|
||||
};
|
||||
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||
@@ -48,7 +55,6 @@
|
||||
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
||||
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
||||
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
|
||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
@@ -67,6 +73,7 @@
|
||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
|
||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
|
||||
@@ -189,7 +196,7 @@
|
||||
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
|
||||
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */,
|
||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
|
||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
|
||||
);
|
||||
@@ -248,7 +255,7 @@
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "kanbangit-native_iOS";
|
||||
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
|
||||
productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
|
||||
@@ -279,6 +286,7 @@
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = WorkspaceTests;
|
||||
packageProductDependencies = (
|
||||
@@ -418,6 +426,11 @@
|
||||
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
|
||||
};
|
||||
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||
targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
@@ -451,7 +464,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -467,7 +480,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.6;
|
||||
MARKETING_VERSION = 0.2.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
@@ -628,7 +641,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -644,7 +657,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.6;
|
||||
MARKETING_VERSION = 0.2.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
@@ -691,6 +704,7 @@
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
|
||||
};
|
||||
name = debug;
|
||||
};
|
||||
@@ -710,6 +724,7 @@
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
|
||||
};
|
||||
name = release;
|
||||
};
|
||||
|
||||
@@ -18,8 +18,8 @@ settingGroups:
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||
MARKETING_VERSION: "0.2.6"
|
||||
CURRENT_PROJECT_VERSION: "6"
|
||||
MARKETING_VERSION: "0.2.8"
|
||||
CURRENT_PROJECT_VERSION: "7"
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_IDENTITY: Apple Development
|
||||
@@ -40,6 +40,7 @@ targets:
|
||||
kanbangit-native_iOS:
|
||||
type: application
|
||||
platform: iOS
|
||||
productName: KanbanGit
|
||||
sources:
|
||||
- path: Sources
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
@@ -115,6 +116,8 @@ targets:
|
||||
WorkspaceTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
dependencies:
|
||||
- target: kanbangit-native_iOS
|
||||
sources:
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: ../../../ui/workspace-settings.js
|
||||
@@ -126,6 +129,7 @@ targets:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
|
||||
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
CODE_SIGN_STYLE: Manual
|
||||
SWIFT_VERSION: "5.0"
|
||||
|
||||
@@ -1,6 +1,55 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
@interface KanbanWorkspaceUIDelegate : NSObject <WKUIDelegate>
|
||||
@property(nonatomic, strong) id<WKUIDelegate> originalDelegate;
|
||||
@end
|
||||
|
||||
@implementation KanbanWorkspaceUIDelegate
|
||||
- (BOOL)respondsToSelector:(SEL)selector {
|
||||
return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector];
|
||||
}
|
||||
|
||||
- (id)forwardingTargetForSelector:(SEL)selector {
|
||||
if ([self.originalDelegate respondsToSelector:selector]) {
|
||||
return self.originalDelegate;
|
||||
}
|
||||
return [super forwardingTargetForSelector:selector];
|
||||
}
|
||||
|
||||
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message
|
||||
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
|
||||
NSWindow *window = webView.window;
|
||||
if (!window || window.attachedSheet) {
|
||||
completionHandler(NO);
|
||||
return;
|
||||
}
|
||||
|
||||
NSAlert *alert = [NSAlert new];
|
||||
alert.alertStyle = NSAlertStyleWarning;
|
||||
alert.messageText = frame.request.URL.host ?: @"KanbanGit";
|
||||
alert.informativeText = message;
|
||||
[alert addButtonWithTitle:@"OK"];
|
||||
[alert addButtonWithTitle:@"Cancel"];
|
||||
[alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
|
||||
completionHandler(response == NSAlertFirstButtonReturn);
|
||||
}];
|
||||
}
|
||||
@end
|
||||
|
||||
static char workspaceUIDelegateKey;
|
||||
|
||||
void install_workspace_navigation(void *pointer) {
|
||||
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||
webview.allowsBackForwardNavigationGestures = YES;
|
||||
if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) {
|
||||
return;
|
||||
}
|
||||
|
||||
KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new];
|
||||
delegate.originalDelegate = webview.UIDelegate;
|
||||
// WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view.
|
||||
objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
webview.UIDelegate = delegate;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KanbanGit",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.8",
|
||||
"identifier": "de.kanbangit.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "14.0",
|
||||
"bundleVersion": "11",
|
||||
"bundleVersion": "12",
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "17.0",
|
||||
"developmentTeam": "ZZX2KZGTHU",
|
||||
"bundleVersion": "6"
|
||||
"bundleVersion": "7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,53 @@ static void waitUntil(BOOL (^condition)(void), NSString *message) {
|
||||
check(condition(), message);
|
||||
}
|
||||
|
||||
static id evaluate(WKWebView *webview, NSString *script) {
|
||||
__block BOOL finished = NO;
|
||||
__block id result;
|
||||
[webview evaluateJavaScript:script completionHandler:^(id value, NSError *error) {
|
||||
check(error == nil, error.localizedDescription);
|
||||
result = value;
|
||||
finished = YES;
|
||||
}];
|
||||
waitUntil(^BOOL { return finished; }, @"JavaScript must finish after the confirmation is dismissed");
|
||||
return result;
|
||||
}
|
||||
|
||||
static NSButton *findButton(NSView *view, NSString *title) {
|
||||
if ([view isKindOfClass:NSButton.class] && [((NSButton *)view).title isEqualToString:title]) {
|
||||
return (NSButton *)view;
|
||||
}
|
||||
for (NSView *child in view.subviews) {
|
||||
NSButton *button = findButton(child, title);
|
||||
if (button) { return button; }
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static BOOL containsText(NSView *view, NSString *text) {
|
||||
if ([view isKindOfClass:NSTextField.class] && [((NSTextField *)view).stringValue isEqualToString:text]) {
|
||||
return YES;
|
||||
}
|
||||
for (NSView *child in view.subviews) {
|
||||
if (containsText(child, text)) { return YES; }
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
@interface WorkspaceUI : NSObject <WKUIDelegate>
|
||||
@property(nonatomic) BOOL closed;
|
||||
@property(nonatomic) BOOL uploaded;
|
||||
@end
|
||||
|
||||
@implementation WorkspaceUI
|
||||
- (void)webViewDidClose:(WKWebView *)webView { self.closed = YES; }
|
||||
- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters
|
||||
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray<NSURL *> *))completionHandler {
|
||||
self.uploaded = YES;
|
||||
completionHandler(nil);
|
||||
}
|
||||
@end
|
||||
|
||||
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
|
||||
@property(nonatomic) NSUInteger finished;
|
||||
@end
|
||||
@@ -46,9 +93,18 @@ int main(void) {
|
||||
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
|
||||
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
|
||||
webview.navigationDelegate = pages;
|
||||
WorkspaceUI *originalUI = [WorkspaceUI new];
|
||||
webview.UIDelegate = originalUI;
|
||||
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
|
||||
install_workspace_navigation((__bridge void *)webview);
|
||||
id<WKUIDelegate> installedUI = webview.UIDelegate;
|
||||
install_workspace_navigation((__bridge void *)webview);
|
||||
check(webview.UIDelegate == installedUI, @"Installing workspace behavior twice must retain the same delegate");
|
||||
check([installedUI respondsToSelector:@selector(webViewDidClose:)], @"Existing popup callbacks must remain available");
|
||||
[installedUI webViewDidClose:webview];
|
||||
check(originalUI.closed, @"Existing popup callbacks must reach the original delegate");
|
||||
check([installedUI respondsToSelector:@selector(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:)],
|
||||
@"Existing file upload callbacks must remain available");
|
||||
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
|
||||
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
|
||||
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
|
||||
@@ -69,6 +125,41 @@ int main(void) {
|
||||
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
|
||||
@"Forward must restore the second page");
|
||||
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
|
||||
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
|
||||
|
||||
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
|
||||
styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
|
||||
window.releasedWhenClosed = NO;
|
||||
window.title = @"KanbanGit navigation test";
|
||||
window.contentView = webview;
|
||||
[window orderFront:nil];
|
||||
evaluate(webview, @"document.body.innerHTML = `<input id='duplicate' type='radio'><input id='draft' value='Unsaved project'><input id='upload' type='file'><a id='back' href='#back'>Back</a>`; window.visits = 0; document.querySelector('#back').onclick = event => { event.preventDefault(); if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++; }; true;");
|
||||
evaluate(webview, @"document.querySelector('#upload').click()");
|
||||
waitUntil(^BOOL { return originalUI.uploaded; }, @"File selection must still reach the original UI delegate");
|
||||
evaluate(webview, @"document.querySelector('#back').click()");
|
||||
check([evaluate(webview, @"window.visits") intValue] == 1, @"Pristine forms must navigate without a confirmation");
|
||||
evaluate(webview, @"document.querySelector('#duplicate').click()");
|
||||
for (NSString *choice in @[@"Cancel", @"OK", @"Cancel", @"OK"]) {
|
||||
__block BOOL finished = NO;
|
||||
[webview evaluateJavaScript:@"document.querySelector('#back').click()" completionHandler:^(id result, NSError *error) {
|
||||
check(error == nil, error.localizedDescription);
|
||||
finished = YES;
|
||||
}];
|
||||
waitUntil(^BOOL { return window.attachedSheet != nil; }, @"Dirty form navigation must show a native confirmation");
|
||||
check(!finished, @"The original click must wait for the user's decision");
|
||||
check(containsText(window.attachedSheet.contentView, @"workspace"), @"The confirmation must identify the requesting website");
|
||||
check(containsText(window.attachedSheet.contentView, @"Discard unsaved changes?"), @"The confirmation must show the application's message");
|
||||
NSButton *button = findButton(window.attachedSheet.contentView, choice);
|
||||
check(button != nil, @"The native confirmation must offer OK and Cancel");
|
||||
[button performClick:nil];
|
||||
waitUntil(^BOOL { return finished && window.attachedSheet == nil; }, @"Dismissal must release the original navigation");
|
||||
check([evaluate(webview, @"document.querySelector('#draft').value") isEqualToString:@"Unsaved project"],
|
||||
@"Cancelling a confirmation must preserve the draft");
|
||||
}
|
||||
check([evaluate(webview, @"window.visits") intValue] == 3, @"Only accepted visits must run, exactly once each");
|
||||
window.contentView = nil;
|
||||
check(![evaluate(webview, @"confirm('Detached view')") boolValue], @"A detached view must cancel and release JavaScript immediately");
|
||||
[window orderOut:nil];
|
||||
[window close];
|
||||
puts("PASS: WebKit interactive gesture configuration, full-page back/forward, native confirmations and delegate forwarding");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
test(
|
||||
'WebKit owns interactive navigation and preserves native back/forward history',
|
||||
'WebKit preserves history and confirms dirty form navigation without losing delegate callbacks',
|
||||
{ skip: process.platform !== 'darwin' },
|
||||
() => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
||||
@@ -48,7 +48,7 @@ test(
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
result.stderr || result.error?.message,
|
||||
result.stderr || result.error?.message || result.signal,
|
||||
);
|
||||
assert.match(result.stdout, /PASS: WebKit/);
|
||||
} finally {
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.1",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vue": "^3.5.13",
|
||||
"vue": "^3.5.43",
|
||||
"vue-input-otp": "^0.3.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
Generated
+142
-124
@@ -204,12 +204,15 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@vue/server-renderer@<3.5.42': ^3.5.42
|
||||
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
|
||||
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
|
||||
|
||||
importers:
|
||||
|
||||
@@ -220,7 +223,7 @@ importers:
|
||||
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@inertiajs/vue3':
|
||||
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.43(typescript@5.9.3))
|
||||
'@tiptap/core':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3(@tiptap/pm@3.31.3)
|
||||
@@ -235,7 +238,7 @@ importers:
|
||||
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))
|
||||
version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.43(typescript@5.9.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^12.8.2
|
||||
version: 12.8.2(typescript@5.9.3)
|
||||
@@ -250,10 +253,10 @@ importers:
|
||||
version: 3.0.0(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))
|
||||
lucide-vue-next:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0(vue@3.5.30(typescript@5.9.3))
|
||||
version: 0.468.0(vue@3.5.43(typescript@5.9.3))
|
||||
reka-ui:
|
||||
specifier: ^2.6.1
|
||||
version: 2.9.2(vue@3.5.30(typescript@5.9.3))
|
||||
version: 2.9.2(vue@3.5.43(typescript@5.9.3))
|
||||
tailwind-merge:
|
||||
specifier: ^3.2.0
|
||||
version: 3.5.0
|
||||
@@ -264,11 +267,11 @@ importers:
|
||||
specifier: ^1.2.5
|
||||
version: 1.4.0
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.30(typescript@5.9.3)
|
||||
specifier: ^3.5.43
|
||||
version: 3.5.43(typescript@5.9.3)
|
||||
vue-input-otp:
|
||||
specifier: ^0.3.2
|
||||
version: 0.3.2(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
|
||||
version: 0.3.2(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.4
|
||||
@@ -293,7 +296,7 @@ importers:
|
||||
version: 8.58.1(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.30(typescript@5.9.3))
|
||||
version: 6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.43(typescript@5.9.3))
|
||||
'@vue/eslint-config-typescript':
|
||||
specifier: ^14.3.0
|
||||
version: 14.7.0(eslint-plugin-vue@9.33.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1))(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
|
||||
@@ -370,21 +373,21 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
||||
'@babel/parser@7.29.9':
|
||||
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
'@babel/types@7.29.8':
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@emnapi/core@1.9.1':
|
||||
@@ -1109,17 +1112,17 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@vue/compiler-core@3.5.30':
|
||||
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
|
||||
'@vue/compiler-core@3.5.43':
|
||||
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
|
||||
|
||||
'@vue/compiler-dom@3.5.30':
|
||||
resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
|
||||
'@vue/compiler-dom@3.5.43':
|
||||
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.30':
|
||||
resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
|
||||
'@vue/compiler-sfc@3.5.43':
|
||||
resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.30':
|
||||
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
|
||||
'@vue/compiler-ssr@3.5.43':
|
||||
resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==}
|
||||
|
||||
'@vue/compiler-vue2@2.7.16':
|
||||
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
|
||||
@@ -1143,22 +1146,20 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@vue/reactivity@3.5.30':
|
||||
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
|
||||
'@vue/reactivity@3.5.43':
|
||||
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
|
||||
|
||||
'@vue/runtime-core@3.5.30':
|
||||
resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
|
||||
'@vue/runtime-core@3.5.43':
|
||||
resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
|
||||
|
||||
'@vue/runtime-dom@3.5.30':
|
||||
resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
|
||||
'@vue/runtime-dom@3.5.43':
|
||||
resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
|
||||
|
||||
'@vue/server-renderer@3.5.30':
|
||||
resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
|
||||
peerDependencies:
|
||||
vue: 3.5.30
|
||||
'@vue/server-renderer@3.5.43':
|
||||
resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
|
||||
|
||||
'@vue/shared@3.5.30':
|
||||
resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
|
||||
'@vue/shared@3.5.43':
|
||||
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
|
||||
|
||||
'@vueuse/core@12.8.2':
|
||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||
@@ -2166,6 +2167,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.20:
|
||||
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napi-postinstall@0.3.4:
|
||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -2264,6 +2270,10 @@ packages:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.29:
|
||||
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2480,8 +2490,8 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shell-quote@1.9.0:
|
||||
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
|
||||
shell-quote@1.12.0:
|
||||
resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
@@ -2500,8 +2510,8 @@ packages:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stable-hash-x@0.2.0:
|
||||
@@ -2728,8 +2738,8 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
vue@3.5.30:
|
||||
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
|
||||
vue@3.5.43:
|
||||
resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
@@ -2790,18 +2800,18 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
'@babel/parser@7.29.9':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
'@babel/types@7.29.8':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@emnapi/core@1.9.1':
|
||||
dependencies:
|
||||
@@ -2876,11 +2886,11 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.11': {}
|
||||
|
||||
'@floating-ui/vue@1.1.11(vue@3.5.30(typescript@5.9.3))':
|
||||
'@floating-ui/vue@1.1.11(vue@3.5.43(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@floating-ui/utils': 0.2.11
|
||||
vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3))
|
||||
vue-demi: 0.14.10(vue@3.5.43(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
@@ -2912,13 +2922,13 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@inertiajs/vue3@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))':
|
||||
'@inertiajs/vue3@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.43(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@inertiajs/core': 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
'@types/lodash-es': 4.17.12
|
||||
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
lodash-es: 4.18.1
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
@@ -3053,7 +3063,7 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
tailwindcss: 4.2.2
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.2.2':
|
||||
@@ -3116,10 +3126,10 @@ snapshots:
|
||||
|
||||
'@tanstack/virtual-core@3.13.23': {}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.23(vue@3.5.30(typescript@5.9.3))':
|
||||
'@tanstack/vue-virtual@3.13.23(vue@3.5.43(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.23
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
|
||||
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
@@ -3283,12 +3293,12 @@ snapshots:
|
||||
'@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))':
|
||||
'@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.43(typescript@5.9.3))':
|
||||
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)
|
||||
vue: 3.5.43(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)
|
||||
@@ -3466,11 +3476,11 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@vitejs/plugin-vue@6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.30(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.43(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.2
|
||||
vite: 8.2.2(@types/node@22.19.15)(jiti@2.6.1)
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
|
||||
'@volar/language-core@2.4.15':
|
||||
dependencies:
|
||||
@@ -3486,35 +3496,35 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@vue/compiler-core@3.5.30':
|
||||
'@vue/compiler-core@3.5.43':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@vue/shared': 3.5.30
|
||||
'@babel/parser': 7.29.9
|
||||
'@vue/shared': 3.5.43
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
'@vue/compiler-dom@3.5.30':
|
||||
'@vue/compiler-dom@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
'@vue/compiler-core': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/compiler-sfc@3.5.30':
|
||||
'@vue/compiler-sfc@3.5.43':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@vue/compiler-core': 3.5.30
|
||||
'@vue/compiler-dom': 3.5.30
|
||||
'@vue/compiler-ssr': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
'@babel/parser': 7.29.9
|
||||
'@vue/compiler-core': 3.5.43
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/compiler-ssr': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.26
|
||||
source-map-js: 1.2.1
|
||||
postcss: 8.5.29
|
||||
source-map-js: 1.2.2
|
||||
|
||||
'@vue/compiler-ssr@3.5.30':
|
||||
'@vue/compiler-ssr@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/compiler-vue2@2.7.16':
|
||||
dependencies:
|
||||
@@ -3537,9 +3547,9 @@ snapshots:
|
||||
'@vue/language-core@2.2.12(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.15
|
||||
'@vue/compiler-dom': 3.5.30
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/compiler-vue2': 2.7.16
|
||||
'@vue/shared': 3.5.30
|
||||
'@vue/shared': 3.5.43
|
||||
alien-signals: 1.0.13
|
||||
minimatch: 9.0.9
|
||||
muggle-string: 0.4.1
|
||||
@@ -3547,45 +3557,45 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@vue/reactivity@3.5.30':
|
||||
'@vue/reactivity@3.5.43':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.30
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/runtime-core@3.5.30':
|
||||
'@vue/runtime-core@3.5.43':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
'@vue/reactivity': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/runtime-dom@3.5.30':
|
||||
'@vue/runtime-dom@3.5.43':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.30
|
||||
'@vue/runtime-core': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
'@vue/reactivity': 3.5.43
|
||||
'@vue/runtime-core': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))':
|
||||
'@vue/server-renderer@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
'@vue/compiler-ssr': 3.5.43
|
||||
'@vue/runtime-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/shared@3.5.30': {}
|
||||
'@vue/shared@3.5.43': {}
|
||||
|
||||
'@vueuse/core@12.8.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 12.8.2
|
||||
'@vueuse/shared': 12.8.2(typescript@5.9.3)
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/core@14.2.1(vue@3.5.30(typescript@5.9.3))':
|
||||
'@vueuse/core@14.2.1(vue@3.5.43(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 14.2.1
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.30(typescript@5.9.3))
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.43(typescript@5.9.3))
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
|
||||
'@vueuse/metadata@12.8.2': {}
|
||||
|
||||
@@ -3593,13 +3603,13 @@ snapshots:
|
||||
|
||||
'@vueuse/shared@12.8.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/shared@14.2.1(vue@3.5.30(typescript@5.9.3))':
|
||||
'@vueuse/shared@14.2.1(vue@3.5.43(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||
dependencies:
|
||||
@@ -3777,7 +3787,7 @@ snapshots:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
rxjs: 7.8.2
|
||||
shell-quote: 1.9.0
|
||||
shell-quote: 1.12.0
|
||||
supports-color: 8.1.1
|
||||
tree-kill: 1.2.2
|
||||
yargs: 17.7.2
|
||||
@@ -4588,9 +4598,9 @@ snapshots:
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
lucide-vue-next@0.468.0(vue@3.5.30(typescript@5.9.3)):
|
||||
lucide-vue-next@0.468.0(vue@3.5.43(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
@@ -4633,6 +4643,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
nanoid@3.3.20: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -4730,7 +4742,13 @@ snapshots:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
postcss@8.5.29:
|
||||
dependencies:
|
||||
nanoid: 3.3.20
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
@@ -4849,19 +4867,19 @@ snapshots:
|
||||
gopd: 1.2.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
reka-ui@2.9.2(vue@3.5.30(typescript@5.9.3)):
|
||||
reka-ui@2.9.2(vue@3.5.43(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@floating-ui/vue': 1.1.11(vue@3.5.30(typescript@5.9.3))
|
||||
'@floating-ui/vue': 1.1.11(vue@3.5.43(typescript@5.9.3))
|
||||
'@internationalized/date': 3.12.0
|
||||
'@internationalized/number': 3.6.5
|
||||
'@tanstack/vue-virtual': 3.13.23(vue@3.5.30(typescript@5.9.3))
|
||||
'@vueuse/core': 14.2.1(vue@3.5.30(typescript@5.9.3))
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.30(typescript@5.9.3))
|
||||
'@tanstack/vue-virtual': 3.13.23(vue@3.5.43(typescript@5.9.3))
|
||||
'@vueuse/core': 14.2.1(vue@3.5.43(typescript@5.9.3))
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.43(typescript@5.9.3))
|
||||
aria-hidden: 1.2.6
|
||||
defu: 6.1.7
|
||||
ohash: 2.0.11
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
@@ -4961,7 +4979,7 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.9.0: {}
|
||||
shell-quote@1.12.0: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
@@ -4991,7 +5009,7 @@ snapshots:
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
stable-hash-x@0.2.0: {}
|
||||
|
||||
@@ -5193,9 +5211,9 @@ snapshots:
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)):
|
||||
vue-demi@0.14.10(vue@3.5.43(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
|
||||
vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
dependencies:
|
||||
@@ -5222,11 +5240,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-input-otp@0.3.2(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)):
|
||||
vue-input-otp@0.3.2(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vueuse/core': 12.8.2(typescript@5.9.3)
|
||||
reka-ui: 2.9.2(vue@3.5.30(typescript@5.9.3))
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
reka-ui: 2.9.2(vue@3.5.43(typescript@5.9.3))
|
||||
vue: 3.5.43(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- typescript
|
||||
@@ -5237,13 +5255,13 @@ snapshots:
|
||||
'@vue/language-core': 2.2.12(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
vue@3.5.30(typescript@5.9.3):
|
||||
vue@3.5.43(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.30
|
||||
'@vue/compiler-sfc': 3.5.30
|
||||
'@vue/runtime-dom': 3.5.30
|
||||
'@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3))
|
||||
'@vue/shared': 3.5.30
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/compiler-sfc': 3.5.43
|
||||
'@vue/runtime-dom': 3.5.43
|
||||
'@vue/server-renderer': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
|
||||
@@ -8,9 +8,15 @@ allowBuilds:
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
overrides:
|
||||
'@vue/server-renderer@<3.5.42': ^3.5.42
|
||||
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
|
||||
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
|
||||
audit:
|
||||
ignore:
|
||||
- GHSA-vfj7-8cjw-p6xm
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { Layers3, LockKeyhole } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -12,7 +11,13 @@ import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
|
||||
import { index, update } from '@/routes/project-templates';
|
||||
import {
|
||||
index,
|
||||
update,
|
||||
store as storeStandalone,
|
||||
} from '@/routes/project-templates';
|
||||
import TemplateConfigurationEditor from '@/components/projects/TemplateConfigurationEditor.vue';
|
||||
import { createTemplateConfiguration } from '@/lib/project-template-configuration';
|
||||
import { show } from '@/routes/projects';
|
||||
import { store } from '@/routes/projects/templates';
|
||||
import type { ProjectDuplicateCopyAreaOption } from '@/types';
|
||||
@@ -22,11 +27,18 @@ import type { ProjectTemplateModel } from '@/types/models/project-template-model
|
||||
|
||||
const props = defineProps<{
|
||||
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||
project?: ProjectModel;
|
||||
project?: ProjectModel | null;
|
||||
initialConfiguration?: Record<string, unknown>;
|
||||
template?: ProjectTemplateModel;
|
||||
}>();
|
||||
const { t } = useLocalization();
|
||||
const { areaText } = useProjectTemplateAreas();
|
||||
const publishingBase = computed(() =>
|
||||
props.project?.project_template &&
|
||||
!props.project.project_template.is_published
|
||||
? props.project.project_template
|
||||
: null,
|
||||
);
|
||||
const source = computed(() => props.template?.source_project ?? props.project);
|
||||
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
|
||||
const form = useForm(
|
||||
@@ -34,14 +46,23 @@ const form = useForm(
|
||||
{
|
||||
name:
|
||||
props.template?.name ??
|
||||
t('templates.defaultName', { project: props.project?.name ?? '' }),
|
||||
(props.project
|
||||
? t('templates.defaultName', { project: props.project.name })
|
||||
: ''),
|
||||
slug:
|
||||
props.template?.slug ??
|
||||
`${props.project?.key.toLowerCase()}-template`,
|
||||
publishingBase.value?.slug ??
|
||||
(props.project
|
||||
? `${props.project.key.toLowerCase()}-template`
|
||||
: ''),
|
||||
copy_areas: props.template
|
||||
? [...props.template.copy_areas]
|
||||
: props.copyAreaOptions.map((area) => area.value),
|
||||
confirm_linked_projects_update: false,
|
||||
configuration_snapshot: createTemplateConfiguration(
|
||||
props.template?.configuration_snapshot ??
|
||||
props.initialConfiguration,
|
||||
),
|
||||
},
|
||||
);
|
||||
const protection = useFormProtection(form);
|
||||
@@ -53,13 +74,10 @@ const copyAreasError = computed(
|
||||
)?.[1],
|
||||
);
|
||||
const cancelTarget = computed(() =>
|
||||
props.template ? index() : show(props.project!.id),
|
||||
props.project ? show(props.project.id) : index(),
|
||||
);
|
||||
function submit(): void {
|
||||
if (
|
||||
!source.value ||
|
||||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
|
||||
) {
|
||||
if (linkedCount.value > 0 && !form.confirm_linked_projects_update) {
|
||||
return;
|
||||
}
|
||||
form.transform((data) =>
|
||||
@@ -67,6 +85,7 @@ function submit(): void {
|
||||
? {
|
||||
name: data.name,
|
||||
copy_areas: data.copy_areas,
|
||||
configuration_snapshot: data.configuration_snapshot,
|
||||
...(linkedCount.value > 0
|
||||
? {
|
||||
confirm_linked_projects_update:
|
||||
@@ -74,10 +93,21 @@ function submit(): void {
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
|
||||
: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
copy_areas: data.copy_areas,
|
||||
...(!props.project
|
||||
? { configuration_snapshot: data.configuration_snapshot }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
protection.submit(
|
||||
props.template ? update(props.template.id) : store(props.project!.id),
|
||||
props.template
|
||||
? update(props.template.id)
|
||||
: props.project
|
||||
? store(props.project.id)
|
||||
: storeStandalone(),
|
||||
{ preserveScroll: true },
|
||||
);
|
||||
}
|
||||
@@ -90,12 +120,6 @@ function submit(): void {
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<FeedbackState
|
||||
v-if="!source"
|
||||
state="error"
|
||||
:title="t('templates.missingSource')"
|
||||
:description="t('templates.missingSourceHelp')"
|
||||
/>
|
||||
<div
|
||||
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
|
||||
>
|
||||
@@ -115,7 +139,7 @@ function submit(): void {
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:disabled="form.processing || !source"
|
||||
:disabled="form.processing"
|
||||
:placeholder="t('templates.namePlaceholder')"
|
||||
:invalid="!!form.errors.name"
|
||||
aria-describedby="name-error"
|
||||
@@ -139,7 +163,11 @@ function submit(): void {
|
||||
name="slug"
|
||||
required
|
||||
maxlength="255"
|
||||
:disabled="!!template || form.processing || !source"
|
||||
:disabled="
|
||||
!!template ||
|
||||
!!publishingBase ||
|
||||
form.processing
|
||||
"
|
||||
:placeholder="t('templates.slugPlaceholder')"
|
||||
:invalid="!!form.errors.slug"
|
||||
aria-describedby="slug-help slug-error"
|
||||
@@ -152,7 +180,7 @@ function submit(): void {
|
||||
>
|
||||
{{
|
||||
t(
|
||||
template
|
||||
template || publishingBase
|
||||
? 'templates.slugLocked'
|
||||
: 'templates.slugHelp',
|
||||
)
|
||||
@@ -166,7 +194,7 @@ function submit(): void {
|
||||
</section>
|
||||
<fieldset
|
||||
class="bg-card min-w-0 rounded-md border p-5"
|
||||
:disabled="form.processing || !source"
|
||||
:disabled="form.processing"
|
||||
aria-describedby="areas-help copy-areas-error"
|
||||
>
|
||||
<legend class="text-title px-1">
|
||||
@@ -231,6 +259,14 @@ function submit(): void {
|
||||
:message="copyAreasError"
|
||||
/>
|
||||
</fieldset>
|
||||
<TemplateConfigurationEditor
|
||||
v-if="template || !project"
|
||||
v-model="form.configuration_snapshot"
|
||||
:areas="form.copy_areas"
|
||||
:options="copyAreaOptions"
|
||||
:errors="form.errors"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
</div>
|
||||
<aside
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
@@ -310,7 +346,7 @@ function submit(): void {
|
||||
id="confirm-linked"
|
||||
v-model="form.confirm_linked_projects_update"
|
||||
type="checkbox"
|
||||
:disabled="form.processing || !source"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="!!form.errors.confirm_linked_projects_update"
|
||||
aria-describedby="linked-help linked-error"
|
||||
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||
@@ -335,8 +371,7 @@ function submit(): void {
|
||||
:loading="form.processing"
|
||||
:loading-label="t('forms.saving')"
|
||||
:disabled="
|
||||
!source ||
|
||||
(linkedCount > 0 && !form.confirm_linked_projects_update)
|
||||
linkedCount > 0 && !form.confirm_linked_projects_update
|
||||
"
|
||||
>{{
|
||||
t(template ? 'templates.saveChanges' : 'templates.saveNew')
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { notificationLabel } from '@/lib/notification-labels';
|
||||
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
|
||||
import type {
|
||||
TemplateConfiguration,
|
||||
TemplateConfigurationEditorProps,
|
||||
TemplateConfigurationItem,
|
||||
TemplateListArea,
|
||||
TemplateTextField,
|
||||
TemplateNotificationFilter,
|
||||
} from '@/types/project-template-configuration';
|
||||
|
||||
const props = defineProps<TemplateConfigurationEditorProps>();
|
||||
const configuration = defineModel<TemplateConfiguration>({ required: true });
|
||||
const { t } = useLocalization();
|
||||
const { areaText } = useProjectTemplateAreas();
|
||||
const listAreas = computed(
|
||||
() =>
|
||||
props.options
|
||||
.map((option) => option.value)
|
||||
.filter(
|
||||
(area) =>
|
||||
props.areas.includes(area) &&
|
||||
area !== 'project_settings' &&
|
||||
area !== 'status_transitions',
|
||||
) as TemplateListArea[],
|
||||
);
|
||||
const selectClass =
|
||||
'border-input bg-background min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm';
|
||||
const filterAttributes = [
|
||||
'status.from.id',
|
||||
'status.to.id',
|
||||
'status.from.label',
|
||||
'status.to.label',
|
||||
'project.id',
|
||||
'project.key',
|
||||
'project.name',
|
||||
'ticket.id',
|
||||
'ticket.key',
|
||||
'ticket.title',
|
||||
'ticket.url',
|
||||
'actor.id',
|
||||
'actor.name',
|
||||
'assignee.from.id',
|
||||
'assignee.from.name',
|
||||
'assignee.to.id',
|
||||
'assignee.to.name',
|
||||
];
|
||||
const filterOperators = ['equals', 'not_equals', 'exists', 'not_exists', 'in'];
|
||||
|
||||
function fields(area: TemplateListArea): TemplateTextField[] {
|
||||
if (area === 'ticket_fields') {
|
||||
return [{ key: 'label', label: t('templateEditor.label') }];
|
||||
}
|
||||
if (area === 'ticket_link_types') {
|
||||
return [
|
||||
{ key: 'label', label: t('templateEditor.label') },
|
||||
{
|
||||
key: 'counterpart_label',
|
||||
label: t('templateEditor.counterpart'),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (area === 'repository_connections') {
|
||||
return [
|
||||
{ key: 'base_url', label: t('templateEditor.server') },
|
||||
{
|
||||
key: 'provider_repository_id',
|
||||
label: t('templateEditor.repositoryId'),
|
||||
},
|
||||
{ key: 'owner_name', label: t('templateEditor.owner') },
|
||||
{
|
||||
key: 'repository_name',
|
||||
label: t('templateEditor.repositoryName'),
|
||||
},
|
||||
{
|
||||
key: 'repository_full_name',
|
||||
label: t('templateEditor.fullName'),
|
||||
},
|
||||
{ key: 'repository_url', label: t('templateEditor.repositoryUrl') },
|
||||
];
|
||||
}
|
||||
return [{ key: 'name', label: t('templateEditor.name') }];
|
||||
}
|
||||
function reindex(area: TemplateListArea): void {
|
||||
const counters = new Map<string, number>();
|
||||
configuration.value[area].forEach((item, index) => {
|
||||
if (
|
||||
area === 'repository_connections' ||
|
||||
area === 'notification_actions'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const group = item.board_column_key ?? '';
|
||||
counters.set(group, (counters.get(group) ?? 0) + 1);
|
||||
item.position =
|
||||
area === 'ticket_statuses' ? counters.get(group) : index + 1;
|
||||
});
|
||||
}
|
||||
function add(area: TemplateListArea): void {
|
||||
const item: TemplateConfigurationItem = {
|
||||
configuration_key: crypto.randomUUID(),
|
||||
};
|
||||
if (area === 'ticket_fields') {
|
||||
Object.assign(item, { label: '', field_type: 'number' });
|
||||
} else if (area === 'ticket_link_types') {
|
||||
Object.assign(item, { label: '', counterpart_label: '' });
|
||||
} else if (area === 'repository_connections') {
|
||||
Object.assign(item, {
|
||||
provider: 'gitea',
|
||||
base_url: '',
|
||||
provider_repository_id: '',
|
||||
owner_name: '',
|
||||
repository_name: '',
|
||||
repository_full_name: '',
|
||||
repository_url: '',
|
||||
});
|
||||
} else {
|
||||
Object.assign(item, { name: '' });
|
||||
}
|
||||
if (area === 'ticket_statuses') {
|
||||
item.board_column_key = null;
|
||||
configuration.value.status_transitions.push({
|
||||
status_key: item.configuration_key,
|
||||
target_keys: [],
|
||||
});
|
||||
}
|
||||
if (area === 'ticket_types') {
|
||||
item.is_parent = false;
|
||||
}
|
||||
if (area === 'notification_actions') {
|
||||
Object.assign(item, {
|
||||
enabled: true,
|
||||
event_type: 'ticket_status_changed',
|
||||
channel: 'webhook',
|
||||
delivery_url: '',
|
||||
message_template: '',
|
||||
channel_configuration: { allowed_mentions: [] },
|
||||
filters: [],
|
||||
});
|
||||
}
|
||||
configuration.value[area].push(item);
|
||||
reindex(area);
|
||||
}
|
||||
function remove(area: TemplateListArea, index: number): void {
|
||||
const item = configuration.value[area][index];
|
||||
if (area === 'board_columns') {
|
||||
configuration.value.ticket_statuses.forEach((status) => {
|
||||
if (status.board_column_key === item.configuration_key) {
|
||||
status.board_column_key = null;
|
||||
}
|
||||
});
|
||||
reindex('ticket_statuses');
|
||||
}
|
||||
if (area === 'ticket_statuses') {
|
||||
configuration.value.status_transitions =
|
||||
configuration.value.status_transitions.filter(
|
||||
(transition) =>
|
||||
transition.status_key !== item.configuration_key,
|
||||
);
|
||||
configuration.value.status_transitions.forEach((transition) => {
|
||||
transition.target_keys = transition.target_keys.filter(
|
||||
(key) => key !== item.configuration_key,
|
||||
);
|
||||
});
|
||||
}
|
||||
configuration.value[area].splice(index, 1);
|
||||
reindex(area);
|
||||
}
|
||||
function move(area: TemplateListArea, index: number, offset: number): void {
|
||||
const entries = configuration.value[area];
|
||||
const next = index + offset;
|
||||
if (next < 0 || next >= entries.length) {
|
||||
return;
|
||||
}
|
||||
[entries[index], entries[next]] = [entries[next], entries[index]];
|
||||
reindex(area);
|
||||
}
|
||||
function targets(statusKey: string): string[] {
|
||||
return (
|
||||
configuration.value.status_transitions.find(
|
||||
(transition) => transition.status_key === statusKey,
|
||||
)?.target_keys ?? []
|
||||
);
|
||||
}
|
||||
function toggleTarget(from: string, to: string, checked: boolean): void {
|
||||
let transition = configuration.value.status_transitions.find(
|
||||
(entry) => entry.status_key === from,
|
||||
);
|
||||
if (!transition) {
|
||||
transition = { status_key: from, target_keys: [] };
|
||||
configuration.value.status_transitions.push(transition);
|
||||
}
|
||||
transition.target_keys = checked
|
||||
? [...new Set([...transition.target_keys, to])]
|
||||
: transition.target_keys.filter((key) => key !== to);
|
||||
}
|
||||
function filterChanged(filter: TemplateNotificationFilter): void {
|
||||
if (['exists', 'not_exists'].includes(filter.operator)) {
|
||||
filter.value = null;
|
||||
} else if (filter.operator === 'in') {
|
||||
filter.value = Array.isArray(filter.value)
|
||||
? filter.value
|
||||
: filter.value
|
||||
? [filter.value]
|
||||
: [];
|
||||
} else if (Array.isArray(filter.value)) {
|
||||
filter.value = filter.value[0] ?? '';
|
||||
}
|
||||
}
|
||||
function mention(
|
||||
item: TemplateConfigurationItem,
|
||||
value: string,
|
||||
checked: boolean,
|
||||
): void {
|
||||
const settings = (item.channel_configuration ??= { allowed_mentions: [] });
|
||||
settings.allowed_mentions = checked
|
||||
? [...new Set([...(settings.allowed_mentions ?? []), value])]
|
||||
: (settings.allowed_mentions ?? []).filter((entry) => entry !== value);
|
||||
}
|
||||
function areaErrors(area: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
Object.entries(props.errors)
|
||||
.filter(
|
||||
([key]) =>
|
||||
key === `configuration_snapshot.${area}` ||
|
||||
key.startsWith(`configuration_snapshot.${area}.`),
|
||||
)
|
||||
.map(([, value]) => value)
|
||||
.filter((value): value is string => !!value),
|
||||
),
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="gap-column grid min-w-0" :disabled="disabled">
|
||||
<legend class="text-title mb-4">{{ t('templateEditor.title') }}</legend>
|
||||
<p class="text-muted-foreground text-caption">
|
||||
{{ t('templateEditor.help') }}
|
||||
</p>
|
||||
<details
|
||||
v-if="areas.includes('project_settings')"
|
||||
class="bg-card rounded-md border p-5"
|
||||
open
|
||||
>
|
||||
<summary class="cursor-pointer font-medium">
|
||||
{{ areaText('project_settings', 'label') }}
|
||||
</summary>
|
||||
<div class="mt-4 grid gap-4">
|
||||
<Label class="flex items-center gap-3"
|
||||
><Checkbox
|
||||
v-model="configuration.project_settings.show_author"
|
||||
/>{{ t('templateEditor.showAuthor') }}</Label
|
||||
>
|
||||
<Label class="flex items-center gap-3"
|
||||
><Checkbox
|
||||
v-model="
|
||||
configuration.project_settings.show_story_points
|
||||
"
|
||||
/>{{ t('templateEditor.showPoints') }}</Label
|
||||
>
|
||||
<InputError
|
||||
v-for="error in areaErrors('project_settings')"
|
||||
:key="error"
|
||||
:message="error"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
<details
|
||||
v-for="area in listAreas"
|
||||
:key="area"
|
||||
class="bg-card min-w-0 rounded-md border p-5"
|
||||
:open="
|
||||
['board_columns', 'ticket_statuses', 'ticket_types'].includes(
|
||||
area,
|
||||
)
|
||||
"
|
||||
>
|
||||
<summary class="cursor-pointer font-medium">
|
||||
{{ areaText(area, 'label') }} ({{ configuration[area].length }})
|
||||
</summary>
|
||||
<div class="mt-4 grid min-w-0 gap-4">
|
||||
<article
|
||||
v-for="(item, index) in configuration[area]"
|
||||
:key="item.configuration_key"
|
||||
class="grid min-w-0 gap-4 rounded-md border p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-caption font-medium">{{
|
||||
item.name ||
|
||||
item.label ||
|
||||
t('templateEditor.entry', { number: index + 1 })
|
||||
}}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:disabled="index === 0"
|
||||
:aria-label="t('templateEditor.moveUp')"
|
||||
@click="move(area, index, -1)"
|
||||
><ArrowUp
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:disabled="
|
||||
index === configuration[area].length - 1
|
||||
"
|
||||
:aria-label="t('templateEditor.moveDown')"
|
||||
@click="move(area, index, 1)"
|
||||
><ArrowDown
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="t('templateEditor.remove')"
|
||||
@click="remove(area, index)"
|
||||
><Trash2
|
||||
/></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="field in fields(area)"
|
||||
:key="field.key"
|
||||
class="grid min-w-0 gap-2"
|
||||
>
|
||||
<Label
|
||||
:for="`${item.configuration_key}-${field.key}`"
|
||||
>{{ field.label }}</Label
|
||||
>
|
||||
<Input
|
||||
:id="`${item.configuration_key}-${field.key}`"
|
||||
v-model="item[field.key]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="area === 'ticket_statuses'"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label :for="`${item.configuration_key}-column`">{{
|
||||
t('templateEditor.column')
|
||||
}}</Label>
|
||||
<select
|
||||
:id="`${item.configuration_key}-column`"
|
||||
v-model="item.board_column_key"
|
||||
:class="selectClass"
|
||||
@change="reindex('ticket_statuses')"
|
||||
>
|
||||
<option :value="null">
|
||||
{{ t('templateEditor.backlog') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="column in configuration.board_columns"
|
||||
:key="column.configuration_key"
|
||||
:value="column.configuration_key"
|
||||
>
|
||||
{{ column.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<Label
|
||||
v-if="area === 'ticket_types'"
|
||||
class="flex items-center gap-3"
|
||||
><Checkbox v-model="item.is_parent" />{{
|
||||
t('templateEditor.parentType')
|
||||
}}</Label
|
||||
>
|
||||
<div v-if="area === 'ticket_fields'" class="grid gap-2">
|
||||
<Label :for="`${item.configuration_key}-type`">{{
|
||||
t('templateEditor.fieldType')
|
||||
}}</Label>
|
||||
<select
|
||||
:id="`${item.configuration_key}-type`"
|
||||
v-model="item.field_type"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option value="number">
|
||||
{{ t('templateEditor.number') }}
|
||||
</option>
|
||||
<option value="user">
|
||||
{{ t('templateEditor.user') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="area === 'notification_actions'">
|
||||
<Label class="flex items-center gap-3"
|
||||
><Checkbox v-model="item.enabled" />{{
|
||||
t('templateEditor.enabled')
|
||||
}}</Label
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Label class="grid gap-2"
|
||||
>{{ t('templateEditor.event')
|
||||
}}<select
|
||||
v-model="item.event_type"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option
|
||||
v-for="event in [
|
||||
'ticket_status_changed',
|
||||
'ticket_create',
|
||||
'ticket_assignee_changed',
|
||||
]"
|
||||
:key="event"
|
||||
:value="event"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'event',
|
||||
event,
|
||||
event,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select></Label
|
||||
>
|
||||
<Label class="grid gap-2"
|
||||
>{{ t('templateEditor.channel')
|
||||
}}<select
|
||||
v-model="item.channel"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option value="webhook">
|
||||
{{ t('notifications.channel.webhook') }}
|
||||
</option>
|
||||
<option value="discord">
|
||||
{{ t('notifications.channel.discord') }}
|
||||
</option>
|
||||
</select></Label
|
||||
>
|
||||
<Label class="grid gap-2 sm:col-span-2"
|
||||
>{{ t('templateEditor.deliveryUrl')
|
||||
}}<Input
|
||||
v-model="item.delivery_url"
|
||||
required
|
||||
type="url"
|
||||
/></Label>
|
||||
<Label class="grid gap-2 sm:col-span-2"
|
||||
>{{ t('templateEditor.message')
|
||||
}}<textarea
|
||||
v-model="item.message_template"
|
||||
:class="selectClass"
|
||||
rows="4"
|
||||
:required="item.channel === 'discord'"
|
||||
maxlength="2000"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<fieldset
|
||||
v-if="item.channel === 'discord'"
|
||||
class="grid gap-3 rounded-md border p-3"
|
||||
>
|
||||
<legend>
|
||||
{{ t('templateEditor.allowedMentions') }}
|
||||
</legend>
|
||||
<Label
|
||||
v-for="value in ['everyone', 'roles', 'users']"
|
||||
:key="value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="
|
||||
item.channel_configuration?.allowed_mentions?.includes(
|
||||
value,
|
||||
) ?? false
|
||||
"
|
||||
@update:model-value="
|
||||
mention(item, value, $event === true)
|
||||
"
|
||||
/>{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'mention',
|
||||
value,
|
||||
value,
|
||||
)
|
||||
}}
|
||||
</Label>
|
||||
</fieldset>
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
v-for="(filter, filterIndex) in item.filters"
|
||||
:key="filterIndex"
|
||||
class="grid gap-3 rounded-md border p-3 sm:grid-cols-2"
|
||||
>
|
||||
<Label class="grid gap-2"
|
||||
>{{ t('notifications.attribute')
|
||||
}}<select
|
||||
v-model="filter.attribute"
|
||||
@change="
|
||||
filter.value =
|
||||
filter.operator === 'in'
|
||||
? []
|
||||
: null
|
||||
"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option
|
||||
v-for="attribute in filterAttributes"
|
||||
:key="attribute"
|
||||
:value="attribute"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'attribute',
|
||||
attribute,
|
||||
attribute,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select></Label
|
||||
>
|
||||
<Label class="grid gap-2"
|
||||
>{{ t('notifications.operator')
|
||||
}}<select
|
||||
v-model="filter.operator"
|
||||
@change="filterChanged(filter)"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option
|
||||
v-for="operator in filterOperators"
|
||||
:key="operator"
|
||||
:value="operator"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'operator',
|
||||
operator,
|
||||
operator,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select></Label
|
||||
>
|
||||
<Label
|
||||
v-if="
|
||||
!['exists', 'not_exists'].includes(
|
||||
filter.operator,
|
||||
)
|
||||
"
|
||||
class="grid gap-2"
|
||||
>{{ t('notifications.value')
|
||||
}}<Input
|
||||
:model-value="
|
||||
Array.isArray(filter.value)
|
||||
? filter.value.join(', ')
|
||||
: (filter.value ?? '')
|
||||
"
|
||||
@update:model-value="
|
||||
filter.value = String($event)
|
||||
"
|
||||
/></Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="
|
||||
item.filters?.splice(filterIndex, 1)
|
||||
"
|
||||
>{{
|
||||
t('notifications.removeFilter')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="
|
||||
(item.filters ??= []).push({
|
||||
attribute: 'status.to.label',
|
||||
operator: 'equals',
|
||||
value: '',
|
||||
})
|
||||
"
|
||||
><Plus />{{
|
||||
t('notifications.addFilter')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
<InputError
|
||||
v-for="error in areaErrors(area)"
|
||||
:key="error"
|
||||
:message="error"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="add(area)"
|
||||
><Plus />{{ t('templateEditor.addEntry') }}</Button
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
<details
|
||||
v-if="areas.includes('status_transitions')"
|
||||
class="bg-card rounded-md border p-5"
|
||||
open
|
||||
>
|
||||
<summary class="cursor-pointer font-medium">
|
||||
{{ areaText('status_transitions', 'label') }}
|
||||
</summary>
|
||||
<div class="mt-4 grid gap-4">
|
||||
<fieldset
|
||||
v-for="status in configuration.ticket_statuses"
|
||||
:key="status.configuration_key"
|
||||
class="grid gap-3 rounded-md border p-4"
|
||||
>
|
||||
<legend class="px-1 font-medium">{{ status.name }}</legend>
|
||||
<Label
|
||||
v-for="target in configuration.ticket_statuses.filter(
|
||||
(entry) =>
|
||||
entry.configuration_key !==
|
||||
status.configuration_key,
|
||||
)"
|
||||
:key="target.configuration_key"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="
|
||||
targets(status.configuration_key).includes(
|
||||
target.configuration_key,
|
||||
)
|
||||
"
|
||||
@update:model-value="
|
||||
toggleTarget(
|
||||
status.configuration_key,
|
||||
target.configuration_key,
|
||||
$event === true,
|
||||
)
|
||||
"
|
||||
/>{{ target.name }}
|
||||
</Label>
|
||||
</fieldset>
|
||||
<InputError
|
||||
v-for="error in areaErrors('status_transitions')"
|
||||
:key="error"
|
||||
:message="error"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</fieldset>
|
||||
</template>
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TemplateResetForm } from '@/types/project-template-configuration';
|
||||
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -27,10 +28,7 @@ const props = defineProps<{
|
||||
templateState?: ProjectTemplateState | null;
|
||||
}>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm<{
|
||||
areas: string[];
|
||||
status_replacements: Record<string, number | undefined>;
|
||||
}>({
|
||||
const form = useForm<TemplateResetForm>({
|
||||
areas: [],
|
||||
status_replacements: {},
|
||||
});
|
||||
@@ -245,6 +243,15 @@ function resetArea(): void {
|
||||
v-if="statusRequirement"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<p
|
||||
v-if="
|
||||
statusRequirement.replacementOptions
|
||||
.length === 0
|
||||
"
|
||||
class="text-destructive text-sm"
|
||||
>
|
||||
{{ t('inheritance.noReplacementTargets') }}
|
||||
</p>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('inheritance.statusHelp') }}
|
||||
</p>
|
||||
|
||||
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
|
||||
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>();
|
||||
defineEmits<{ editParent: [] }>();
|
||||
@@ -33,7 +37,7 @@ const canAddLink = computed(
|
||||
props.ticketLinkTypes.length > 0 &&
|
||||
props.ticketLinkOptions.length > 0,
|
||||
);
|
||||
const form = useForm({
|
||||
const form = useForm<TicketLinkFormData>({
|
||||
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||
link_direction: 'source',
|
||||
linked_ticket_id: '',
|
||||
@@ -41,10 +45,9 @@ const form = useForm({
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.canEditTicket && form.isDirty,
|
||||
});
|
||||
const selectedType = computed(() =>
|
||||
props.ticketLinkTypes.find(
|
||||
(type) => String(type.id) === String(form.ticket_link_type_id),
|
||||
),
|
||||
const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
|
||||
() => props.ticketLinkTypes,
|
||||
form,
|
||||
);
|
||||
const selectedTicket = computed(() =>
|
||||
props.ticketLinkOptions.find(
|
||||
@@ -221,40 +224,18 @@ function submit(): void {
|
||||
/>
|
||||
<fieldset
|
||||
: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
|
||||
id="ticket_link_type_id"
|
||||
v-model="form.ticket_link_type_id"
|
||||
v-model="selectedAction"
|
||||
kind="select"
|
||||
:label="t('ticketDetail.linkType')"
|
||||
:options="
|
||||
ticketLinkTypes.map((type) => ({
|
||||
value: type.id,
|
||||
label: `${type.label} / ${type.counterpart_label}`,
|
||||
}))
|
||||
:label="t('ticketDetail.linkAction')"
|
||||
:options="actions"
|
||||
:error="
|
||||
form.errors.ticket_link_type_id ||
|
||||
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"
|
||||
required
|
||||
/>
|
||||
@@ -281,26 +262,19 @@ function submit(): void {
|
||||
/>
|
||||
</fieldset>
|
||||
<p
|
||||
v-if="selectedType && selectedTicket"
|
||||
v-if="selectedActionLabel && selectedTicket"
|
||||
aria-live="polite"
|
||||
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
<span class="font-mono text-xs">{{
|
||||
ticket.ticket_key
|
||||
}}</span
|
||||
><span>{{
|
||||
form.link_direction === 'source'
|
||||
? selectedType.label
|
||||
: selectedType.counterpart_label
|
||||
}}</span
|
||||
><span>{{ selectedActionLabel }}</span
|
||||
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||
class="font-mono text-xs"
|
||||
>{{ selectedTicket.ticket_key }}</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ t('ticketRelationships.directionHelp') }}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing || !selectedTicket"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Unlink } from 'lucide-vue-next';
|
||||
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
@@ -20,7 +18,6 @@ const props = defineProps<{
|
||||
}>();
|
||||
const emit = defineEmits<{ removed: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
||||
const form = useForm({});
|
||||
const protection = useFormProtection(form);
|
||||
function remove(): void {
|
||||
@@ -47,27 +44,6 @@ function remove(): void {
|
||||
:data-ticket-link="link.id"
|
||||
>
|
||||
<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">{{
|
||||
ticketKey
|
||||
}}</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,21 @@
|
||||
import type { TemplateConfiguration } from '@/types/project-template-configuration';
|
||||
|
||||
export function createTemplateConfiguration(
|
||||
snapshot?: Record<string, unknown>,
|
||||
): TemplateConfiguration {
|
||||
return JSON.parse(
|
||||
JSON.stringify({
|
||||
project_settings: { show_author: true, show_story_points: true },
|
||||
board_columns: [],
|
||||
ticket_statuses: [],
|
||||
status_transitions: [],
|
||||
ticket_types: [],
|
||||
ticket_priorities: [],
|
||||
ticket_fields: [],
|
||||
ticket_link_types: [],
|
||||
repository_connections: [],
|
||||
notification_actions: [],
|
||||
...snapshot,
|
||||
}),
|
||||
) as TemplateConfiguration;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"format": "Markdown stored verbatim and supported by the Tiptap WYSIWYG editor and ticket detail view.",
|
||||
"supported": "Supported: plain text, paragraphs, line breaks, headings, bold, italic, strikethrough, ordinary ordered/unordered lists (including nested lists), blockquotes, inline/fenced/indented code, horizontal rules, and safe links.",
|
||||
"unsupported": "Not visually supported: task checkboxes, tables, HTML, images, or unsafe links. Newly supplied incompatible descriptions reject the entire mutation with a description field error; existing descriptions are preserved when description is omitted.",
|
||||
"criteria": "Put acceptance criteria exclusively in the structured acceptance_criteria field. Do not repeat them in description as task checkboxes or a copied criteria list. No criteria are automatically extracted or moved.",
|
||||
"links": "Safe link destinations: http://, https://, mailto:, a root-relative path starting with a single /, or a #fragment. Protocol-relative URLs, other schemes, whitespace, control characters, and backslashes (including percent-encoded forms) are not allowed.",
|
||||
"example": "## Goal\n\nExplain the **context** and approach.\n\n- Implement the change\n- Verify [the guide](https://example.com/guide)\n\n> Keep the scope clear.\n\nUse `code` when needed.",
|
||||
"unsupported_tokens": {
|
||||
"html": "HTML",
|
||||
"table": "tables",
|
||||
"image": "images",
|
||||
"task": "task checkboxes",
|
||||
"unsafe_link": "unsafe links"
|
||||
},
|
||||
"link_prefix": "^(?:https?://|mailto:|/(?!/)|#)",
|
||||
"unsafe_link_characters": "[\\s\\\\\\x00-\\x1f\\x7f]"
|
||||
}
|
||||
@@ -2,30 +2,40 @@ import type { JSONContent } from '@tiptap/core';
|
||||
import { Markdown, MarkdownManager } from '@tiptap/markdown';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { h } from 'vue';
|
||||
import descriptionFormat from './ticket-description-format.json';
|
||||
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,
|
||||
)
|
||||
new RegExp(descriptionFormat.unsafe_link_characters, 'u').test(value)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
new RegExp(descriptionFormat.unsafe_link_characters, 'u').test(
|
||||
decodeURIComponent(value),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (/^(https?:\/\/|mailto:)/i.test(value)) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:', 'mailto:'].includes(url.protocol)
|
||||
new URL(value);
|
||||
return new RegExp(descriptionFormat.link_prefix, 'i').test(value)
|
||||
? value
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return /^(\/(?!\/)|#)/.test(value) ? value : null;
|
||||
return new RegExp(descriptionFormat.link_prefix, 'i').test(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export function descriptionExtensions() {
|
||||
@@ -55,15 +65,21 @@ const markdown = new MarkdownManager({
|
||||
/** 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;
|
||||
}
|
||||
});
|
||||
markdown.instance.walkTokens(
|
||||
markdown.instance.lexer(value, { gfm: true, breaks: true }),
|
||||
(token) => {
|
||||
if (
|
||||
Object.hasOwn(
|
||||
descriptionFormat.unsupported_tokens,
|
||||
token.type,
|
||||
) ||
|
||||
(token.type === 'list_item' && token.task) ||
|
||||
(token.type === 'link' && !safeDescriptionLink(token.href))
|
||||
) {
|
||||
supported = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
return supported;
|
||||
}
|
||||
|
||||
|
||||
+43
-12
@@ -248,6 +248,38 @@ export const en = {
|
||||
'Previous assignee name when the event updates assignment; blank otherwise.',
|
||||
'notifications.variable.assignee_to':
|
||||
'New assignee name when the event updates assignment; blank when unassigned.',
|
||||
'templateEditor.title': 'Template configuration',
|
||||
'templateEditor.help':
|
||||
'Changes apply to linked projects that still inherit each group. Used entries remain protected as local overrides.',
|
||||
'templateEditor.name': 'Name',
|
||||
'templateEditor.label': 'Label',
|
||||
'templateEditor.counterpart': 'Reverse label',
|
||||
'templateEditor.server': 'Server URL',
|
||||
'templateEditor.repositoryId': 'Repository ID',
|
||||
'templateEditor.owner': 'Owner',
|
||||
'templateEditor.repositoryName': 'Repository name',
|
||||
'templateEditor.fullName': 'Owner / repository',
|
||||
'templateEditor.repositoryUrl': 'Repository URL',
|
||||
'templateEditor.entry': 'Entry {number}',
|
||||
'templateEditor.moveUp': 'Move up',
|
||||
'templateEditor.moveDown': 'Move down',
|
||||
'templateEditor.remove': 'Remove entry',
|
||||
'templateEditor.addEntry': 'Add entry',
|
||||
'templateEditor.showAuthor': 'Show ticket author',
|
||||
'templateEditor.showPoints': 'Show story points',
|
||||
'templateEditor.column': 'Board column',
|
||||
'templateEditor.backlog': 'Backlog (no board column)',
|
||||
'templateEditor.parentType': 'Allows child tickets',
|
||||
'templateEditor.fieldType': 'Field type',
|
||||
'templateEditor.number': 'Number',
|
||||
'templateEditor.user': 'User',
|
||||
'templateEditor.allowedMentions': 'Allowed Discord mentions',
|
||||
'templateEditor.enabled': 'Enabled',
|
||||
'templateEditor.event': 'Event',
|
||||
'templateEditor.channel': 'Channel',
|
||||
'templateEditor.deliveryUrl': 'Delivery URL',
|
||||
'templateEditor.message': 'Message template',
|
||||
'templates.createEmpty': 'New empty template',
|
||||
'templates.description':
|
||||
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
|
||||
'templates.updated': 'Project templates updated.',
|
||||
@@ -264,7 +296,7 @@ export const en = {
|
||||
other: '{count} linked projects',
|
||||
},
|
||||
'templates.savedFrom': 'Saved from {project} ({key}).',
|
||||
'templates.sourceUnavailable': 'Source project unavailable',
|
||||
'templates.sourceUnavailable': 'Independent template',
|
||||
'templates.edit': 'Edit',
|
||||
'templates.use': 'Use template',
|
||||
'templates.createTitle': 'Save {project} as a template',
|
||||
@@ -272,7 +304,7 @@ export const en = {
|
||||
'Choose the configuration to reuse in future projects.',
|
||||
'templates.editTitle': 'Edit {name}',
|
||||
'templates.editDescription':
|
||||
'Refresh the template from its source project and review the impact on linked projects.',
|
||||
'Edit the template configuration and review the impact on linked projects.',
|
||||
'templates.defaultName': '{project} template',
|
||||
'templates.details': 'Template details',
|
||||
'templates.name': 'Template name',
|
||||
@@ -285,13 +317,15 @@ export const en = {
|
||||
'The slug is locked so existing template references remain stable.',
|
||||
'templates.groups': 'Configuration groups',
|
||||
'templates.createGroupsHelp':
|
||||
'Only selected groups are saved. Related groups required by the workflow are included automatically.',
|
||||
'Only selected groups are saved. Statuses, columns and transitions inherit independently.',
|
||||
'templates.editGroupsHelp':
|
||||
'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.',
|
||||
'Edit the selected groups directly. Linked projects keep their existing local overrides.',
|
||||
'templates.impact': 'Template impact',
|
||||
'templates.inheritance': 'Inheritance',
|
||||
'templates.emptyStart':
|
||||
'Templates contain configuration only. New projects start with a clean ticket history.',
|
||||
'templates.cleanStart':
|
||||
'Tickets and other operational ticket data are never copied. The source project stays unchanged.',
|
||||
'Tickets are never copied. Saving a project as a template links that project to the new template.',
|
||||
'templates.refreshHelp':
|
||||
'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.',
|
||||
'templates.linkedWarning': 'Linked projects will be affected',
|
||||
@@ -1213,7 +1247,7 @@ export const en = {
|
||||
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||
'ticketDetail.linkedTicketsDescription':
|
||||
'Track cross-ticket relationships with project-defined link labels.',
|
||||
'ticketDetail.linkType': 'Link type',
|
||||
'ticketDetail.linkAction': 'Action',
|
||||
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||
'ticketDetail.saveLink': 'Save link',
|
||||
'ticketDetail.noLinks':
|
||||
@@ -1227,11 +1261,6 @@ export const en = {
|
||||
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||
'ticketRelationships.addLink': 'Add or update a link',
|
||||
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||
'ticketRelationships.direction': 'This ticket',
|
||||
'ticketRelationships.directionHelp':
|
||||
'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.noTypes':
|
||||
'No link types are configured for this project.',
|
||||
@@ -1336,8 +1365,10 @@ export const en = {
|
||||
'inheritance.resetWarning':
|
||||
'Local configuration in this area may be replaced or removed.',
|
||||
'inheritance.statusHelp':
|
||||
'Choose where tickets in local statuses should move before those statuses are removed.',
|
||||
'Choose a replacement for each used local status. Its tickets and local status transitions move to that replacement.',
|
||||
'inheritance.statusLabel': '{status} ({count} tickets)',
|
||||
'inheritance.noReplacementTargets':
|
||||
'Add a status to the template before resetting used local statuses.',
|
||||
'inheritance.selectStatus': 'Select replacement status',
|
||||
'inheritance.area.projectSettings': 'Project settings',
|
||||
'inheritance.area.boardColumns': 'Board columns',
|
||||
|
||||
@@ -6,31 +6,58 @@ import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects';
|
||||
import { create } from '@/routes/projects/templates';
|
||||
import {
|
||||
create as createEmpty,
|
||||
index as templatesIndex,
|
||||
} from '@/routes/project-templates';
|
||||
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
|
||||
const props = defineProps<ProjectTemplatesCreatePageProps>();
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{ title: t('projects.saveTemplate'), href: create(props.project.id) },
|
||||
]);
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() =>
|
||||
props.project
|
||||
? [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{
|
||||
title: t('projects.saveTemplate'),
|
||||
href: create(props.project.id),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ title: t('navigation.templates'), href: templatesIndex() },
|
||||
{ title: t('templates.createEmpty'), href: createEmpty() },
|
||||
],
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('templates.createTitle', { project: project.name })" />
|
||||
<Head
|
||||
:title="
|
||||
project
|
||||
? t('templates.createTitle', { project: project.name })
|
||||
: t('templates.createEmpty')
|
||||
"
|
||||
/>
|
||||
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
|
||||
<header class="gap-stack grid">
|
||||
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||
{{ t('templates.createTitle', { project: project.name }) }}
|
||||
{{
|
||||
project
|
||||
? t('templates.createTitle', {
|
||||
project: project.name,
|
||||
})
|
||||
: t('templates.createEmpty')
|
||||
}}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templates.createDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
<ProjectTemplateForm
|
||||
:key="project.id"
|
||||
:key="project?.id ?? 'empty'"
|
||||
:project="project"
|
||||
:initial-configuration="initialConfiguration"
|
||||
:copy-area-options="copyAreaOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { destroy, edit, index } from '@/routes/project-templates';
|
||||
import { destroy, edit, index, create } from '@/routes/project-templates';
|
||||
import {
|
||||
create as createProject,
|
||||
index as projectsIndex,
|
||||
@@ -80,9 +80,9 @@ function removeTemplate(): void {
|
||||
</p>
|
||||
</div>
|
||||
<Button as-child class="shrink-0"
|
||||
><Link :href="createProject()"
|
||||
><Link :href="create()"
|
||||
><Plus aria-hidden="true" />{{
|
||||
t('projects.create')
|
||||
t('templates.createEmpty')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
|
||||
@@ -88,7 +88,10 @@ const settingsItems = computed(() =>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.projectCreate"
|
||||
v-if="
|
||||
$page.props.auth.permissions.projectCreate &&
|
||||
canManageAccess
|
||||
"
|
||||
as-child
|
||||
variant="outline"
|
||||
class="self-start"
|
||||
|
||||
@@ -67,7 +67,8 @@ export type ProjectTemplatesIndexPageProps = {
|
||||
|
||||
export type ProjectTemplatesCreatePageProps = {
|
||||
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||
project: ProjectModel;
|
||||
project: ProjectModel | null;
|
||||
initialConfiguration?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ProjectTemplatesEditPageProps = {
|
||||
@@ -82,6 +83,8 @@ export type ProjectTemplateStateArea = {
|
||||
|
||||
export type ProjectTemplateState = {
|
||||
effectiveConfiguration: Record<string, unknown>;
|
||||
localConfiguration?: Record<string, unknown>;
|
||||
localAreas?: ProjectTemplateStateArea[];
|
||||
inheritedAreas: ProjectTemplateStateArea[];
|
||||
inheritedConfiguration?: Record<string, unknown>;
|
||||
inheritsFromTemplate: boolean;
|
||||
@@ -95,7 +98,7 @@ export type ProjectTemplateState = {
|
||||
name: string;
|
||||
ticketCount: number;
|
||||
}>;
|
||||
replacementOptions: Array<{ id: number; name: string }>;
|
||||
replacementOptions: Array<{ id: number | string; name: string }>;
|
||||
}
|
||||
>;
|
||||
templateConfiguration?: Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ProjectDuplicateCopyAreaOption } from '@/types/page-props';
|
||||
|
||||
export type TemplateNotificationFilter = {
|
||||
attribute: string;
|
||||
operator: string;
|
||||
value: string | string[] | null;
|
||||
};
|
||||
|
||||
export type TemplateConfigurationItem = {
|
||||
configuration_key: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
counterpart_label?: string;
|
||||
position?: number;
|
||||
is_parent?: boolean;
|
||||
field_type?: string;
|
||||
board_column_key?: string | null;
|
||||
provider?: string;
|
||||
base_url?: string;
|
||||
provider_repository_id?: string;
|
||||
owner_name?: string;
|
||||
repository_name?: string;
|
||||
repository_full_name?: string;
|
||||
repository_url?: string;
|
||||
enabled?: boolean;
|
||||
event_type?: string;
|
||||
channel?: string;
|
||||
channel_configuration?: { allowed_mentions?: string[] };
|
||||
message_template?: string | null;
|
||||
delivery_url?: string;
|
||||
filters?: TemplateNotificationFilter[];
|
||||
};
|
||||
|
||||
export type TemplateStatusTransition = {
|
||||
status_key: string;
|
||||
target_keys: string[];
|
||||
};
|
||||
export type TemplateConfiguration = {
|
||||
project_settings: { show_author: boolean; show_story_points: boolean };
|
||||
board_columns: TemplateConfigurationItem[];
|
||||
ticket_statuses: TemplateConfigurationItem[];
|
||||
status_transitions: TemplateStatusTransition[];
|
||||
ticket_types: TemplateConfigurationItem[];
|
||||
ticket_priorities: TemplateConfigurationItem[];
|
||||
ticket_fields: TemplateConfigurationItem[];
|
||||
ticket_link_types: TemplateConfigurationItem[];
|
||||
repository_connections: TemplateConfigurationItem[];
|
||||
notification_actions: TemplateConfigurationItem[];
|
||||
};
|
||||
export type TemplateListArea = Exclude<
|
||||
keyof TemplateConfiguration,
|
||||
'project_settings' | 'status_transitions'
|
||||
>;
|
||||
export type TemplateTextField = {
|
||||
key:
|
||||
| 'name'
|
||||
| 'label'
|
||||
| 'counterpart_label'
|
||||
| 'base_url'
|
||||
| 'provider_repository_id'
|
||||
| 'owner_name'
|
||||
| 'repository_name'
|
||||
| 'repository_full_name'
|
||||
| 'repository_url'
|
||||
| 'delivery_url';
|
||||
label: string;
|
||||
};
|
||||
export type TemplateConfigurationEditorProps = {
|
||||
areas: string[];
|
||||
options: ProjectDuplicateCopyAreaOption[];
|
||||
errors: Record<string, string | undefined>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export type TemplateResetForm = {
|
||||
areas: string[];
|
||||
status_replacements: Record<string, number | string | undefined>;
|
||||
};
|
||||
@@ -67,3 +67,14 @@ export type TicketBranchFormData = {
|
||||
branch_name: 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'];
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ Route::middleware('can:create-project')
|
||||
->prefix('project-templates')
|
||||
->name('project-templates.')
|
||||
->group(function (): void {
|
||||
Route::get('create', [ProjectTemplateController::class, 'createEmpty'])->name('create');
|
||||
Route::post('/', [ProjectTemplateController::class, 'storeEmpty'])->name('store');
|
||||
Route::get('/', [ProjectTemplateController::class, 'index'])->name('index');
|
||||
Route::get('{projectTemplate}/edit', [ProjectTemplateController::class, 'edit'])->name('edit');
|
||||
Route::patch('{projectTemplate}', [ProjectTemplateController::class, 'update'])->name('update');
|
||||
|
||||
@@ -20,7 +20,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
|
||||
Route::get('board', [ProjectBoardController::class, 'index'])->name('board.index');
|
||||
Route::get('backlog', [ProjectController::class, 'backlog'])->name('backlog.index');
|
||||
|
||||
Route::middleware('can:create-project')->group(function (): void {
|
||||
Route::middleware(['can:create-project', 'can:admin-project,project'])->group(function (): void {
|
||||
Route::get('templates/create', [ProjectTemplateController::class, 'create'])->name('templates.create');
|
||||
Route::post('templates', [ProjectTemplateController::class, 'store'])->name('templates.store');
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ class DiscordLoggerTest extends TestCase
|
||||
'logging.channels.discord_errors.url' => 'https://discord.example.test/webhook',
|
||||
'logging.channels.discord_errors.mention' => '@here',
|
||||
'logging.channels.discord_errors.title' => '## KanbanGit error',
|
||||
'logging.channels.discord_errors.max_trace_lines' => 2,
|
||||
]);
|
||||
|
||||
Log::forgetChannel('discord_errors');
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Mcp;
|
||||
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Mcp\Servers\KanbanGitServer;
|
||||
use App\Mcp\Tools\CreateTicket;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\UpdateTicket;
|
||||
use App\Mcp\Tools\UpdateTicketDescription;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketDescriptionContractTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Project $project;
|
||||
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->user = User::factory()->create();
|
||||
$this->project = Project::factory()->create(['key' => 'CORE']);
|
||||
app(InitializeProjectTicketMetadata::class)->handle($this->project);
|
||||
$this->syncProjectPermissions($this->project, $this->user, ['view', 'write']);
|
||||
}
|
||||
|
||||
public function test_discovery_exposes_the_shared_contract_for_every_writing_tool_and_field_for_any_key_owner(): void
|
||||
{
|
||||
foreach ([$this->user, User::factory()->create()] as $user) {
|
||||
$this->syncProjectPermissions($this->project, $user, ['view', 'write']);
|
||||
$this->withApplicationKey($user);
|
||||
$this->postJson('/mcp/kanbangit', [
|
||||
'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize',
|
||||
'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => [], 'clientInfo' => ['name' => 'Contract test', 'version' => '1.0']],
|
||||
])->assertOk()->assertJsonPath('result.instructions', fn (string $value): bool => str_contains($value, TicketDescriptionFormat::instructions()));
|
||||
$response = $this->postJson('/mcp/kanbangit', [
|
||||
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => [],
|
||||
])->assertOk();
|
||||
$writers = [];
|
||||
foreach ($response->json('result.tools') as $tool) {
|
||||
$fields = $tool['inputSchema']['properties'];
|
||||
if (! ($tool['annotations']['readOnlyHint'] ?? false)) {
|
||||
$writers[] = $tool['name'];
|
||||
$this->assertStringContainsString(TicketDescriptionFormat::instructions(), $tool['description'], $tool['name']);
|
||||
}
|
||||
if (isset($fields['description'])) {
|
||||
$this->assertStringContainsString(TicketDescriptionFormat::instructions(), $fields['description']['description'], $tool['name']);
|
||||
}
|
||||
if (isset($fields['acceptance_criteria'])) {
|
||||
$this->assertStringContainsString(TicketDescriptionFormat::criteriaInstructions(), $fields['acceptance_criteria']['description'], $tool['name']);
|
||||
}
|
||||
}
|
||||
$this->assertContains('create_ticket', $writers);
|
||||
$this->assertContains('update_ticket', $writers);
|
||||
$this->assertContains('update_ticket_description', $writers);
|
||||
}
|
||||
}
|
||||
|
||||
#[DataProvider('descriptionCases')]
|
||||
public function test_backend_and_editor_share_compatibility_cases(string $value, bool $supported): void
|
||||
{
|
||||
$this->assertSame($supported, TicketDescriptionFormat::unsupportedContent($value) === []);
|
||||
}
|
||||
|
||||
public static function descriptionCases(): array
|
||||
{
|
||||
$cases = json_decode(file_get_contents(__DIR__.'/../../Fixtures/ticket-description-cases.json'), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
return collect($cases)->mapWithKeys(fn (array $case): array => [$case['name'] => [$case['value'], $case['supported']]])->all();
|
||||
}
|
||||
|
||||
#[DataProvider('unsupportedDescriptions')]
|
||||
public function test_incompatible_description_rejects_creation_and_every_update_without_partial_changes(string $description): void
|
||||
{
|
||||
$projectBefore = $this->project->fresh()->getAttributes();
|
||||
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->createPayload($description))
|
||||
->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']);
|
||||
$this->assertDatabaseCount('tickets', 0);
|
||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
||||
$this->assertSame($projectBefore, $this->project->fresh()->getAttributes());
|
||||
|
||||
$ticket = $this->ticket();
|
||||
$before = $ticket->fresh()->getAttributes();
|
||||
foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) {
|
||||
KanbanGitServer::actingAs($this->user)->tool($tool, [
|
||||
'ticket_id' => $ticket->id, 'description' => $description,
|
||||
'title' => 'Must not change', 'story_points' => 99,
|
||||
'acceptance_criteria' => [['text' => 'Must not replace']],
|
||||
])->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']);
|
||||
$this->assertSame($before, $ticket->fresh()->getAttributes());
|
||||
$this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text);
|
||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static function unsupportedDescriptions(): array
|
||||
{
|
||||
return array_map(fn (array $case): array => [$case[0]], array_filter(self::descriptionCases(), fn (array $case): bool => ! $case[1]));
|
||||
}
|
||||
|
||||
public function test_compatible_descriptions_are_stored_verbatim_and_criteria_remain_separate_and_null_clears(): void
|
||||
{
|
||||
$description = TicketDescriptionFormat::contract()['example'];
|
||||
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, [
|
||||
...$this->createPayload($description), 'acceptance_criteria' => [['text' => 'Separate criterion']],
|
||||
])->assertOk();
|
||||
$ticket = $this->project->tickets()->sole();
|
||||
$this->assertSame($description, $ticket->description);
|
||||
$this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text);
|
||||
$this->assertSame([], TicketDescriptionFormat::unsupportedContent($description));
|
||||
$this->withoutVite();
|
||||
$this->actingAs($this->user)->get(route('projects.tickets.show', [$this->project, $ticket]))
|
||||
->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description)->where('ticket.acceptance_criteria.0.text', 'Separate criterion'));
|
||||
$process = new Process(['node', 'tests/Fixtures/ticket-description.mjs', '--stored-description'], base_path());
|
||||
$process->setInput(json_encode(['description' => $ticket->description, 'criteria' => $ticket->acceptanceCriteria()->get()->toArray()], JSON_THROW_ON_ERROR));
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) {
|
||||
$updated = "> Quote\n\n**Updated**\n\n```markdown\n- [x] literal example\n```";
|
||||
KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => $updated])->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $updated)->etc());
|
||||
$this->assertSame($updated, $ticket->fresh()->description);
|
||||
KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => null])->assertOk();
|
||||
$this->assertNull($ticket->fresh()->description);
|
||||
$this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_updates_without_description_preserve_legacy_source_and_checklist_identity(): void
|
||||
{
|
||||
$ticket = $this->ticket();
|
||||
$legacy = "## Legacy\n\n- [x] Keep verbatim\n\n<table></table>";
|
||||
$ticket->update(['description' => $legacy]);
|
||||
$criterion = $ticket->acceptanceCriteria()->sole();
|
||||
KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $ticket->id, 'title' => 'Updated title'])
|
||||
->assertOk();
|
||||
$this->assertSame($legacy, $ticket->fresh()->description);
|
||||
$this->assertSame($criterion->id, $ticket->acceptanceCriteria()->sole()->id);
|
||||
KanbanGitServer::actingAs($this->user)->tool(UpdateTicketDescription::class, ['ticket_id' => $ticket->id])->assertOk();
|
||||
$this->assertSame($legacy, $ticket->fresh()->description);
|
||||
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_id' => $ticket->id])->assertOk()
|
||||
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $legacy)->etc());
|
||||
}
|
||||
|
||||
private function createPayload(?string $description): array
|
||||
{
|
||||
return [
|
||||
'project_id' => $this->project->id, 'title' => 'Formatted ticket', 'description' => $description,
|
||||
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
|
||||
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
|
||||
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
|
||||
];
|
||||
}
|
||||
|
||||
private function ticket(): Ticket
|
||||
{
|
||||
$ticket = Ticket::factory()->create([
|
||||
...collect($this->createPayload('Original description'))->except('project_id')->all(),
|
||||
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1,
|
||||
]);
|
||||
$ticket->acceptanceCriteria()->create(['text' => 'Separate criterion', 'checked' => false, 'indent' => 0, 'position' => 1]);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
}
|
||||
@@ -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', [
|
||||
'id' => $boardColumn->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('ticket_statuses', [
|
||||
'id' => $ticketStatus->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('tickets', [
|
||||
'id' => $ticket->id,
|
||||
]);
|
||||
|
||||
@@ -38,7 +38,7 @@ class ProjectTemplateControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->syncGlobalPermissions($user, ['project:create']);
|
||||
$this->syncProjectPermissions($project, $user, ['view']);
|
||||
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/project-templates')
|
||||
@@ -79,7 +79,7 @@ class ProjectTemplateControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->syncGlobalPermissions($user, ['project:create']);
|
||||
$this->syncProjectPermissions($project, $user, ['view']);
|
||||
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||
|
||||
$project->ticketTypes()->delete();
|
||||
$project->ticketStatuses()->delete();
|
||||
@@ -119,19 +119,8 @@ class ProjectTemplateControllerTest extends TestCase
|
||||
$this->assertTrue($template->is_published);
|
||||
$this->assertNull($template->project_id);
|
||||
|
||||
$this->assertSame([
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||
'show_author' => false,
|
||||
'show_story_points' => false,
|
||||
],
|
||||
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
|
||||
[
|
||||
'name' => 'Bug',
|
||||
'position' => 1,
|
||||
'is_parent' => false,
|
||||
],
|
||||
],
|
||||
], $template->configuration_snapshot);
|
||||
$this->assertSame(app(DuplicateProjectConfiguration::class)->export($project, $template->copy_areas), $template->configuration_snapshot);
|
||||
$this->assertSame($template->id, $project->fresh()->project_template_id);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('project-templates.destroy', $template))
|
||||
@@ -271,6 +260,11 @@ class ProjectTemplateControllerTest extends TestCase
|
||||
],
|
||||
])->saveQuietly();
|
||||
|
||||
app(DuplicateProjectConfiguration::class)->apply($inheritingProject, $template->configuration_snapshot, adoptExisting: true);
|
||||
$nextConfiguration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($template->configuration_snapshot);
|
||||
$nextConfiguration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
|
||||
$nextConfiguration['ticket_types'][0]['name'] = 'Feature';
|
||||
|
||||
$sourceProject->forceFill([
|
||||
'show_author' => true,
|
||||
'show_story_points' => true,
|
||||
@@ -281,6 +275,7 @@ class ProjectTemplateControllerTest extends TestCase
|
||||
$this->actingAs($user)
|
||||
->patch(route('project-templates.update', $template), [
|
||||
'name' => 'Platform Template v2',
|
||||
'configuration_snapshot' => $nextConfiguration,
|
||||
'slug' => 'should-not-change',
|
||||
'copy_areas' => [
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
@@ -296,19 +291,7 @@ class ProjectTemplateControllerTest extends TestCase
|
||||
|
||||
$this->assertSame('Platform Template v2', $template->name);
|
||||
$this->assertSame('platform-template', $template->slug);
|
||||
$this->assertSame([
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||
'show_author' => true,
|
||||
'show_story_points' => true,
|
||||
],
|
||||
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
|
||||
[
|
||||
'name' => 'Feature',
|
||||
'position' => 1,
|
||||
'is_parent' => false,
|
||||
],
|
||||
],
|
||||
], $template->configuration_snapshot);
|
||||
$this->assertSame($nextConfiguration, $template->configuration_snapshot);
|
||||
|
||||
$this->assertTrue($inheritingProject->show_author);
|
||||
$this->assertTrue($inheritingProject->show_story_points);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user