Compare commits

..
Author SHA1 Message Date
Twyco 1291a0fcad fix(KAN-14): preserve template inheritance and project data
CI/CD / preconditions (pull_request) Successful in 4s
CI/CD / test_and_check_app (pull_request) Failing after 2m14s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-15 11:38:17 +02:00
Twyco 5769d84526 Merge pull request 'Fix/ios unsaved form navigation' (#180) from fix/ios-unsaved-form-navigation into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m47s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m24s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 26s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m9s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 22s
Reviewed-on: #180
2026-10-15 07:55:42 +00:00
Twyco 99264c7bc8 fix: pnpm packages
CI/CD / preconditions (pull_request) Successful in 4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 2m55s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 2m16s
2026-10-15 09:50:04 +02:00
Twyco 1b448e363c fix: restore iOS navigation with unsaved forms 2026-10-15 09:46:19 +02:00
Twyco 65e9df6d6d fix: pnpm packages
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m34s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m49s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 2m15s
CI/CD / Publish immutable image (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Canceled after 31s
2026-10-15 09:22:09 +02:00
Twyco 7d7b723f69 Merge pull request 'fix: restore macOS navigation with unsaved forms' (#179) from fix/macos-unsaved-form-navigation into main
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m18s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m32s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 28s
CI/CD / test_and_check_app (push) Failing after 19s
CI/CD / preconditions (push) Successful in 3s
CI/CD / Publish immutable image (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m14s
Reviewed-on: #179
2026-10-15 07:06:48 +00:00
Twyco 7a656de7ce fix: restore macOS navigation with unsaved forms 2026-10-15 07:06:48 +00:00
Twyco 811dbf7fa4 Merge pull request 'KAN-73: Explain and validate MCP ticket description format' (#178) from feature/KAN-73-mcp-description-contract into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Failing after 1m26s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #178
2026-10-15 07:06:05 +00:00
Twyco 5567eea06f KAN-73: document and validate MCP ticket description format 2026-10-15 07:06:05 +00:00
Twyco ca1f243234 Merge pull request 'KAN-72: Complete MCP ticket editing and discovery' (#177) from feature/KAN-72-complete-mcp-ticket-editing into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 1m46s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #177
2026-10-15 07:04:52 +00:00
Twyco 9ed8d0c6cc feat(KAN-72): complete MCP ticket editing and discovery
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m12s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-02 13:34:00 +02:00
Twyco ba12e65e81 Merge pull request 'Fix KAN-64 project deletion on MariaDB' (#176) from fix/KAN-64-project-delete-response into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 10s
Reviewed-on: #176
2026-10-02 10:17:19 +00:00
Twyco 85b9409e3c Fix project deletion with linked ticket statuses on MariaDB 2026-10-02 10:17:19 +00:00
Twyco f862a6a226 Merge pull request 'KAN-12: Simplify links to action and ticket' (#175) from feature/KAN-12-simplify-link-actions into main
CI/CD / Publish immutable image (push) Successful in 14s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 8s
Reviewed-on: #175
2026-10-01 20:36:03 +00:00
Twyco c8f494fe7c feat: simplify ticket links to action and ticket (KAN-12)
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m2s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 22:32:02 +02:00
Twyco f7c3873c19 Merge pull request 'KAN-68: Add WYSIWYG ticket description editor' (#172) from feature/KAN-68-wysiwyg-description-editor into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m10s
CI/CD / test_and_check_app (push) Successful in 8s
CI/CD / Publish immutable image (push) Successful in 40s
Reviewed-on: #172
2026-10-01 19:22:22 +00:00
Twyco d8e1659f4b feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m11s
CI/CD / test_and_check_app (pull_request) Successful in 5m58s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 19:21:22 +00:00
118 changed files with 6447 additions and 604 deletions
@@ -2,6 +2,7 @@
namespace App\Actions\ProjectBoard; namespace App\Actions\ProjectBoard;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions
'position' => $index + 1, 'position' => $index + 1,
])->saveQuietly(); ])->saveQuietly();
}); });
app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses');
} }
private function statusContextLabel(TicketStatus $ticketStatus): string private function statusContextLabel(TicketStatus $ticketStatus): string
@@ -2,6 +2,7 @@
namespace App\Actions\ProjectBoard; namespace App\Actions\ProjectBoard;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -50,5 +51,9 @@ trait ReordersBoardColumns
'position' => $index + 1, 'position' => $index + 1,
])->saveQuietly(); ])->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\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager; use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateTicketStatusTransitions class UpdateTicketStatusTransitions
{ {
@@ -20,22 +22,25 @@ class UpdateTicketStatusTransitions
*/ */
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
{ {
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', [])) return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus {
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId) Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail();
->unique() $allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->values() ->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
->all(); ->unique()
->values()
->all();
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds); $ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
$this->projectTemplateOverrideManager->mark( $this->projectTemplateOverrideManager->mark(
$ticketStatus->project, $ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
); );
$this->refreshProjectBaseTemplate->handle( $this->refreshProjectBaseTemplate->handle(
$ticketStatus->project, $ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, 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\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateProjectTicketMetadataSettings class UpdateProjectTicketMetadataSettings
{ {
@@ -20,19 +21,22 @@ class UpdateProjectTicketMetadataSettings
*/ */
public function handle(Project $project, array $validated): Project public function handle(Project $project, array $validated): Project
{ {
$project->update([ return DB::transaction(function () use ($project, $validated): Project {
'show_author' => Arr::get($validated, 'show_author', false), Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
'show_story_points' => Arr::get($validated, 'show_story_points', false), $project->update([
]); 'show_author' => Arr::get($validated, 'show_author', false),
$this->projectTemplateOverrideManager->mark( 'show_story_points' => Arr::get($validated, 'show_story_points', false),
$project, ]);
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, $this->projectTemplateOverrideManager->mark(
); $project,
$this->refreshProjectBaseTemplate->handle( DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
$project, );
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, $this->refreshProjectBaseTemplate->handle(
); $project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
return $project->fresh(); return $project->fresh();
}, 3);
} }
} }
+2
View File
@@ -36,6 +36,7 @@ class CreateProject
if ($creationMode === 'template' && $sourceTemplateId !== null) { if ($creationMode === 'template' && $sourceTemplateId !== null) {
$sourceTemplate = ProjectTemplate::query() $sourceTemplate = ProjectTemplate::query()
->published() ->published()
->lockForUpdate()
->findOrFail((int) $sourceTemplateId); ->findOrFail((int) $sourceTemplateId);
$project->forceFill([ $project->forceFill([
@@ -72,6 +73,7 @@ class CreateProject
$this->duplicateProjectConfiguration->apply( $this->duplicateProjectConfiguration->apply(
$project->fresh() ?? $project, $project->fresh() ?? $project,
$sourceTemplate->configuration_snapshot ?? [], $sourceTemplate->configuration_snapshot ?? [],
adoptExisting: true,
); );
} }
@@ -15,11 +15,18 @@ class CreateProjectBaseTemplate
{ {
$copyAreas = DuplicateProjectConfiguration::copyableAreas(); $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([ return ProjectTemplate::query()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'source_project_id' => $project->id, 'source_project_id' => $project->id,
'name' => "{$project->name} base template", 'name' => "{$project->name} base template",
'slug' => strtolower($project->key).'-base-template', 'slug' => $candidate,
'is_published' => false, 'is_published' => false,
'copy_areas' => $copyAreas, 'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas), 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
+43 -10
View File
@@ -5,28 +5,61 @@ namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate; use App\Models\ProjectTemplate;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class CreateProjectTemplate class CreateProjectTemplate
{ {
public function __construct( public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, 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 * @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
*/ */
public function handle(Project $sourceProject, array $attributes): ProjectTemplate 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([ return $template;
'project_id' => null, }, 3);
'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),
]);
} }
} }
+1
View File
@@ -14,6 +14,7 @@ class DeleteProject
DB::transaction(function () use ($project): void { DB::transaction(function () use ($project): void {
$baseTemplate = $project->baseTemplate()->first(); $baseTemplate = $project->baseTemplate()->first();
$project->ticketStatuses()->delete();
$project->delete(); $project->delete();
$baseTemplate?->delete(); $baseTemplate?->delete();
}); });
@@ -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; namespace App\Actions\Projects;
use App\Actions\Notifications\NormalizeNotificationFilters;
use App\Models\Project; use App\Models\Project;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class DuplicateProjectConfiguration class DuplicateProjectConfiguration
{ {
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
public function __construct( public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
) {} ) {}
/** @return list<string> */ /** @return list<string> */
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
unset($configuration[self::COPY_BOARD_WORKFLOW]); unset($configuration[self::COPY_BOARD_WORKFLOW]);
} }
return $configuration; return $this->projectConfigurationIdentity->normalize($configuration);
} }
/** @param list<string> $copyAreas @return array<string, mixed> */ /** @param list<string> $copyAreas @return array<string, mixed> */
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
]; ];
} }
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys( $columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column, int $index): array => [$column->id => $index], static fn ($column): array => [$column->id => $column->configuration_key],
); );
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys( $statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status, int $index): array => [$status->id => $index], static fn ($status): array => [$status->id => $status->configuration_key],
); );
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) { if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map( $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(); )->all();
} }
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) { if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map( $configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => [ 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, 'name' => $status->name,
'position' => $status->position, 'position' => $status->position,
], ],
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) { if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map( $configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map( static fn ($status): array => [
static fn (int $id): ?int => $statusIndexes->get($id), 'status_key' => $status->configuration_key,
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(), 'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?string => $statusKeys->get($id),
)->filter()->sort()->values()->all(),
],
)->all(); )->all();
} }
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value, 'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [], 'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url, 'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
'filters' => $item->filters ?? [], 'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
], ],
]; ];
$relations = [ $relations = [
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
foreach ($simpleAreas as $area => $mapper) { foreach ($simpleAreas as $area => $mapper) {
if (in_array($area, $selectedAreas, true)) { 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<string, mixed> $configuration
* @param array<int|string, int|string> $statusReplacements * @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); $configuration = $this->normalizeConfiguration($configuration);
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
$blockedAreas = []; $blockedAreas = [];
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) { if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly(); $targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
} }
if (isset($configuration[self::COPY_BOARD_COLUMNS])) { if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
$blocked = $this->syncModels( $currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
$targetProject->boardColumns()->get(), $nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
$configuration[self::COPY_BOARD_COLUMNS], $columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
fn (array $item) => $targetProject->boardColumns()->create($item), $statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(), static fn ($status): bool => $nextStatuses->has($status->configuration_key)
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(), || $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; $blockedAreas[] = self::COPY_BOARD_COLUMNS;
} }
} }
if (isset($configuration[self::COPY_TICKET_STATUSES])) { if (isset($configuration[self::COPY_TICKET_STATUSES])) {
$columns = $targetProject->boardColumns()->get()->values(); $columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array { $existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
$columnIndex = $item['board_column_index'] ?? null; $items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id; $columnKey = $item['board_column_key'] ?? null;
unset($item['board_column_index']); $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; return $item;
})->all(); })->all();
$blocked = $this->syncModels( $templateKeys = array_column($items, 'configuration_key');
$targetProject->ticketStatuses()->get(), $canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
$items, || array_key_exists($status->id, $statusReplacements);
fn (array $item) => $targetProject->ticketStatuses()->create($item), $beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(), $replacement = $statusReplacements[$status->id] ?? null;
function (Model $status) use ($statusReplacements): bool { if ($replacement === null) {
if (! $status->tickets()->exists()) { return;
return true; }
} $replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
$replacementId = $statusReplacements[$status->id] ?? null; ->where('configuration_key', (string) $replacement)->first();
if ($replacementId === null) { if ($replacementStatus === null && is_numeric($replacement)) {
return false; $replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
} }
$status->tickets()->update(['ticket_status_id' => $replacementId]); if ($replacementStatus === null) {
throw ValidationException::withMessages([
return true; 'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
}, ]);
); }
if ($blocked) { $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; $blockedAreas[] = self::COPY_TICKET_STATUSES;
} }
} }
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) { if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
$statuses = $targetProject->ticketStatuses()->get()->values(); $statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) { foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
$status = $statuses->get($index); $status = $statuses->get($transition['status_key']);
if ($status !== null) { 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 = [ $guards = [
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()], self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()], self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()], self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()], self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null], self::COPY_REPOSITORY_CONNECTIONS => null,
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null], self::COPY_NOTIFICATION_ACTIONS => null,
]; ];
foreach ($guards as $area => $canDelete) {
foreach ($relations as $area => [$relation, $canDelete]) {
if (! isset($configuration[$area])) { if (! isset($configuration[$area])) {
continue; continue;
} }
$blocked = $this->syncModels( $items = $configuration[$area];
$targetProject->{$relation}()->get(), if ($area === self::COPY_NOTIFICATION_ACTIONS) {
$configuration[$area], $statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
fn (array $item) => $targetProject->{$relation}()->create($item), $items = array_map(fn (array $item): array => [
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(), ...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
$canDelete, ], $items);
); }
if ($blocked) { if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
$blockedAreas[] = $area; $blockedAreas[] = $area;
} }
} }
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]); $targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
} }
return array_values(array_unique($blockedAreas)); return $blockedAreas;
}); });
foreach ($blockedAreas as $area) { foreach ($blockedAreas as $area) {
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area); $this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
} }
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
/** @param list<string> $copyAreas */ /** @param list<string> $copyAreas */
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void 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 list<array<string, mixed>> $items
* @param array<int, 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) { $relation = ProjectConfigurationIdentity::relations()[$area];
$model = $models->get($index); $models = $project->{$relation}()->get();
$model === null ? $create($item) : $update($model, $item); $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; if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
foreach ($models->slice(count($items)) as $model) { foreach ($models as $model) {
if ($canDelete !== null && ! $canDelete($model)) { $model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
$blocked = true; }
}
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(); $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; namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\DB;
class ProjectTemplateOverrideManager class ProjectTemplateOverrideManager
{ {
@@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager
return; return;
} }
$project->loadMissing('projectTemplate'); DB::transaction(function () use ($project, $area): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) { $project->load('projectTemplate');
return; if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
} return;
}
$overrides = array_values(array_unique([ $project->forceFill(['template_overrides' => array_values(array_unique([
...($project->template_overrides ?? []), ...($project->template_overrides ?? []), $area,
$area, ]))])->saveQuietly();
])); }, 3);
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
} }
/** /**
@@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager
*/ */
public function clear(Project $project, array $areas): void public function clear(Project $project, array $areas): void
{ {
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas)); DB::transaction(function () use ($project, $areas): void {
$project->forceFill(['template_overrides' => $remaining])->saveQuietly(); $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 public function withoutRecording(callable $callback): mixed
@@ -3,6 +3,8 @@
namespace App\Actions\Projects; namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class RefreshProjectBaseTemplate class RefreshProjectBaseTemplate
{ {
@@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate
public function handle(Project $project, string $area): void public function handle(Project $project, string $area): void
{ {
$project->loadMissing('projectTemplate'); DB::transaction(function () use ($project, $area): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
if ($project->projectTemplate === null || $project->projectTemplate->is_published) { $template = ProjectTemplate::query()->find($project->project_template_id);
return; if ($template === null || $template->is_published) {
} return;
}
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration( $template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$project->projectTemplate->configuration_snapshot ?? [], $snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
); $template->forceFill(['configuration_snapshot' => array_replace(
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
$project->projectTemplate->forceFill([ )])->saveQuietly();
'configuration_snapshot' => array_replace( }, 3);
$snapshot,
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
),
])->saveQuietly();
} }
} }
@@ -71,7 +71,9 @@ class ResolveProjectTemplateState
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value')); $inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
return [ 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, 'inheritedAreas' => $inheritedAreas,
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues), 'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
'inheritsFromTemplate' => $projectTemplate->is_published, 'inheritsFromTemplate' => $projectTemplate->is_published,
@@ -96,24 +98,20 @@ class ResolveProjectTemplateState
return []; return [];
} }
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? []; $templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null;
if (! is_array($templateStatuses)) { if (! is_array($templateStatuses)) {
return []; return [];
} }
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values(); $statuses = $project->ticketStatuses()->withCount('tickets')->get();
$replacementOptions = $statuses->take(count($templateStatuses))->map( $templateKeys = array_column($templateStatuses, 'configuration_key');
static fn ($status): array => ['id' => $status->id, 'name' => $status->name], $replacementOptions = collect($templateStatuses)->map(
static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']],
)->all(); )->all();
$removedStatuses = $statuses->slice(count($templateStatuses)) $removedStatuses = $statuses
->filter(static fn ($status): bool => $status->tickets_count > 0) ->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0)
->map(static fn ($status): array => [ ->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count])
'id' => $status->id, ->values()->all();
'name' => $status->name,
'ticketCount' => $status->tickets_count,
])
->values()
->all();
return $removedStatuses === [] ? [] : [ return $removedStatuses === [] ? [] : [
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [ DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
+22 -25
View File
@@ -19,37 +19,34 @@ class UpdateProjectTemplate
*/ */
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
{ {
$projectTemplate->loadMissing('sourceProject'); return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate {
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
$sourceProject = $projectTemplate->sourceProject; $previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []);
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.'); $copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? []));
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); Arr::get($attributes, 'configuration_snapshot', $previousConfiguration),
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas); ))->only($copyAreas)->all();
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration( $affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
$projectTemplate->configuration_snapshot ?? [], ->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
); ->values()->all();
$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();
$projectTemplate->forceFill([ $projectTemplate->forceFill([
'name' => $attributes['name'], 'name' => Arr::get($attributes, 'name'),
'copy_areas' => $normalizedCopyAreas, 'copy_areas' => $copyAreas,
'configuration_snapshot' => $nextConfiguration, 'configuration_snapshot' => $nextConfiguration,
])->save(); ])->save();
Project::query() Project::query()
->where('project_template_id', $projectTemplate->id) ->where('project_template_id', $projectTemplate->id)
->orderBy('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) { 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); $currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
$overrides = $linkedProject->template_overrides ?? []; $overrides = $linkedProject->template_overrides ?? [];
$replacementConfiguration = []; $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\Project;
use App\Models\ProjectTemplate; use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class UpdateProjectTemplateLink class UpdateProjectTemplateLink
{ {
@@ -16,6 +17,7 @@ class UpdateProjectTemplateLink
public function switch(Project $project, ProjectTemplate $template): void public function switch(Project $project, ProjectTemplate $template): void
{ {
DB::transaction(function () use ($project, $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(); $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration( $configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [], $template->configuration_snapshot ?? [],
@@ -42,19 +44,27 @@ class UpdateProjectTemplateLink
* @param list<string> $areas * @param list<string> $areas
* @param array<int|string, int|string> $statusReplacements * @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; $template = $project->projectTemplate;
if ($template === null) { 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(); $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration( if ($project->project_template_id !== $template->id) {
$template->configuration_snapshot ?? [], throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']);
))->only($areas)->all(); }
$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( $blockedAreas = $this->duplicateProjectConfiguration->apply(
$project, $project,
$configuration, $configuration,
@@ -64,6 +74,8 @@ class UpdateProjectTemplateLink
$project->fresh(), $project->fresh(),
array_values(array_diff(array_keys($configuration), $blockedAreas)), 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 * @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
*/ */
public static function syncTicket(Ticket $ticket, array $items): void public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
{ {
$normalized = self::normalize($items); $normalized = self::normalize($items);
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id'); $existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
: $existingCriteria->get($criterionId); : $existingCriteria->get($criterionId);
while ( while (
$criterion === null $reuseUnidentifiedCriteria
&& $criterion === null
&& $fallbackIndex < $fallbackCriteria->count() && $fallbackIndex < $fallbackCriteria->count()
) { ) {
/** @var AcceptanceCriterion $candidate */ /** @var AcceptanceCriterion $candidate */
@@ -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));
}
}
+6 -3
View File
@@ -51,6 +51,7 @@ class UpdateProjectTicket
Ticket $ticket, Ticket $ticket,
User $actor, User $actor,
array $validated, array $validated,
bool $replaceCriteriaWithIds = false,
): bool { ): bool {
$fieldValueAttributes = Arr::get($validated, 'field_values', []); $fieldValueAttributes = Arr::get($validated, 'field_values', []);
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize( $nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
@@ -58,7 +59,7 @@ class UpdateProjectTicket
); );
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated); $ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool { return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
if (! $ticketAttributes['is_blocked']) { if (! $ticketAttributes['is_blocked']) {
$ticketAttributes['blocked_comment'] = null; $ticketAttributes['blocked_comment'] = null;
} }
@@ -91,7 +92,9 @@ class UpdateProjectTicket
/** @var Collection<int, TicketFieldValue> $existingFieldValues */ /** @var Collection<int, TicketFieldValue> $existingFieldValues */
$existingFieldValues = $ticket->fieldValues $existingFieldValues = $ticket->fieldValues
->keyBy('ticket_field_definition_id'); ->keyBy('ticket_field_definition_id');
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria); $currentAcceptanceCriteria = $replaceCriteriaWithIds
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$changes = []; $changes = [];
$summaryLabels = []; $summaryLabels = [];
@@ -209,7 +212,7 @@ class UpdateProjectTicket
} }
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) { if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria); AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
} }
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle( $fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
+5 -1
View File
@@ -161,7 +161,7 @@ class ProjectController extends Controller
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration))); $areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
$statusReplacements = Arr::get($validated, 'status_replacements', []); $statusReplacements = Arr::get($validated, 'status_replacements', []);
$this->updateProjectTemplateLink->reset( $blockedAreas = $this->updateProjectTemplateLink->reset(
$project->load('projectTemplate'), $project->load('projectTemplate'),
$areas, $areas,
$statusReplacements, $statusReplacements,
@@ -171,6 +171,10 @@ class ProjectController extends Controller
? "Selected settings were reset to the {$template->name} template." ? "Selected settings were reset to the {$template->name} template."
: "{$project->name} was reset to the {$template->name} template baseline."; : "{$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); return back()->with('status', $status);
} }
@@ -3,9 +3,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\Projects\CreateProjectTemplate; use App\Actions\Projects\CreateProjectTemplate;
use App\Actions\Projects\DeleteProjectTemplate;
use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Actions\Projects\UpdateProjectTemplate; use App\Actions\Projects\UpdateProjectTemplate;
use App\Http\Requests\StoreProjectTemplateRequest; use App\Http\Requests\StoreProjectTemplateRequest;
use App\Http\Requests\StoreStandaloneProjectTemplateRequest;
use App\Http\Requests\UpdateProjectTemplateRequest; use App\Http\Requests\UpdateProjectTemplateRequest;
use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource; use App\Http\Resources\ProjectTemplateResource;
@@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller
{ {
public function __construct( public function __construct(
private readonly CreateProjectTemplate $createProjectTemplate, private readonly CreateProjectTemplate $createProjectTemplate,
private readonly DeleteProjectTemplate $deleteProjectTemplate,
private readonly UpdateProjectTemplate $updateProjectTemplate, 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 public function edit(ProjectTemplate $projectTemplate): Response
{ {
abort_if(! $projectTemplate->is_published, 404); abort_if(! $projectTemplate->is_published, 404);
@@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller
abort_if(! $projectTemplate->is_published, 404); abort_if(! $projectTemplate->is_published, 404);
$templateName = $projectTemplate->name; $templateName = $projectTemplate->name;
$projectTemplate->delete(); $this->deleteProjectTemplate->handle($projectTemplate);
return to_route('project-templates.index') return to_route('project-templates.index')
->with('status', "Template {$templateName} was deleted."); ->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]), Rule::in([$project->key]),
], ],
'status_replacements' => ['sometimes', 'array'], 'status_replacements' => ['sometimes', 'array'],
'status_replacements.*' => [ 'status_replacements.*' => ['required'],
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
],
]; ];
} }
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$validated = $validator->validated(); $validated = $validator->validated();
$areas = array_values(Arr::get( $project->loadMissing('projectTemplate');
$validated, $configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
'areas', $areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
DuplicateProjectConfiguration::copyableAreas(), 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)) { if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
return; return;
} }
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
$project->loadMissing('projectTemplate'); $statuses = $project->ticketStatuses()->withCount('tickets')->get();
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration( $allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
$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');
$replacements = Arr::get($validated, 'status_replacements', []); $replacements = Arr::get($validated, 'status_replacements', []);
foreach ($replacements as $id => $replacement) {
foreach ($statuses->slice(count($templateStatuses)) as $status) { if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
if ($status->tickets_count === 0) { || ! 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; continue;
} }
$replacement = Arr::get($replacements, (string) $status->id);
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0); if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
if (! $allowedReplacementIds->contains($replacementId)) { $validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
$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 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 protected function prepareForValidation(): void
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
$project = $this->route('project');
$baseTemplate = $project?->projectTemplate;
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')], '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' => ['required', 'array', 'min:1'], 'copy_areas' => ['present', 'array'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], '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; namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
use App\Models\ProjectTemplate; use App\Models\ProjectTemplate;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
class UpdateProjectTemplateRequest extends FormRequest class UpdateProjectTemplateRequest extends FormRequest
{ {
use ValidatesProjectTemplateConfiguration;
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can('create-project') ?? false; return $this->user()?->can('create-project') ?? false;
@@ -25,8 +28,9 @@ class UpdateProjectTemplateRequest extends FormRequest
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false; $requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
return [ return [
...$this->configurationRules(),
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'copy_areas' => ['required', 'array', 'min:1'], 'copy_areas' => ['present', 'array'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [ 'confirm_linked_projects_update' => [
Rule::excludeIf(! $requiresImpactConfirmation), Rule::excludeIf(! $requiresImpactConfirmation),
+2
View File
@@ -32,6 +32,8 @@ class ProjectResource extends JsonResource
? null ? null
: [ : [
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []), '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_areas' => Arr::get($templateState, 'inheritedAreas', []),
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []), 'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false), 'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
+15 -3
View File
@@ -2,24 +2,32 @@
namespace App\Mcp\Servers; namespace App\Mcp\Servers;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Mcp\Tools\CreateTicket; use App\Mcp\Tools\CreateTicket;
use App\Mcp\Tools\GetProjectConfiguration;
use App\Mcp\Tools\GetTicket; use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListMyAssignedTickets; use App\Mcp\Tools\ListMyAssignedTickets;
use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\ListTicketsByStatus; use App\Mcp\Tools\ListTicketsByStatus;
use App\Mcp\Tools\ListTicketTypes; use App\Mcp\Tools\ListTicketTypes;
use App\Mcp\Tools\MoveTicketStatus; use App\Mcp\Tools\MoveTicketStatus;
use App\Mcp\Tools\SearchTickets;
use App\Mcp\Tools\UpdateTicket;
use App\Mcp\Tools\UpdateTicketAssignee; use App\Mcp\Tools\UpdateTicketAssignee;
use App\Mcp\Tools\UpdateTicketDescription; use App\Mcp\Tools\UpdateTicketDescription;
use Laravel\Mcp\Server; use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version; use Laravel\Mcp\Server\Attributes\Version;
#[Name('KanbanGit MCP')] #[Name('KanbanGit MCP')]
#[Version('0.1.0')] #[Version('0.2.1')]
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
class KanbanGitServer extends Server class KanbanGitServer extends Server
{ {
protected function boot(): void
{
$this->instructions = 'Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
}
protected array $tools = [ protected array $tools = [
ListMyAssignedTickets::class, ListMyAssignedTickets::class,
ListTicketsByStatus::class, ListTicketsByStatus::class,
@@ -29,6 +37,10 @@ class KanbanGitServer extends Server
MoveTicketStatus::class, MoveTicketStatus::class,
CreateTicket::class, CreateTicket::class,
ListTicketTypes::class, ListTicketTypes::class,
ListProjects::class,
GetProjectConfiguration::class,
SearchTickets::class,
UpdateTicket::class,
]; ];
protected array $resources = [ protected array $resources = [
@@ -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();
}
}
+24
View File
@@ -13,6 +13,7 @@ use App\Models\TicketStatus;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request; use Laravel\Mcp\Request;
@@ -78,6 +79,17 @@ class KanbanGitMcp
return $ticket; return $ticket;
} }
public function ticketForKey(User $user, string $ticketKey): Ticket
{
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
}
$this->ensureCanRead($user, $ticket->project);
return $ticket;
}
/** /**
* @throws AuthorizationException * @throws AuthorizationException
*/ */
@@ -201,6 +213,18 @@ class KanbanGitMcp
)); ));
} }
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
public function paginationPayload(LengthAwarePaginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
'has_more_pages' => $paginator->hasMorePages(),
];
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
+110
View File
@@ -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;
}
}
+8 -3
View File
@@ -5,11 +5,14 @@ namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateProjectTicket; use App\Actions\Tickets\CreateProjectTicket;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Enums\Permission; use App\Enums\Permission;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Rules\VisualTicketDescription;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -28,6 +31,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class CreateTicket extends Tool class CreateTicket extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly CreateProjectTicket $createProjectTicket, private readonly CreateProjectTicket $createProjectTicket,
@@ -44,7 +49,7 @@ class CreateTicket extends Tool
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id')); $project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
$validated = $request->validate([ $validated = $request->validate([
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string', new VisualTicketDescription],
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)], 'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)], 'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)], 'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
@@ -140,7 +145,7 @@ class CreateTicket extends Tool
return [ return [
'project_id' => $schema->integer()->description('Project ID.')->required(), 'project_id' => $schema->integer()->description('Project ID.')->required(),
'title' => $schema->string()->max(255)->description('Ticket title.')->required(), 'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
'description' => $schema->string()->nullable()->description('Ticket description.'), '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_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(), 'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(), 'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
@@ -156,7 +161,7 @@ class CreateTicket extends Tool
'checked' => $schema->boolean()->default(false), 'checked' => $schema->boolean()->default(false),
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0), 'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
])->withoutAdditionalProperties()) ])->withoutAdditionalProperties())
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'), ->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()),
'field_values' => $schema->array() 'field_values' => $schema->array()
->items($schema->object([ ->items($schema->object([
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(), 'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
+75
View File
@@ -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.'),
];
}
}
+8 -5
View File
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('get_ticket')] #[Name('get_ticket')]
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')] #[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
#[IsReadOnly] #[IsReadOnly]
class GetTicket extends Tool class GetTicket extends Tool
{ {
@@ -25,11 +25,14 @@ class GetTicket extends Tool
public function handle(Request $request): Response|ResponseFactory public function handle(Request $request): Response|ResponseFactory
{ {
$validated = $request->validate([ $validated = $request->validate([
'ticket_id' => ['required', 'integer'], 'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
]); ]);
$user = $this->kanbanGitMcp->user($request); $user = $this->kanbanGitMcp->user($request);
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']); $ticket = isset($validated['ticket_id'])
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
return Response::structured([ return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket), 'data' => $this->kanbanGitMcp->ticketPayload($ticket),
@@ -43,8 +46,8 @@ class GetTicket extends Tool
{ {
return [ return [
'ticket_id' => $schema->integer() 'ticket_id' => $schema->integer()
->description('Ticket ID.') ->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
->required(), 'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
]; ];
} }
} }
+46
View File
@@ -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
View File
@@ -3,6 +3,7 @@
namespace App\Mcp\Tools; namespace App\Mcp\Tools;
use App\Actions\Tickets\MoveProjectTicket; use App\Actions\Tickets\MoveProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class MoveTicketStatus extends Tool class MoveTicketStatus extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly MoveProjectTicket $moveProjectTicket, private readonly MoveProjectTicket $moveProjectTicket,
+83
View File
@@ -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.'),
];
}
}
+75
View File
@@ -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
View File
@@ -3,6 +3,7 @@
namespace App\Mcp\Tools; namespace App\Mcp\Tools;
use App\Actions\Tickets\UpdateProjectTicket; use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class UpdateTicketAssignee extends Tool class UpdateTicketAssignee extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket, private readonly UpdateProjectTicket $updateProjectTicket,
+9 -3
View File
@@ -2,10 +2,14 @@
namespace App\Mcp\Tools; namespace App\Mcp\Tools;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Actions\Tickets\UpdateProjectTicket; use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use App\Rules\VisualTicketDescription;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request; use Laravel\Mcp\Request;
use Laravel\Mcp\Response; use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\ResponseFactory;
@@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class UpdateTicketDescription extends Tool class UpdateTicketDescription extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket, private readonly UpdateProjectTicket $updateProjectTicket,
@@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool
public function handle(Request $request): Response|ResponseFactory public function handle(Request $request): Response|ResponseFactory
{ {
$validated = $request->validate([ $validated = $request->validate([
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string', new VisualTicketDescription],
'ticket_id' => ['required', 'integer'], 'ticket_id' => ['required', 'integer'],
]); ]);
@@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
$payload = [ $payload = [
...$this->kanbanGitMcp->updatePayload($ticket), ...$this->kanbanGitMcp->updatePayload($ticket),
'description' => $validated['description'] ?? null, ...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
]; ];
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload); $wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
@@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool
return [ return [
'description' => $schema->string() 'description' => $schema->string()
->nullable() ->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() 'ticket_id' => $schema->integer()
->description('Ticket ID.') ->description('Ticket ID.')
->required(), ->required(),
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; 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 class BoardColumn extends Model
{ {
/** @use HasFactory<BoardColumnFactory> */ /** @use HasFactory<BoardColumnFactory> */
@@ -5,11 +5,44 @@ namespace App\Models\Concerns;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate; use App\Models\ProjectTemplate;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
trait SyncsProjectTemplateOwnership 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 protected function syncProjectTemplateOwnership(): void
{ {
if ($this->getAttribute('configuration_key') === null) {
$this->setAttribute('configuration_key', (string) Str::uuid());
}
$projectTemplateId = $this->getAttribute('project_template_id'); $projectTemplateId = $this->getAttribute('project_template_id');
$projectId = $this->getAttribute('project_id'); $projectId = $this->getAttribute('project_id');
+1 -1
View File
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([ #[Fillable(['configuration_key',
'channel', 'channel',
'channel_configuration', 'channel_configuration',
'delivery_url', 'delivery_url',
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable(['configuration_key',
'base_url', 'base_url',
'owner_name', 'owner_name',
'project_id', 'project_id',
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; 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 class TicketFieldDefinition extends Model
{ {
/** /**
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; 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 class TicketLinkType extends Model
{ {
/** @use HasFactory<TicketLinkTypeFactory> */ /** @use HasFactory<TicketLinkTypeFactory> */
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; 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 class TicketPriority extends Model
{ {
/** @use HasFactory<TicketPriorityFactory> */ /** @use HasFactory<TicketPriorityFactory> */
+1 -1
View File
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str; 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 class TicketStatus extends Model
{ {
/** @use HasFactory<TicketStatusFactory> */ /** @use HasFactory<TicketStatusFactory> */
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; 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 class TicketType extends Model
{ {
/** @use HasFactory<TicketTypeFactory> */ /** @use HasFactory<TicketTypeFactory> */
@@ -30,6 +30,9 @@ class ProjectConfigurationObserver
public function deleted(Model $model): void public function deleted(Model $model): void
{ {
$this->markOverride($model); $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 private function markOverride(Model $model): void
+21
View File
@@ -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\BoardColumn;
use App\Models\Project; use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<BoardColumn> * @extends Factory<BoardColumn>
@@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'name' => fake()->unique()->words(2, true), 'name' => fake()->unique()->words(2, true),
'position' => 1, 'position' => 1,
'project_id' => Project::factory(), 'project_id' => Project::factory(),
@@ -7,6 +7,7 @@ use App\Enums\NotificationEventType;
use App\Models\NotificationAction; use App\Models\NotificationAction;
use App\Models\Project; use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<NotificationAction> * @extends Factory<NotificationAction>
@@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'channel' => NotificationChannel::Webhook, 'channel' => NotificationChannel::Webhook,
'channel_configuration' => [], 'channel_configuration' => [],
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(), '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\Project;
use App\Models\RepositoryConnection; use App\Models\RepositoryConnection;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<RepositoryConnection> * @extends Factory<RepositoryConnection>
@@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'base_url' => 'https://git.'.fake()->domainName(), 'base_url' => 'https://git.'.fake()->domainName(),
'owner_name' => fake()->userName(), 'owner_name' => fake()->userName(),
'project_id' => Project::factory(), 'project_id' => Project::factory(),
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketFieldDefinition; use App\Models\TicketFieldDefinition;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketFieldDefinition> * @extends Factory<TicketFieldDefinition>
@@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES), 'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
'label' => fake()->unique()->words(2, true), 'label' => fake()->unique()->words(2, true),
'position' => 1, 'position' => 1,
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketLinkType; use App\Models\TicketLinkType;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketLinkType> * @extends Factory<TicketLinkType>
@@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory
$label = fake()->unique()->words(2, true); $label = fake()->unique()->words(2, true);
return [ return [
'configuration_key' => (string) Str::uuid(),
'label' => $label, 'label' => $label,
'counterpart_label' => $label, 'counterpart_label' => $label,
'position' => 1, 'position' => 1,
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketPriority; use App\Models\TicketPriority;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketPriority> * @extends Factory<TicketPriority>
@@ -19,6 +20,7 @@ class TicketPriorityFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']), 'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
'position' => 1, 'position' => 1,
'project_id' => Project::factory(), 'project_id' => Project::factory(),
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketStatus> * @extends Factory<TicketStatus>
@@ -19,6 +20,7 @@ class TicketStatusFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'board_column_id' => null, 'board_column_id' => null,
'name' => fake()->unique()->words(2, true), 'name' => fake()->unique()->words(2, true),
'position' => 1, 'position' => 1,
+2
View File
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketType; use App\Models\TicketType;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketType> * @extends Factory<TicketType>
@@ -19,6 +20,7 @@ class TicketTypeFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'is_parent' => false, 'is_parent' => false,
'name' => fake()->unique()->jobTitle(), 'name' => fake()->unique()->jobTitle(),
'position' => 1, 'position' => 1,
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.2.6", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -25,5 +25,5 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@tauri-apps/cli": "2.11.4"
}, },
"releaseNotes": "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."
} }
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.6" version = "0.2.8"
dependencies = [ dependencies = [
"semver", "semver",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.6" version = "0.2.8"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -43,6 +43,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private var settingsAvailable = false private var settingsAvailable = false
private var toolbarHeight: NSLayoutConstraint! private var toolbarHeight: NSLayoutConstraint!
private var appearance: UISegmentedControl! private var appearance: UISegmentedControl!
private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)?
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
@@ -51,6 +52,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
deinit { javaScriptConfirmation?.completion(false) }
private func text(_ key: String) -> String { private func text(_ key: String) -> String {
NSLocalizedString(key, comment: "KanbanGit iOS workspace") NSLocalizedString(key, comment: "KanbanGit iOS workspace")
} }
@@ -199,6 +202,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
func open(_ url: URL) { func open(_ url: URL) {
if server?.url == url, connection.ready { showWorkspace(); return } if server?.url == url, connection.ready { showWorkspace(); return }
completeJavaScriptConfirmation(false)
let selected = WorkspaceServer.selecting(url, current: server) let selected = WorkspaceServer.selecting(url, current: server)
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return } guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey) defaults.set(data, forKey: serverKey)
@@ -265,6 +269,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
@objc private func showSettings() { @objc private func showSettings() {
completeJavaScriptConfirmation(false)
loading.stopAnimating() loading.stopAnimating()
view.endEditing(true) view.endEditing(true)
launcher.isHidden = false launcher.isHidden = false
@@ -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) { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
guard server?.allows(url) == true else { guard server?.allows(url) == true else {
@@ -9,6 +9,7 @@
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server."; "switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
"confirmSwitch" = "Switch server"; "confirmSwitch" = "Switch server";
"cancel" = "Cancel"; "cancel" = "Cancel";
"ok" = "OK";
"connecting" = "Connecting to your server…"; "connecting" = "Connecting to your server…";
"connected" = "Your workspace is open. Use Server settings to change servers."; "connected" = "Your workspace is open. Use Server settings to change servers.";
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept."; "connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
@@ -135,6 +135,94 @@ final class WorkspaceTests: XCTestCase {
"Changing workspaces must retain native gesture navigation") "Changing workspaces must retain native gesture navigation")
} }
@MainActor
func testDirtyFormNavigationPresentsNativeConfirmation() async throws {
let animationsEnabled = UIView.areAnimationsEnabled
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(animationsEnabled) }
let name = "ios-confirmation-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true; window.rootViewController = nil }
let url = URL(string: "https://example.invalid/")!
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
<a id="back" href="#back">Back</a><script>
window.visits = 0;
document.querySelector('#back').onclick = event => {
event.preventDefault();
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
};
</script></body></html>
""")
try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'")
_ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true")
let pristineVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(pristineVisits as? Int, 1)
XCTAssertNil(controller.presentedViewController)
_ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true")
var expectedVisits = 1
for (index, accepted) in [false, true, false, true].enumerated() {
let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
let alert = try await waitForConfirmation(controller)
XCTAssertEqual(alert.title, "example.invalid")
XCTAssertEqual(alert.message, "Discard unsaved changes?")
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default])
XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft")
if index == 3 {
await withCheckedContinuation { continuation in
alert.dismiss(animated: false) { continuation.resume() }
}
}
controller.completeJavaScriptConfirmation(accepted)
controller.completeJavaScriptConfirmation(!accepted)
_ = try await click.value
if accepted { expectedVisits += 1 }
let visits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each")
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
XCTAssertEqual(draft as? String, "Unsaved project")
XCTAssertNil(controller.presentedViewController)
}
let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert)
controller.present(otherAlert, animated: false)
let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')")
XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript")
XCTAssertTrue(controller.presentedViewController === otherAlert)
await withCheckedContinuation { continuation in
otherAlert.dismiss(animated: false) { continuation.resume() }
}
let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
_ = try await waitForConfirmation(controller)
controller.open(URL(string: "https://other.invalid/")!)
_ = try await pendingClick.value
XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation")
let oldVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(oldVisits as? Int, expectedVisits)
let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')")
XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations")
XCTAssertNil(controller.presentedViewController)
}
@MainActor
private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController {
for _ in 0..<100 {
if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert }
try await Task.sleep(for: .milliseconds(50))
}
return try XCTUnwrap(controller.presentedViewController as? UIAlertController,
"Dirty forms must offer a visible confirmation instead of silently cancelling navigation")
}
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws { func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
for body in [ for body in [
#"{"version":2,"id":1,"action":"getState","payload":{}}"#, #"{"version":2,"id":1,"action":"getState","payload":{}}"#,
@@ -33,6 +33,13 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
remoteInfo = "kanbangit-native_iOS";
};
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = { 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */; containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
@@ -48,7 +55,6 @@
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; }; 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; }; 1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; }; 2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -67,6 +73,7 @@
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; }; 946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; }; 9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; };
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; }; AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; }; BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
@@ -189,7 +196,7 @@
59D2DE9FD94E6D2D64CB0117 /* Products */ = { 59D2DE9FD94E6D2D64CB0117 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */, 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */,
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */, A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */, 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
); );
@@ -248,7 +255,7 @@
packageProductDependencies = ( packageProductDependencies = (
); );
productName = "kanbangit-native_iOS"; productName = "kanbangit-native_iOS";
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */; productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = { 988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
@@ -279,6 +286,7 @@
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
); );
name = WorkspaceTests; name = WorkspaceTests;
packageProductDependencies = ( packageProductDependencies = (
@@ -418,6 +426,11 @@
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */; target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */; targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
}; };
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
@@ -451,7 +464,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = 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=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.6; MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -628,7 +641,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = 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=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.6; MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -691,6 +704,7 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
}; };
name = debug; name = debug;
}; };
@@ -710,6 +724,7 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
}; };
name = release; name = release;
}; };
+6 -2
View File
@@ -18,8 +18,8 @@ settingGroups:
DEVELOPMENT_TEAM: ZZX2KZGTHU DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
MARKETING_VERSION: "0.2.6" MARKETING_VERSION: "0.2.8"
CURRENT_PROJECT_VERSION: "6" CURRENT_PROJECT_VERSION: "7"
configs: configs:
debug: debug:
CODE_SIGN_IDENTITY: Apple Development CODE_SIGN_IDENTITY: Apple Development
@@ -40,6 +40,7 @@ targets:
kanbangit-native_iOS: kanbangit-native_iOS:
type: application type: application
platform: iOS platform: iOS
productName: KanbanGit
sources: sources:
- path: Sources - path: Sources
- path: ../../../ui/workspace-navigation.js - path: ../../../ui/workspace-navigation.js
@@ -115,6 +116,8 @@ targets:
WorkspaceTests: WorkspaceTests:
type: bundle.unit-test type: bundle.unit-test
platform: iOS platform: iOS
dependencies:
- target: kanbangit-native_iOS
sources: sources:
- path: ../../../ui/workspace-navigation.js - path: ../../../ui/workspace-navigation.js
- path: ../../../ui/workspace-settings.js - path: ../../../ui/workspace-settings.js
@@ -126,6 +129,7 @@ targets:
base: base:
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
DEVELOPMENT_TEAM: ZZX2KZGTHU DEVELOPMENT_TEAM: ZZX2KZGTHU
CODE_SIGN_STYLE: Manual CODE_SIGN_STYLE: Manual
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
+49
View File
@@ -1,6 +1,55 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h> #import <WebKit/WebKit.h>
#import <objc/runtime.h>
@interface KanbanWorkspaceUIDelegate : NSObject <WKUIDelegate>
@property(nonatomic, strong) id<WKUIDelegate> originalDelegate;
@end
@implementation KanbanWorkspaceUIDelegate
- (BOOL)respondsToSelector:(SEL)selector {
return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector];
}
- (id)forwardingTargetForSelector:(SEL)selector {
if ([self.originalDelegate respondsToSelector:selector]) {
return self.originalDelegate;
}
return [super forwardingTargetForSelector:selector];
}
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
NSWindow *window = webView.window;
if (!window || window.attachedSheet) {
completionHandler(NO);
return;
}
NSAlert *alert = [NSAlert new];
alert.alertStyle = NSAlertStyleWarning;
alert.messageText = frame.request.URL.host ?: @"KanbanGit";
alert.informativeText = message;
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"];
[alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
completionHandler(response == NSAlertFirstButtonReturn);
}];
}
@end
static char workspaceUIDelegateKey;
void install_workspace_navigation(void *pointer) { void install_workspace_navigation(void *pointer) {
WKWebView *webview = (__bridge WKWebView *)pointer; WKWebView *webview = (__bridge WKWebView *)pointer;
webview.allowsBackForwardNavigationGestures = YES; webview.allowsBackForwardNavigationGestures = YES;
if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) {
return;
}
KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new];
delegate.originalDelegate = webview.UIDelegate;
// WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view.
objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
webview.UIDelegate = delegate;
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit", "productName": "KanbanGit",
"version": "0.2.6", "version": "0.2.8",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -25,7 +25,7 @@
], ],
"macOS": { "macOS": {
"minimumSystemVersion": "14.0", "minimumSystemVersion": "14.0",
"bundleVersion": "11", "bundleVersion": "12",
"dmg": { "dmg": {
"windowSize": { "windowSize": {
"width": 660, "width": 660,
+1 -1
View File
@@ -5,7 +5,7 @@
"iOS": { "iOS": {
"minimumSystemVersion": "17.0", "minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU", "developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "6" "bundleVersion": "7"
} }
} }
} }
+92 -1
View File
@@ -17,6 +17,53 @@ static void waitUntil(BOOL (^condition)(void), NSString *message) {
check(condition(), message); check(condition(), message);
} }
static id evaluate(WKWebView *webview, NSString *script) {
__block BOOL finished = NO;
__block id result;
[webview evaluateJavaScript:script completionHandler:^(id value, NSError *error) {
check(error == nil, error.localizedDescription);
result = value;
finished = YES;
}];
waitUntil(^BOOL { return finished; }, @"JavaScript must finish after the confirmation is dismissed");
return result;
}
static NSButton *findButton(NSView *view, NSString *title) {
if ([view isKindOfClass:NSButton.class] && [((NSButton *)view).title isEqualToString:title]) {
return (NSButton *)view;
}
for (NSView *child in view.subviews) {
NSButton *button = findButton(child, title);
if (button) { return button; }
}
return nil;
}
static BOOL containsText(NSView *view, NSString *text) {
if ([view isKindOfClass:NSTextField.class] && [((NSTextField *)view).stringValue isEqualToString:text]) {
return YES;
}
for (NSView *child in view.subviews) {
if (containsText(child, text)) { return YES; }
}
return NO;
}
@interface WorkspaceUI : NSObject <WKUIDelegate>
@property(nonatomic) BOOL closed;
@property(nonatomic) BOOL uploaded;
@end
@implementation WorkspaceUI
- (void)webViewDidClose:(WKWebView *)webView { self.closed = YES; }
- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray<NSURL *> *))completionHandler {
self.uploaded = YES;
completionHandler(nil);
}
@end
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate> @interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
@property(nonatomic) NSUInteger finished; @property(nonatomic) NSUInteger finished;
@end @end
@@ -46,9 +93,18 @@ int main(void) {
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"]; [configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration]; WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
webview.navigationDelegate = pages; webview.navigationDelegate = pages;
WorkspaceUI *originalUI = [WorkspaceUI new];
webview.UIDelegate = originalUI;
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures"); check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
install_workspace_navigation((__bridge void *)webview); install_workspace_navigation((__bridge void *)webview);
id<WKUIDelegate> installedUI = webview.UIDelegate;
install_workspace_navigation((__bridge void *)webview); install_workspace_navigation((__bridge void *)webview);
check(webview.UIDelegate == installedUI, @"Installing workspace behavior twice must retain the same delegate");
check([installedUI respondsToSelector:@selector(webViewDidClose:)], @"Existing popup callbacks must remain available");
[installedUI webViewDidClose:webview];
check(originalUI.closed, @"Existing popup callbacks must reach the original delegate");
check([installedUI respondsToSelector:@selector(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:)],
@"Existing file upload callbacks must remain available");
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures"); check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter"); check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate"); check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
@@ -69,6 +125,41 @@ int main(void) {
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward, check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
@"Forward must restore the second page"); @"Forward must restore the second page");
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation"); check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
window.releasedWhenClosed = NO;
window.title = @"KanbanGit navigation test";
window.contentView = webview;
[window orderFront:nil];
evaluate(webview, @"document.body.innerHTML = `<input id='duplicate' type='radio'><input id='draft' value='Unsaved project'><input id='upload' type='file'><a id='back' href='#back'>Back</a>`; window.visits = 0; document.querySelector('#back').onclick = event => { event.preventDefault(); if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++; }; true;");
evaluate(webview, @"document.querySelector('#upload').click()");
waitUntil(^BOOL { return originalUI.uploaded; }, @"File selection must still reach the original UI delegate");
evaluate(webview, @"document.querySelector('#back').click()");
check([evaluate(webview, @"window.visits") intValue] == 1, @"Pristine forms must navigate without a confirmation");
evaluate(webview, @"document.querySelector('#duplicate').click()");
for (NSString *choice in @[@"Cancel", @"OK", @"Cancel", @"OK"]) {
__block BOOL finished = NO;
[webview evaluateJavaScript:@"document.querySelector('#back').click()" completionHandler:^(id result, NSError *error) {
check(error == nil, error.localizedDescription);
finished = YES;
}];
waitUntil(^BOOL { return window.attachedSheet != nil; }, @"Dirty form navigation must show a native confirmation");
check(!finished, @"The original click must wait for the user's decision");
check(containsText(window.attachedSheet.contentView, @"workspace"), @"The confirmation must identify the requesting website");
check(containsText(window.attachedSheet.contentView, @"Discard unsaved changes?"), @"The confirmation must show the application's message");
NSButton *button = findButton(window.attachedSheet.contentView, choice);
check(button != nil, @"The native confirmation must offer OK and Cancel");
[button performClick:nil];
waitUntil(^BOOL { return finished && window.attachedSheet == nil; }, @"Dismissal must release the original navigation");
check([evaluate(webview, @"document.querySelector('#draft').value") isEqualToString:@"Unsaved project"],
@"Cancelling a confirmation must preserve the draft");
}
check([evaluate(webview, @"window.visits") intValue] == 3, @"Only accepted visits must run, exactly once each");
window.contentView = nil;
check(![evaluate(webview, @"confirm('Detached view')") boolValue], @"A detached view must cancel and release JavaScript immediately");
[window orderOut:nil];
[window close];
puts("PASS: WebKit interactive gesture configuration, full-page back/forward, native confirmations and delegate forwarding");
} }
} }
+2 -2
View File
@@ -8,7 +8,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
test( 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' }, { skip: process.platform !== 'darwin' },
() => { () => {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-')); const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
@@ -48,7 +48,7 @@ test(
assert.equal( assert.equal(
result.status, result.status,
0, 0,
result.stderr || result.error?.message, result.stderr || result.error?.message || result.signal,
); );
assert.match(result.stdout, /PASS: WebKit/); assert.match(result.stdout, /PASS: WebKit/);
} finally { } finally {
+6 -1
View File
@@ -48,6 +48,11 @@
"dependencies": { "dependencies": {
"@inertiajs/core": "2.3.18", "@inertiajs/core": "2.3.18",
"@inertiajs/vue3": "^2.3.7", "@inertiajs/vue3": "^2.3.7",
"@tiptap/core": "^3.31.3",
"@tiptap/markdown": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@tiptap/vue-3": "^3.31.3",
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -57,7 +62,7 @@
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1", "tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5", "tw-animate-css": "^1.2.5",
"vue": "^3.5.13", "vue": "^3.5.43",
"vue-input-otp": "^0.3.2" "vue-input-otp": "^0.3.2"
}, },
"optionalDependencies": { "optionalDependencies": {
+620 -121
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -8,9 +8,15 @@ allowBuilds:
unrs-resolver: true unrs-resolver: true
vue-demi: true vue-demi: true
overrides: overrides:
'@vue/server-renderer@<3.5.42': ^3.5.42
axios@>=1.13.0 <1.20.0: ^1.20.0 axios@>=1.13.0 <1.20.0: ^1.20.0
axios@>=1.15.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.1 <1.20.0: ^1.20.0
axios@>=1.15.2 <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.16.1 <1.20.0: ^1.20.0
axios@>=1.17.0 <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
+1
View File
@@ -1,6 +1,7 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './theme.css'; @import './theme.css';
@import './ticket-description.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
+71
View File
@@ -0,0 +1,71 @@
.ticket-description {
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: var(--text-sm);
line-height: 1.75;
}
.ticket-description > * + * {
margin-top: 0.75rem;
}
.ticket-description h1 {
font-size: 1.5rem;
font-weight: 700;
}
.ticket-description h2 {
font-size: 1.25rem;
font-weight: 700;
}
.ticket-description h3,
.ticket-description h4,
.ticket-description h5,
.ticket-description h6 {
font-size: 1.125rem;
font-weight: 600;
}
.ticket-description ul {
list-style: disc;
padding-left: 1.5rem;
}
.ticket-description ol {
list-style: decimal;
padding-left: 1.5rem;
}
.ticket-description li > ul,
.ticket-description li > ol {
margin-top: 0.25rem;
}
.ticket-description blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
color: var(--color-muted-foreground);
}
.ticket-description a {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 3px;
}
.ticket-description code {
border-radius: 0.25rem;
background: var(--color-muted);
padding: 0.125rem 0.25rem;
font-size: 0.875em;
}
.ticket-description pre {
max-width: 100%;
overflow-x: auto;
border-radius: 0.375rem;
background: var(--color-muted);
padding: 0.75rem;
white-space: pre;
}
.ticket-description pre code {
padding: 0;
background: none;
}
.ticket-description hr {
border-color: var(--color-border);
margin-block: 1rem;
}
.ticket-description:focus-visible {
outline: none;
}
@@ -3,7 +3,6 @@ import { Link, useForm } from '@inertiajs/vue3';
import { Layers3, LockKeyhole } from 'lucide-vue-next'; import { Layers3, LockKeyhole } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -12,7 +11,13 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas'; 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 { show } from '@/routes/projects';
import { store } from '@/routes/projects/templates'; import { store } from '@/routes/projects/templates';
import type { ProjectDuplicateCopyAreaOption } from '@/types'; import type { ProjectDuplicateCopyAreaOption } from '@/types';
@@ -22,11 +27,18 @@ import type { ProjectTemplateModel } from '@/types/models/project-template-model
const props = defineProps<{ const props = defineProps<{
copyAreaOptions: ProjectDuplicateCopyAreaOption[]; copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project?: ProjectModel; project?: ProjectModel | null;
initialConfiguration?: Record<string, unknown>;
template?: ProjectTemplateModel; template?: ProjectTemplateModel;
}>(); }>();
const { t } = useLocalization(); const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas(); 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 source = computed(() => props.template?.source_project ?? props.project);
const linkedCount = computed(() => props.template?.linked_project_count ?? 0); const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
const form = useForm( const form = useForm(
@@ -34,14 +46,23 @@ const form = useForm(
{ {
name: name:
props.template?.name ?? props.template?.name ??
t('templates.defaultName', { project: props.project?.name ?? '' }), (props.project
? t('templates.defaultName', { project: props.project.name })
: ''),
slug: slug:
props.template?.slug ?? props.template?.slug ??
`${props.project?.key.toLowerCase()}-template`, publishingBase.value?.slug ??
(props.project
? `${props.project.key.toLowerCase()}-template`
: ''),
copy_areas: props.template copy_areas: props.template
? [...props.template.copy_areas] ? [...props.template.copy_areas]
: props.copyAreaOptions.map((area) => area.value), : props.copyAreaOptions.map((area) => area.value),
confirm_linked_projects_update: false, confirm_linked_projects_update: false,
configuration_snapshot: createTemplateConfiguration(
props.template?.configuration_snapshot ??
props.initialConfiguration,
),
}, },
); );
const protection = useFormProtection(form); const protection = useFormProtection(form);
@@ -53,13 +74,10 @@ const copyAreasError = computed(
)?.[1], )?.[1],
); );
const cancelTarget = computed(() => const cancelTarget = computed(() =>
props.template ? index() : show(props.project!.id), props.project ? show(props.project.id) : index(),
); );
function submit(): void { function submit(): void {
if ( if (linkedCount.value > 0 && !form.confirm_linked_projects_update) {
!source.value ||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
) {
return; return;
} }
form.transform((data) => form.transform((data) =>
@@ -67,6 +85,7 @@ function submit(): void {
? { ? {
name: data.name, name: data.name,
copy_areas: data.copy_areas, copy_areas: data.copy_areas,
configuration_snapshot: data.configuration_snapshot,
...(linkedCount.value > 0 ...(linkedCount.value > 0
? { ? {
confirm_linked_projects_update: 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( 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 }, { preserveScroll: true },
); );
} }
@@ -90,12 +120,6 @@ function submit(): void {
@submit.prevent="submit" @submit.prevent="submit"
> >
<FormFeedback :state="protection.state.value" @retry="submit" /> <FormFeedback :state="protection.state.value" @retry="submit" />
<FeedbackState
v-if="!source"
state="error"
:title="t('templates.missingSource')"
:description="t('templates.missingSourceHelp')"
/>
<div <div
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]" 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" name="name"
required required
maxlength="255" maxlength="255"
:disabled="form.processing || !source" :disabled="form.processing"
:placeholder="t('templates.namePlaceholder')" :placeholder="t('templates.namePlaceholder')"
:invalid="!!form.errors.name" :invalid="!!form.errors.name"
aria-describedby="name-error" aria-describedby="name-error"
@@ -139,7 +163,11 @@ function submit(): void {
name="slug" name="slug"
required required
maxlength="255" maxlength="255"
:disabled="!!template || form.processing || !source" :disabled="
!!template ||
!!publishingBase ||
form.processing
"
:placeholder="t('templates.slugPlaceholder')" :placeholder="t('templates.slugPlaceholder')"
:invalid="!!form.errors.slug" :invalid="!!form.errors.slug"
aria-describedby="slug-help slug-error" aria-describedby="slug-help slug-error"
@@ -152,7 +180,7 @@ function submit(): void {
> >
{{ {{
t( t(
template template || publishingBase
? 'templates.slugLocked' ? 'templates.slugLocked'
: 'templates.slugHelp', : 'templates.slugHelp',
) )
@@ -166,7 +194,7 @@ function submit(): void {
</section> </section>
<fieldset <fieldset
class="bg-card min-w-0 rounded-md border p-5" 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" aria-describedby="areas-help copy-areas-error"
> >
<legend class="text-title px-1"> <legend class="text-title px-1">
@@ -231,6 +259,14 @@ function submit(): void {
:message="copyAreasError" :message="copyAreasError"
/> />
</fieldset> </fieldset>
<TemplateConfigurationEditor
v-if="template || !project"
v-model="form.configuration_snapshot"
:areas="form.copy_areas"
:options="copyAreaOptions"
:errors="form.errors"
:disabled="form.processing"
/>
</div> </div>
<aside <aside
class="bg-card gap-column grid min-w-0 rounded-md border p-5" class="bg-card gap-column grid min-w-0 rounded-md border p-5"
@@ -310,7 +346,7 @@ function submit(): void {
id="confirm-linked" id="confirm-linked"
v-model="form.confirm_linked_projects_update" v-model="form.confirm_linked_projects_update"
type="checkbox" type="checkbox"
:disabled="form.processing || !source" :disabled="form.processing"
:aria-invalid="!!form.errors.confirm_linked_projects_update" :aria-invalid="!!form.errors.confirm_linked_projects_update"
aria-describedby="linked-help linked-error" aria-describedby="linked-help linked-error"
class="accent-primary mt-0.5 size-5 shrink-0" class="accent-primary mt-0.5 size-5 shrink-0"
@@ -335,8 +371,7 @@ function submit(): void {
:loading="form.processing" :loading="form.processing"
:loading-label="t('forms.saving')" :loading-label="t('forms.saving')"
:disabled=" :disabled="
!source || linkedCount > 0 && !form.confirm_linked_projects_update
(linkedCount > 0 && !form.confirm_linked_projects_update)
" "
>{{ >{{
t(template ? 'templates.saveChanges' : 'templates.saveNew') 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 { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import type { TemplateResetForm } from '@/types/project-template-configuration';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types'; import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
const props = defineProps<{ const props = defineProps<{
@@ -27,10 +28,7 @@ const props = defineProps<{
templateState?: ProjectTemplateState | null; templateState?: ProjectTemplateState | null;
}>(); }>();
const { t } = useLocalization(); const { t } = useLocalization();
const form = useForm<{ const form = useForm<TemplateResetForm>({
areas: string[];
status_replacements: Record<string, number | undefined>;
}>({
areas: [], areas: [],
status_replacements: {}, status_replacements: {},
}); });
@@ -245,6 +243,15 @@ function resetArea(): void {
v-if="statusRequirement" v-if="statusRequirement"
class="gap-column grid min-w-0" 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"> <p class="text-caption text-muted-foreground">
{{ t('inheritance.statusHelp') }} {{ t('inheritance.statusHelp') }}
</p> </p>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, defineComponent, h } from 'vue';
import {
parseDescription,
renderDescriptionNode,
supportsVisualDescription,
} from '@/lib/ticket-description';
const props = defineProps<{ description: string }>();
const document = computed(() =>
supportsVisualDescription(props.description)
? parseDescription(props.description)
: null,
);
const Content = defineComponent(
() => () =>
document.value
? h('div', { class: 'ticket-description' }, [
renderDescriptionNode(document.value),
])
: h(
'p',
{
class: 'wrap-anywhere whitespace-pre-wrap text-sm leading-7',
},
props.description,
),
);
</script>
<template>
<Content />
</template>
@@ -0,0 +1,386 @@
<script setup lang="ts">
import { EditorContent, useEditor } from '@tiptap/vue-3';
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
SquareCode,
Link,
Unlink,
Undo2,
Redo2,
} from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import {
descriptionExtensions,
parseDescription,
safeDescriptionLink,
supportsVisualDescription,
} from '@/lib/ticket-description';
import type {
DescriptionToolbarAction,
TicketDescriptionEditorProps,
} from '@/types/ticket-description';
const props = defineProps<TicketDescriptionEditorProps>();
const model = defineModel<string>({ required: true });
const { t } = useLocalization();
const sourceMode = ref(!supportsVisualDescription(model.value));
const supported = computed(() => supportsVisualDescription(model.value));
const visual = computed(() => !sourceMode.value && supported.value);
const linkOpen = ref(false);
const linkUrl = ref('');
const linkError = ref('');
const linkInput = ref<InstanceType<typeof Input> | null>(null);
let currentMarkdown = model.value;
const editor = useEditor({
extensions: descriptionExtensions(),
content: supported.value ? parseDescription(model.value) : '',
editable: !props.disabled,
editorProps: {
attributes: {
id: props.id,
role: 'textbox',
'aria-multiline': 'true',
'aria-labelledby': `${props.id}-label`,
'aria-describedby': `${props.id}-hint ${props.id}-error`,
'aria-invalid': String(Boolean(props.error)),
'aria-disabled': String(Boolean(props.disabled)),
class: 'ticket-description min-h-48 px-3 py-3 outline-none',
},
},
onUpdate: ({ editor: instance }) => {
currentMarkdown = instance.isEmpty ? '' : instance.getMarkdown();
model.value = currentMarkdown;
},
});
watch(model, (value) => {
if (value === currentMarkdown) {
return;
}
currentMarkdown = value;
if (supportsVisualDescription(value)) {
editor.value?.commands.setContent(parseDescription(value), {
emitUpdate: false,
});
}
});
watch(
() => props.disabled,
(disabled) => {
editor.value?.setEditable(!disabled, false);
if (disabled) {
linkOpen.value = false;
}
},
);
watch([() => props.error, () => props.disabled], () => {
editor.value?.setOptions({
editorProps: {
attributes: {
...editor.value.options.editorProps.attributes,
'aria-invalid': String(Boolean(props.error)),
'aria-disabled': String(Boolean(props.disabled)),
},
},
});
});
const actions: DescriptionToolbarAction[] = [
{
label: 'descriptionEditor.bold',
icon: Bold,
active: 'bold',
run: (c) => c.toggleBold(),
},
{
label: 'descriptionEditor.italic',
icon: Italic,
active: 'italic',
run: (c) => c.toggleItalic(),
},
{
label: 'descriptionEditor.strike',
icon: Strikethrough,
active: 'strike',
run: (c) => c.toggleStrike(),
},
{
label: 'descriptionEditor.heading1',
icon: Heading1,
active: 'heading',
attributes: { level: 1 },
run: (c) => c.toggleHeading({ level: 1 }),
},
{
label: 'descriptionEditor.heading2',
icon: Heading2,
active: 'heading',
attributes: { level: 2 },
run: (c) => c.toggleHeading({ level: 2 }),
},
{
label: 'descriptionEditor.heading3',
icon: Heading3,
active: 'heading',
attributes: { level: 3 },
run: (c) => c.toggleHeading({ level: 3 }),
},
{
label: 'descriptionEditor.bulletList',
icon: List,
active: 'bulletList',
run: (c) => c.toggleBulletList(),
},
{
label: 'descriptionEditor.orderedList',
icon: ListOrdered,
active: 'orderedList',
run: (c) => c.toggleOrderedList(),
},
{
label: 'descriptionEditor.quote',
icon: Quote,
active: 'blockquote',
run: (c) => c.toggleBlockquote(),
},
{
label: 'descriptionEditor.code',
icon: Code,
active: 'code',
run: (c) => c.toggleCode(),
},
{
label: 'descriptionEditor.codeBlock',
icon: SquareCode,
active: 'codeBlock',
run: (c) => c.toggleCodeBlock(),
},
{ label: 'descriptionEditor.undo', icon: Undo2, run: (c) => c.undo() },
{ label: 'descriptionEditor.redo', icon: Redo2, run: (c) => c.redo() },
];
function runAction(action: DescriptionToolbarAction): void {
if (editor.value && !props.disabled) {
action.run(editor.value.chain().focus()).run();
}
}
function switchMode(): void {
linkOpen.value = false;
sourceMode.value = !sourceMode.value;
}
async function editLink(): Promise<void> {
linkUrl.value = editor.value?.getAttributes('link').href ?? '';
linkError.value = '';
linkOpen.value = true;
await nextTick();
linkInput.value?.$el?.focus();
}
function saveLink(): void {
if (!editor.value || props.disabled) {
return;
}
const href = safeDescriptionLink(linkUrl.value.trim());
if (!href) {
linkError.value = t('descriptionEditor.invalidLink');
return;
}
const chain = editor.value.chain().focus().extendMarkRange('link');
if (editor.value.state.selection.empty && !editor.value.isActive('link')) {
chain
.insertContent({
type: 'text',
text: href,
marks: [{ type: 'link', attrs: { href } }],
})
.run();
} else {
chain.setLink({ href }).run();
}
linkOpen.value = false;
}
function closeLink(): void {
linkOpen.value = false;
editor.value?.commands.focus();
}
</script>
<template>
<div class="grid min-w-0 gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<Label
:id="`${id}-label`"
:for="id"
@click="visual && editor?.commands.focus()"
>{{ label }}</Label
>
<Button
type="button"
variant="ghost"
size="sm"
:disabled="disabled || (!supported && !visual)"
:aria-pressed="sourceMode"
@click="switchMode"
>
{{
visual
? t('descriptionEditor.source')
: t('descriptionEditor.visual')
}}
</Button>
</div>
<div
class="border-input bg-card focus-within:border-ring focus-within:ring-ring/35 shadow-xs min-w-0 overflow-hidden rounded-md border focus-within:ring-2"
:class="{ 'border-destructive': error, 'opacity-60': disabled }"
>
<div
v-if="visual"
role="group"
:aria-label="t('descriptionEditor.formatting')"
class="bg-muted/30 flex flex-wrap gap-1 border-b p-2"
>
<Button
v-for="action in actions"
:key="action.label"
type="button"
size="icon-sm"
:variant="
action.active &&
editor?.isActive(action.active, action.attributes)
? 'secondary'
: 'ghost'
"
:title="t(action.label)"
:aria-label="t(action.label)"
:aria-pressed="
action.active
? Boolean(
editor?.isActive(
action.active,
action.attributes,
),
)
: undefined
"
:disabled="
disabled ||
!editor ||
!action.run(editor.can().chain()).run()
"
@mousedown.prevent
@click="runAction(action)"
>
<component
:is="action.icon"
class="size-4"
aria-hidden="true"
/>
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
:title="t('descriptionEditor.link')"
:aria-label="t('descriptionEditor.link')"
:aria-pressed="Boolean(editor?.isActive('link'))"
:disabled="
disabled ||
!editor ||
!editor.can().setLink({ href: 'https://example.com' })
"
@mousedown.prevent
@click="editLink"
><Link class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
:title="t('descriptionEditor.unlink')"
:aria-label="t('descriptionEditor.unlink')"
:disabled="disabled || !editor?.isActive('link')"
@mousedown.prevent
@click="
editor
?.chain()
.focus()
.extendMarkRange('link')
.unsetLink()
.run()
"
><Unlink class="size-4" aria-hidden="true"
/></Button>
</div>
<div
v-if="linkOpen && visual"
class="grid gap-2 border-b p-3"
@keydown.esc.prevent.stop="closeLink"
>
<Label :for="`${id}-link`">{{
t('descriptionEditor.linkUrl')
}}</Label>
<Input
:id="`${id}-link`"
ref="linkInput"
v-model="linkUrl"
type="text"
:disabled="disabled"
:invalid="Boolean(linkError)"
:aria-describedby="`${id}-link-error`"
placeholder="https://"
@keydown.enter.prevent.stop="saveLink"
/>
<InputError :id="`${id}-link-error`" :message="linkError" />
<div class="flex gap-2">
<Button
type="button"
size="sm"
:disabled="disabled"
@click="saveLink"
>{{ t('descriptionEditor.applyLink') }}</Button
>
<Button
type="button"
size="sm"
variant="ghost"
@click="closeLink"
>{{ t('common.cancel') }}</Button
>
</div>
</div>
<EditorContent v-if="visual && editor" :editor="editor" />
<textarea
v-else
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:placeholder="placeholder"
:aria-labelledby="`${id}-label`"
:aria-describedby="`${id}-hint ${id}-error`"
:aria-invalid="Boolean(error)"
class="min-h-48 w-full resize-y bg-transparent px-3 py-3 font-mono text-sm outline-none"
/>
</div>
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
{{
supported
? t('descriptionEditor.hint')
: t('descriptionEditor.unsupported')
}}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import TicketDescriptionEditor from '@/components/tickets/TicketDescriptionEditor.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue'; import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets'; import { ticketStatusLabel } from '@/lib/tickets';
@@ -72,10 +73,9 @@ const statusOptions = computed(
:placeholder="t('ticketForm.titlePlaceholder')" :placeholder="t('ticketForm.titlePlaceholder')"
required required
/> />
<TicketFormField <TicketDescriptionEditor
id="description" id="description"
v-model="description" v-model="description"
kind="textarea"
:label="t('ticketDetail.description')" :label="t('ticketDetail.description')"
:error="errors.description" :error="errors.description"
:disabled="disabled" :disabled="disabled"
@@ -13,6 +13,7 @@ import {
watch, watch,
} from 'vue'; } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import TicketDescription from '@/components/tickets/TicketDescription.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue'; import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
@@ -272,16 +273,12 @@ const submit = (): void => {
</h2></CardHeader </h2></CardHeader
> >
<CardContent> <CardContent>
<p <TicketDescription
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7" v-if="ticket.description"
:class="{ :description="ticket.description"
'text-muted-foreground': !ticket.description, />
}" <p v-else class="text-muted-foreground text-sm leading-7">
> {{ t('ticketDetail.noDescription') }}
{{
ticket.description ||
t('ticketDetail.noDescription')
}}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail'; import type {
TicketDetailRelationshipsProps,
TicketLinkFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>(); const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>(); defineEmits<{ editParent: [] }>();
@@ -33,7 +37,7 @@ const canAddLink = computed(
props.ticketLinkTypes.length > 0 && props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0, props.ticketLinkOptions.length > 0,
); );
const form = useForm({ const form = useForm<TicketLinkFormData>({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '', ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
link_direction: 'source', link_direction: 'source',
linked_ticket_id: '', linked_ticket_id: '',
@@ -41,10 +45,9 @@ const form = useForm({
const protection = useFormProtection(form, { const protection = useFormProtection(form, {
isDirty: () => props.canEditTicket && form.isDirty, isDirty: () => props.canEditTicket && form.isDirty,
}); });
const selectedType = computed(() => const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
props.ticketLinkTypes.find( () => props.ticketLinkTypes,
(type) => String(type.id) === String(form.ticket_link_type_id), form,
),
); );
const selectedTicket = computed(() => const selectedTicket = computed(() =>
props.ticketLinkOptions.find( props.ticketLinkOptions.find(
@@ -221,40 +224,18 @@ function submit(): void {
/> />
<fieldset <fieldset
:disabled="form.processing" :disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-3" class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
> >
<TicketFormField <TicketFormField
id="ticket_link_type_id" id="ticket_link_type_id"
v-model="form.ticket_link_type_id" v-model="selectedAction"
kind="select" kind="select"
:label="t('ticketDetail.linkType')" :label="t('ticketDetail.linkAction')"
:options=" :options="actions"
ticketLinkTypes.map((type) => ({ :error="
value: type.id, form.errors.ticket_link_type_id ||
label: `${type.label} / ${type.counterpart_label}`, form.errors.link_direction
}))
" "
:error="form.errors.ticket_link_type_id"
:disabled="form.processing"
required
/>
<TicketFormField
id="link_direction"
v-model="form.link_direction"
kind="select"
:label="t('ticketRelationships.direction')"
:options="[
{
value: 'source',
label: selectedType?.label ?? '',
},
{
value: 'counterpart',
label:
selectedType?.counterpart_label ?? '',
},
]"
:error="form.errors.link_direction"
:disabled="form.processing" :disabled="form.processing"
required required
/> />
@@ -281,26 +262,19 @@ function submit(): void {
/> />
</fieldset> </fieldset>
<p <p
v-if="selectedType && selectedTicket" v-if="selectedActionLabel && selectedTicket"
aria-live="polite" aria-live="polite"
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm" class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
> >
<span class="font-mono text-xs">{{ <span class="font-mono text-xs">{{
ticket.ticket_key ticket.ticket_key
}}</span }}</span
><span>{{ ><span>{{ selectedActionLabel }}</span
form.link_direction === 'source'
? selectedType.label
: selectedType.counterpart_label
}}</span
><ArrowRight class="size-4" aria-hidden="true" /><span ><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs" class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span >{{ selectedTicket.ticket_key }}</span
> >
</p> </p>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.directionHelp') }}
</p>
<Button <Button
type="submit" type="submit"
:disabled="form.processing || !selectedTicket" :disabled="form.processing || !selectedTicket"
@@ -1,10 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3'; import { Link, useForm } from '@inertiajs/vue3';
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next'; import { Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController'; import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
@@ -20,7 +18,6 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ removed: [] }>(); const emit = defineEmits<{ removed: [] }>();
const { t } = useLocalization(); const { t } = useLocalization();
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
const form = useForm({}); const form = useForm({});
const protection = useFormProtection(form); const protection = useFormProtection(form);
function remove(): void { function remove(): void {
@@ -47,27 +44,6 @@ function remove(): void {
:data-ticket-link="link.id" :data-ticket-link="link.id"
> >
<div class="flex min-w-0 flex-wrap items-center gap-2"> <div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge
variant="outline"
class="text-muted-foreground max-w-full whitespace-normal"
>
<ArrowUpRight
v-if="outgoing"
class="size-3.5 shrink-0"
aria-hidden="true"
/><ArrowDownLeft
v-else
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{{
t(
outgoing
? 'ticketRelationships.outgoing'
: 'ticketRelationships.incoming',
)
}}
</Badge>
<span class="text-muted-foreground font-mono text-xs">{{ <span class="text-muted-foreground font-mono text-xs">{{
ticketKey ticketKey
}}</span> }}</span>
@@ -0,0 +1,57 @@
import { computed, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type {
TicketLinkActionOption,
TicketLinkFormData,
} from '@/types/ticket-detail';
export function useTicketLinkActions(
ticketLinkTypes: MaybeRefOrGetter<TicketLinkTypeModel[]>,
form: TicketLinkFormData,
) {
const actions = computed<TicketLinkActionOption[]>(() =>
toValue(ticketLinkTypes).flatMap((type) => {
const choices: TicketLinkActionOption[] = [
{
value: `${type.id}:source`,
label: type.label,
ticketLinkTypeId: type.id,
direction: 'source',
},
];
if (
type.counterpart_label &&
type.counterpart_label !== type.label
) {
choices.push({
value: `${type.id}:counterpart`,
label: type.counterpart_label,
ticketLinkTypeId: type.id,
direction: 'counterpart',
});
}
return choices;
}),
);
const selectedAction = computed({
get: () => `${form.ticket_link_type_id}:${form.link_direction}`,
set: (value: string | number | null) => {
const action = actions.value.find(
(option) => option.value === value,
);
if (action) {
form.ticket_link_type_id = action.ticketLinkTypeId;
form.link_direction = action.direction;
}
},
});
const selectedActionLabel = computed(
() =>
actions.value.find(
(action) => action.value === selectedAction.value,
)?.label,
);
return { actions, selectedAction, selectedActionLabel };
}
@@ -0,0 +1,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]"
}
+145
View File
@@ -0,0 +1,145 @@
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' ||
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 {
new URL(value);
return new RegExp(descriptionFormat.link_prefix, 'i').test(value)
? value
: null;
} catch {
return null;
}
}
return new RegExp(descriptionFormat.link_prefix, 'i').test(value)
? value
: null;
}
export function descriptionExtensions() {
return [
StarterKit.configure({
underline: false,
trailingNode: false,
link: {
openOnClick: false,
autolink: false,
isAllowedUri: (url) => safeDescriptionLink(url) !== null,
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
},
}),
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
];
}
const markdown = new MarkdownManager({
extensions: descriptionExtensions(),
markedOptions: { gfm: true, breaks: true },
});
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
export function supportsVisualDescription(value: string): boolean {
let supported = true;
markdown.instance.walkTokens(
markdown.instance.lexer(value, { 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;
}
export function parseDescription(value: string): JSONContent {
return markdown.parse(value);
}
/** Build Vue nodes from an explicit schema; never insert user-provided HTML. */
export function renderDescriptionNode(node: JSONContent): VNodeChild {
if (node.type === 'text') {
let content: VNodeChild = node.text ?? '';
for (const mark of node.marks ?? []) {
const tag = {
bold: 'strong',
italic: 'em',
strike: 's',
code: 'code',
}[mark.type];
if (tag) {
content = h(tag, {}, [content]);
} else if (mark.type === 'link') {
const href = safeDescriptionLink(mark.attrs?.href);
if (href) {
content = h(
'a',
{ href, target: '_blank', rel: 'noopener noreferrer' },
[content],
);
}
}
}
return content;
}
const children = (node.content ?? []).map(renderDescriptionNode);
switch (node.type) {
case 'doc':
return children;
case 'paragraph':
return h('p', {}, children);
case 'heading':
return h(
`h${Math.min(6, Math.max(1, Number(node.attrs?.level) || 1))}`,
{},
children,
);
case 'bulletList':
return h('ul', {}, children);
case 'orderedList':
return h('ol', { start: Number(node.attrs?.start) || 1 }, children);
case 'listItem':
return h('li', {}, children);
case 'blockquote':
return h('blockquote', {}, children);
case 'codeBlock':
return h('pre', {}, [h('code', {}, children)]);
case 'hardBreak':
return h('br');
case 'horizontalRule':
return h('hr');
default:
return children;
}
}
+70 -12
View File
@@ -1,4 +1,31 @@
export const en = { export const en = {
'descriptionEditor.bold': 'Bold',
'descriptionEditor.italic': 'Italic',
'descriptionEditor.strike': 'Strikethrough',
'descriptionEditor.heading1': 'Heading 1',
'descriptionEditor.heading2': 'Heading 2',
'descriptionEditor.heading3': 'Heading 3',
'descriptionEditor.bulletList': 'Bullet list',
'descriptionEditor.orderedList': 'Numbered list',
'descriptionEditor.quote': 'Quote',
'descriptionEditor.code': 'Inline code',
'descriptionEditor.codeBlock': 'Code block',
'descriptionEditor.undo': 'Undo',
'descriptionEditor.redo': 'Redo',
'descriptionEditor.link': 'Add or edit link',
'descriptionEditor.unlink': 'Remove link',
'descriptionEditor.linkUrl': 'Link address',
'descriptionEditor.applyLink': 'Apply link',
'descriptionEditor.invalidLink':
'Enter a valid https://, http://, mailto:, /path or #anchor address without spaces.',
'descriptionEditor.formatting': 'Description formatting',
'descriptionEditor.source': 'Markdown',
'descriptionEditor.visual': 'Visual editor',
'descriptionEditor.hint':
'Format your description using the toolbar or edit its Markdown source.',
'descriptionEditor.unsupported':
'This description contains formatting that requires Markdown editing. Its original content is preserved.',
'nativeApp.title': 'App', 'nativeApp.title': 'App',
'nativeApp.description': 'Manage the KanbanGit app on this device.', 'nativeApp.description': 'Manage the KanbanGit app on this device.',
'nativeApp.workspace': 'Workspace', 'nativeApp.workspace': 'Workspace',
@@ -221,6 +248,38 @@ export const en = {
'Previous assignee name when the event updates assignment; blank otherwise.', 'Previous assignee name when the event updates assignment; blank otherwise.',
'notifications.variable.assignee_to': 'notifications.variable.assignee_to':
'New assignee name when the event updates assignment; blank when unassigned.', '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': 'templates.description':
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.', 'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
'templates.updated': 'Project templates updated.', 'templates.updated': 'Project templates updated.',
@@ -237,7 +296,7 @@ export const en = {
other: '{count} linked projects', other: '{count} linked projects',
}, },
'templates.savedFrom': 'Saved from {project} ({key}).', 'templates.savedFrom': 'Saved from {project} ({key}).',
'templates.sourceUnavailable': 'Source project unavailable', 'templates.sourceUnavailable': 'Independent template',
'templates.edit': 'Edit', 'templates.edit': 'Edit',
'templates.use': 'Use template', 'templates.use': 'Use template',
'templates.createTitle': 'Save {project} as a template', 'templates.createTitle': 'Save {project} as a template',
@@ -245,7 +304,7 @@ export const en = {
'Choose the configuration to reuse in future projects.', 'Choose the configuration to reuse in future projects.',
'templates.editTitle': 'Edit {name}', 'templates.editTitle': 'Edit {name}',
'templates.editDescription': '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.defaultName': '{project} template',
'templates.details': 'Template details', 'templates.details': 'Template details',
'templates.name': 'Template name', 'templates.name': 'Template name',
@@ -258,13 +317,15 @@ export const en = {
'The slug is locked so existing template references remain stable.', 'The slug is locked so existing template references remain stable.',
'templates.groups': 'Configuration groups', 'templates.groups': 'Configuration groups',
'templates.createGroupsHelp': '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': '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.impact': 'Template impact',
'templates.inheritance': 'Inheritance', 'templates.inheritance': 'Inheritance',
'templates.emptyStart':
'Templates contain configuration only. New projects start with a clean ticket history.',
'templates.cleanStart': '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': 'templates.refreshHelp':
'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.', 'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.',
'templates.linkedWarning': 'Linked projects will be affected', 'templates.linkedWarning': 'Linked projects will be affected',
@@ -1186,7 +1247,7 @@ export const en = {
'ticketDetail.linkedTickets': 'Linked tickets', 'ticketDetail.linkedTickets': 'Linked tickets',
'ticketDetail.linkedTicketsDescription': 'ticketDetail.linkedTicketsDescription':
'Track cross-ticket relationships with project-defined link labels.', 'Track cross-ticket relationships with project-defined link labels.',
'ticketDetail.linkType': 'Link type', 'ticketDetail.linkAction': 'Action',
'ticketDetail.linkedTicket': 'Linked ticket', 'ticketDetail.linkedTicket': 'Linked ticket',
'ticketDetail.saveLink': 'Save link', 'ticketDetail.saveLink': 'Save link',
'ticketDetail.noLinks': 'ticketDetail.noLinks':
@@ -1200,11 +1261,6 @@ export const en = {
'Open a child ticket to change its parent. Expand a group to see its descendants.', 'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link', 'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket', 'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.direction': 'This ticket',
'ticketRelationships.directionHelp':
'Choose how this ticket relates to the linked ticket. The reverse label appears on the other ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}', 'ticketRelationships.removeLink': 'Remove link to {key}',
'ticketRelationships.noTypes': 'ticketRelationships.noTypes':
'No link types are configured for this project.', 'No link types are configured for this project.',
@@ -1309,8 +1365,10 @@ export const en = {
'inheritance.resetWarning': 'inheritance.resetWarning':
'Local configuration in this area may be replaced or removed.', 'Local configuration in this area may be replaced or removed.',
'inheritance.statusHelp': '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.statusLabel': '{status} ({count} tickets)',
'inheritance.noReplacementTargets':
'Add a status to the template before resetting used local statuses.',
'inheritance.selectStatus': 'Select replacement status', 'inheritance.selectStatus': 'Select replacement status',
'inheritance.area.projectSettings': 'Project settings', 'inheritance.area.projectSettings': 'Project settings',
'inheritance.area.boardColumns': 'Board columns', 'inheritance.area.boardColumns': 'Board columns',
@@ -6,31 +6,58 @@ import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects'; import { index, show } from '@/routes/projects';
import { create } from '@/routes/projects/templates'; import { create } from '@/routes/projects/templates';
import {
create as createEmpty,
index as templatesIndex,
} from '@/routes/project-templates';
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
const props = defineProps<ProjectTemplatesCreatePageProps>(); const props = defineProps<ProjectTemplatesCreatePageProps>();
const { t } = useLocalization(); const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [ const breadcrumbs = computed<BreadcrumbItem[]>(() =>
{ title: t('navigation.projects'), href: index() }, props.project
{ title: props.project.name, href: show(props.project.id) }, ? [
{ title: t('projects.saveTemplate'), href: create(props.project.id) }, { 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> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <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"> <div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
<header class="gap-stack grid"> <header class="gap-stack grid">
<h1 class="text-headline-mobile md:text-headline break-words"> <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> </h1>
<p class="text-muted-foreground"> <p class="text-muted-foreground">
{{ t('templates.createDescription') }} {{ t('templates.createDescription') }}
</p> </p>
</header> </header>
<ProjectTemplateForm <ProjectTemplateForm
:key="project.id" :key="project?.id ?? 'empty'"
:project="project" :project="project"
:initial-configuration="initialConfiguration"
:copy-area-options="copyAreaOptions" :copy-area-options="copyAreaOptions"
/> />
</div> </div>
@@ -18,7 +18,7 @@ import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas'; import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { destroy, edit, index } from '@/routes/project-templates'; import { destroy, edit, index, create } from '@/routes/project-templates';
import { import {
create as createProject, create as createProject,
index as projectsIndex, index as projectsIndex,
@@ -80,9 +80,9 @@ function removeTemplate(): void {
</p> </p>
</div> </div>
<Button as-child class="shrink-0" <Button as-child class="shrink-0"
><Link :href="createProject()" ><Link :href="create()"
><Plus aria-hidden="true" />{{ ><Plus aria-hidden="true" />{{
t('projects.create') t('templates.createEmpty')
}}</Link }}</Link
></Button ></Button
> >
+4 -1
View File
@@ -88,7 +88,10 @@ const settingsItems = computed(() =>
</p> </p>
</div> </div>
<Button <Button
v-if="$page.props.auth.permissions.projectCreate" v-if="
$page.props.auth.permissions.projectCreate &&
canManageAccess
"
as-child as-child
variant="outline" variant="outline"
class="self-start" class="self-start"
+5 -2
View File
@@ -67,7 +67,8 @@ export type ProjectTemplatesIndexPageProps = {
export type ProjectTemplatesCreatePageProps = { export type ProjectTemplatesCreatePageProps = {
copyAreaOptions: ProjectDuplicateCopyAreaOption[]; copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project: ProjectModel; project: ProjectModel | null;
initialConfiguration?: Record<string, unknown>;
}; };
export type ProjectTemplatesEditPageProps = { export type ProjectTemplatesEditPageProps = {
@@ -82,6 +83,8 @@ export type ProjectTemplateStateArea = {
export type ProjectTemplateState = { export type ProjectTemplateState = {
effectiveConfiguration: Record<string, unknown>; effectiveConfiguration: Record<string, unknown>;
localConfiguration?: Record<string, unknown>;
localAreas?: ProjectTemplateStateArea[];
inheritedAreas: ProjectTemplateStateArea[]; inheritedAreas: ProjectTemplateStateArea[];
inheritedConfiguration?: Record<string, unknown>; inheritedConfiguration?: Record<string, unknown>;
inheritsFromTemplate: boolean; inheritsFromTemplate: boolean;
@@ -95,7 +98,7 @@ export type ProjectTemplateState = {
name: string; name: string;
ticketCount: number; ticketCount: number;
}>; }>;
replacementOptions: Array<{ id: number; name: string }>; replacementOptions: Array<{ id: number | string; name: string }>;
} }
>; >;
templateConfiguration?: Record<string, unknown>; 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>;
};
+19
View File
@@ -0,0 +1,19 @@
import type { ChainedCommands } from '@tiptap/core';
import type { Component } from 'vue';
import type { TranslationKey } from '@/types/localization';
export type TicketDescriptionEditorProps = {
id: string;
label: string;
error?: string;
disabled?: boolean;
placeholder?: string;
};
export type DescriptionToolbarAction = {
label: TranslationKey;
icon: Component;
active?: string;
attributes?: Record<string, unknown>;
run: (chain: ChainedCommands) => ChainedCommands;
};
+11
View File
@@ -67,3 +67,14 @@ export type TicketBranchFormData = {
branch_name: string; branch_name: string;
repository_connection_id: string; repository_connection_id: string;
}; };
export type TicketLinkFormData = {
ticket_link_type_id: number | string;
link_direction: 'source' | 'counterpart';
linked_ticket_id: number | string;
};
export type TicketLinkActionOption = {
value: string;
label: string;
ticketLinkTypeId: number;
direction: TicketLinkFormData['link_direction'];
};
+2
View File
@@ -7,6 +7,8 @@ Route::middleware('can:create-project')
->prefix('project-templates') ->prefix('project-templates')
->name('project-templates.') ->name('project-templates.')
->group(function (): void { ->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('/', [ProjectTemplateController::class, 'index'])->name('index');
Route::get('{projectTemplate}/edit', [ProjectTemplateController::class, 'edit'])->name('edit'); Route::get('{projectTemplate}/edit', [ProjectTemplateController::class, 'edit'])->name('edit');
Route::patch('{projectTemplate}', [ProjectTemplateController::class, 'update'])->name('update'); Route::patch('{projectTemplate}', [ProjectTemplateController::class, 'update'])->name('update');

Some files were not shown because too many files have changed in this diff Show More