Compare commits

..
Author SHA1 Message Date
Twyco 80d911bcc1 feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m12s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / test_and_check_app (pull_request) Failing after 1m59s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:02:50 +02:00
120 changed files with 705 additions and 5177 deletions
+73 -13
View File
@@ -1,18 +1,78 @@
name: test_and_check_app name: "Lint and Test APP"
description: Build the test image and run all application checks description: "Run Pint and the Pest test suite"
runs: runs:
using: composite using: composite
steps: steps:
- name: Test and check app - name: Setup PHP
shell: bash uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
run: | with:
set -Eeuo pipefail php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
docker buildx build \ - name: Verify Composer lockfile
--file Dockerfile \ run: |
--target test \ if [ ! -f composer.lock ]; then
--tag "local/kanbangit-test:${GITHUB_SHA}" \ echo "::error::composer.lock not found"
--progress plain \ exit 1
--load \ fi
. shell: bash
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Run independent application checks
run: |
pnpm exec concurrently \
--names frontend,backend \
--prefix-colors cyan,magenta \
--kill-others-on-fail \
"composer run ci:frontend" \
"composer run ci:backend"
shell: bash
- name: Run Laravel tests
run: composer run ci:test
shell: bash
@@ -2,7 +2,6 @@
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;
@@ -24,7 +23,6 @@ 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,7 +2,6 @@
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;
@@ -51,9 +50,5 @@ 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,10 +5,8 @@ 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
{ {
@@ -22,25 +20,22 @@ class UpdateTicketStatusTransitions
*/ */
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
{ {
return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus { $allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail(); ->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', [])) ->unique()
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId) ->values()
->unique() ->all();
->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,7 +7,6 @@ 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
{ {
@@ -21,22 +20,19 @@ class UpdateProjectTicketMetadataSettings
*/ */
public function handle(Project $project, array $validated): Project public function handle(Project $project, array $validated): Project
{ {
return DB::transaction(function () use ($project, $validated): Project { $project->update([
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); 'show_author' => Arr::get($validated, 'show_author', false),
$project->update([ 'show_story_points' => Arr::get($validated, 'show_story_points', false),
'show_author' => Arr::get($validated, 'show_author', false), ]);
'show_story_points' => Arr::get($validated, 'show_story_points', false), $this->projectTemplateOverrideManager->mark(
]); $project,
$this->projectTemplateOverrideManager->mark( DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
$project, );
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, $this->refreshProjectBaseTemplate->handle(
); $project,
$this->refreshProjectBaseTemplate->handle( DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
$project, );
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
return $project->fresh(); return $project->fresh();
}, 3);
} }
} }
-2
View File
@@ -36,7 +36,6 @@ 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([
@@ -73,7 +72,6 @@ class CreateProject
$this->duplicateProjectConfiguration->apply( $this->duplicateProjectConfiguration->apply(
$project->fresh() ?? $project, $project->fresh() ?? $project,
$sourceTemplate->configuration_snapshot ?? [], $sourceTemplate->configuration_snapshot ?? [],
adoptExisting: true,
); );
} }
@@ -15,18 +15,11 @@ 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' => $candidate, 'slug' => strtolower($project->key).'-base-template',
'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),
+10 -43
View File
@@ -5,61 +5,28 @@ 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
{ {
return DB::transaction(function () use ($sourceProject, $attributes): ProjectTemplate { $copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
$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 $template; return ProjectTemplate::query()->create([
}, 3); 'project_id' => null,
'source_project_id' => $sourceProject->id,
'name' => $attributes['name'],
'slug' => $attributes['slug'],
'is_published' => true,
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
]);
} }
} }
+1 -8
View File
@@ -3,7 +3,6 @@
namespace App\Actions\Projects; namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\DB;
class DeleteProject class DeleteProject
{ {
@@ -11,13 +10,7 @@ class DeleteProject
{ {
$projectName = $project->name; $projectName = $project->name;
DB::transaction(function () use ($project): void { $project->delete();
$baseTemplate = $project->baseTemplate()->first();
$project->ticketStatuses()->delete();
$project->delete();
$baseTemplate?->delete();
});
return $projectName; return $projectName;
} }
@@ -1,31 +0,0 @@
<?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,13 +2,9 @@
namespace App\Actions\Projects; namespace App\Actions\Projects;
use App\Actions\Notifications\NormalizeNotificationFilters;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketStatus; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
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
{ {
@@ -36,7 +32,6 @@ 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> */
@@ -113,7 +108,7 @@ class DuplicateProjectConfiguration
unset($configuration[self::COPY_BOARD_WORKFLOW]); unset($configuration[self::COPY_BOARD_WORKFLOW]);
} }
return $this->projectConfigurationIdentity->normalize($configuration); return $configuration;
} }
/** @param list<string> $copyAreas @return array<string, mixed> */ /** @param list<string> $copyAreas @return array<string, mixed> */
@@ -138,24 +133,23 @@ class DuplicateProjectConfiguration
]; ];
} }
$columnKeys = $sourceProject->boardColumns->values()->mapWithKeys( $columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column): array => [$column->id => $column->configuration_key], static fn ($column, int $index): array => [$column->id => $index],
); );
$statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys( $statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status): array => [$status->id => $status->configuration_key], static fn ($status, int $index): array => [$status->id => $index],
); );
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 => ['configuration_key' => $column->configuration_key, 'name' => $column->name, 'position' => $column->position], static fn ($column): array => ['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 => [
'configuration_key' => $status->configuration_key, 'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
'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,
], ],
@@ -164,12 +158,9 @@ 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 => [ static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
'status_key' => $status->configuration_key, static fn (int $id): ?int => $statusIndexes->get($id),
'target_keys' => $status->allowedTargetStatuses->pluck('id')->map( )->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
static fn (int $id): ?string => $statusKeys->get($id),
)->filter()->sort()->values()->all(),
],
)->all(); )->all();
} }
@@ -188,7 +179,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' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()), 'filters' => $item->filters ?? [],
], ],
]; ];
$relations = [ $relations = [
@@ -199,9 +190,7 @@ 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( $configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
static fn ($item): array => ['configuration_key' => $item->configuration_key, ...$mapper($item)],
)->all();
} }
} }
@@ -211,124 +200,95 @@ 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 retain referenced project configuration. * @return list<string> areas which retained referenced local values
*/ */
public function apply(Project $targetProject, array $configuration, array $statusReplacements = [], bool $adoptExisting = false): array public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): 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])) {
$currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get(); $blocked = $this->syncModels(
$nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key'); $targetProject->boardColumns()->get(),
$columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key'); $configuration[self::COPY_BOARD_COLUMNS],
$statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every( fn (array $item) => $targetProject->boardColumns()->create($item),
static fn ($status): bool => $nextStatuses->has($status->configuration_key) static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|| $status->tickets_count === 0 || array_key_exists($status->id, $statusReplacements), static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
); );
$canDeleteColumn = static fn (Model $column): bool => $currentStatuses->where('board_column_id', $column->id)->every( if ($blocked) {
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()->keyBy('configuration_key'); $columns = $targetProject->boardColumns()->get()->values();
$existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key'); $items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array { $columnIndex = $item['board_column_index'] ?? null;
$columnKey = $item['board_column_key'] ?? null; $item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
$item['board_column_id'] = $columnKey === null unset($item['board_column_index']);
? 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();
$templateKeys = array_column($items, 'configuration_key'); $blocked = $this->syncModels(
$canDelete = static fn (Model $status): bool => ! $status->tickets()->exists() $targetProject->ticketStatuses()->get(),
|| array_key_exists($status->id, $statusReplacements); $items,
$beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void { fn (array $item) => $targetProject->ticketStatuses()->create($item),
$replacement = $statusReplacements[$status->id] ?? null; static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
if ($replacement === null) { function (Model $status) use ($statusReplacements): bool {
return; if (! $status->tickets()->exists()) {
} return true;
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys) }
->where('configuration_key', (string) $replacement)->first(); $replacementId = $statusReplacements[$status->id] ?? null;
if ($replacementStatus === null && is_numeric($replacement)) { if ($replacementId === null) {
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement); return false;
} }
if ($replacementStatus === null) { $status->tickets()->update(['ticket_status_id' => $replacementId]);
throw ValidationException::withMessages([
'status_replacements.'.$status->id => 'Choose a replacement from the current template.', return true;
]); },
} );
$status->tickets()->update(['ticket_status_id' => $replacementStatus->id]); if ($blocked) {
$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()->keyBy('configuration_key'); $statuses = $targetProject->ticketStatuses()->get()->values();
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) { foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
$status = $statuses->get($transition['status_key']); $status = $statuses->get($index);
if ($status !== null) { if ($status !== null) {
$status->allowedTargetStatuses()->sync(collect($transition['target_keys'])->map( $status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
static fn (string $key): ?int => $statuses->get($key)?->id,
)->filter()->all());
} }
} }
} }
$guards = [ $relations = [
self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(), self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(), self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(), self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(), self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
self::COPY_REPOSITORY_CONNECTIONS => null, self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
self::COPY_NOTIFICATION_ACTIONS => null, self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
]; ];
foreach ($guards as $area => $canDelete) {
foreach ($relations as $area => [$relation, $canDelete]) {
if (! isset($configuration[$area])) { if (! isset($configuration[$area])) {
continue; continue;
} }
$items = $configuration[$area]; $blocked = $this->syncModels(
if ($area === self::COPY_NOTIFICATION_ACTIONS) { $targetProject->{$relation}()->get(),
$statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all(); $configuration[$area],
$items = array_map(fn (array $item): array => [ fn (array $item) => $targetProject->{$relation}()->create($item),
...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds), static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
], $items); $canDelete,
} );
if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) { if ($blocked) {
$blockedAreas[] = $area; $blockedAreas[] = $area;
} }
} }
@@ -337,8 +297,9 @@ class DuplicateProjectConfiguration
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]); $targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
} }
return $blockedAreas; return array_values(array_unique($blockedAreas));
}); });
foreach ($blockedAreas as $area) { foreach ($blockedAreas as $area) {
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area); $this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
} }
@@ -349,113 +310,30 @@ 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), adoptExisting: true); $this->apply($targetProject, $this->export($sourceProject, $copyAreas));
}
/** @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 list<array<string, mixed>> $items * @param EloquentCollection<int, Model> $models
* @param array<int, array<string, mixed>> $items
*/ */
private function syncModels(Project $project, string $area, array $items, ?callable $canDelete, bool $adoptExisting, ?callable $beforeDelete = null): bool private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
{ {
$relation = ProjectConfigurationIdentity::relations()[$area]; foreach ($items as $index => $item) {
$models = $project->{$relation}()->get(); $model = $models->get($index);
$byKey = $models->keyBy('configuration_key'); $model === null ? $create($item) : $update($model, $item);
$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) { $blocked = false;
foreach ($models->slice(count($items)) as $model) {
if ($canDelete !== null && ! $canDelete($model)) { if ($canDelete !== null && ! $canDelete($model)) {
return true; $blocked = true;
}
}
if ($area === self::COPY_REPOSITORY_CONNECTIONS) { continue;
foreach ($models as $model) {
$model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
}
}
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 false; return $blocked;
} }
} }
@@ -1,95 +0,0 @@
<?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,7 +3,6 @@
namespace App\Actions\Projects; namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\DB;
class ProjectTemplateOverrideManager class ProjectTemplateOverrideManager
{ {
@@ -15,16 +14,18 @@ class ProjectTemplateOverrideManager
return; return;
} }
DB::transaction(function () use ($project, $area): void { $project->loadMissing('projectTemplate');
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$project->load('projectTemplate'); if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) { return;
return; }
}
$project->forceFill(['template_overrides' => array_values(array_unique([ $overrides = array_values(array_unique([
...($project->template_overrides ?? []), $area, ...($project->template_overrides ?? []),
]))])->saveQuietly(); $area,
}, 3); ]));
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
} }
/** /**
@@ -32,10 +33,8 @@ class ProjectTemplateOverrideManager
*/ */
public function clear(Project $project, array $areas): void public function clear(Project $project, array $areas): void
{ {
DB::transaction(function () use ($project, $areas): void { $remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); $project->forceFill(['template_overrides' => $remaining])->saveQuietly();
$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,8 +3,6 @@
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
{ {
@@ -14,17 +12,21 @@ class RefreshProjectBaseTemplate
public function handle(Project $project, string $area): void public function handle(Project $project, string $area): void
{ {
DB::transaction(function () use ($project, $area): void { $project->loadMissing('projectTemplate');
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$template = ProjectTemplate::query()->find($project->project_template_id); if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
if ($template === null || $template->is_published) { return;
return; }
}
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail(); $snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []); $project->projectTemplate->configuration_snapshot ?? [],
$template->forceFill(['configuration_snapshot' => array_replace( );
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
)])->saveQuietly(); $project->projectTemplate->forceFill([
}, 3); 'configuration_snapshot' => array_replace(
$snapshot,
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
),
])->saveQuietly();
} }
} }
@@ -71,9 +71,7 @@ class ResolveProjectTemplateState
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value')); $inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
return [ return [
'effectiveConfiguration' => $currentConfiguration, 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
'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,
@@ -98,20 +96,24 @@ class ResolveProjectTemplateState
return []; return [];
} }
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null; $templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
if (! is_array($templateStatuses)) { if (! is_array($templateStatuses)) {
return []; return [];
} }
$statuses = $project->ticketStatuses()->withCount('tickets')->get(); $statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$templateKeys = array_column($templateStatuses, 'configuration_key'); $replacementOptions = $statuses->take(count($templateStatuses))->map(
$replacementOptions = collect($templateStatuses)->map( static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']],
)->all(); )->all();
$removedStatuses = $statuses $removedStatuses = $statuses->slice(count($templateStatuses))
->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0) ->filter(static fn ($status): bool => $status->tickets_count > 0)
->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count]) ->map(static fn ($status): array => [
->values()->all(); 'id' => $status->id,
'name' => $status->name,
'ticketCount' => $status->tickets_count,
])
->values()
->all();
return $removedStatuses === [] ? [] : [ return $removedStatuses === [] ? [] : [
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [ DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
+25 -22
View File
@@ -19,34 +19,37 @@ class UpdateProjectTemplate
*/ */
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
{ {
return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate { $projectTemplate->loadMissing('sourceProject');
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []); $sourceProject = $projectTemplate->sourceProject;
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? [])); abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
Arr::get($attributes, 'configuration_snapshot', $previousConfiguration), $copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
))->only($copyAreas)->all(); $normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)])) $previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null)) $projectTemplate->configuration_snapshot ?? [],
->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' => Arr::get($attributes, 'name'), 'name' => $attributes['name'],
'copy_areas' => $copyAreas, 'copy_areas' => $normalizedCopyAreas,
'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 ($projectTemplate, $affectedAreas, $nextConfiguration, $previousConfiguration): void { ->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
foreach ($linkedProjects as $linkedProject) { foreach ($linkedProjects as $linkedProject) {
$linkedProject = Project::query() $linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
->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 = [];
@@ -78,8 +81,8 @@ class UpdateProjectTemplate
} }
} }
}); });
});
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects'); return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
}, 3);
} }
} }
@@ -5,7 +5,6 @@ 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
{ {
@@ -17,7 +16,6 @@ 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 ?? [],
@@ -44,27 +42,19 @@ 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 = []): array public function reset(Project $project, array $areas, array $statusReplacements = []): void
{ {
$template = $project->projectTemplate; $template = $project->projectTemplate;
if ($template === null) { if ($template === null) {
return []; return;
} }
return DB::transaction(function () use ($project, $template, $areas, $statusReplacements): array { DB::transaction(function () use ($project, $template, $areas, $statusReplacements): 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();
if ($project->project_template_id !== $template->id) { $configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']); $template->configuration_snapshot ?? [],
} ))->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,
@@ -74,8 +64,6 @@ 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);
} }
} }
+12 -15
View File
@@ -17,21 +17,18 @@ class StoreProjectTicketLink
) {} ) {}
/** /**
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated * @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
*/ */
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
{ {
$linkedTicketId = Arr::get($validated, 'linked_ticket_id'); $linkedTicketId = Arr::get($validated, 'linked_ticket_id');
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id'); $ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
$linkedTicket = Ticket::query() $linkedTicket = Ticket::query()
->where('project_id', $project->id) ->where('project_id', $project->id)
->findOrFail($linkedTicketId); ->findOrFail($linkedTicketId);
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink { return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
Ticket::query() Ticket::query()
->whereKey([$ticket->id, $linkedTicket->id]) ->whereKey([$ticket->id, $linkedTicket->id])
->orderBy('id') ->orderBy('id')
@@ -53,31 +50,31 @@ class StoreProjectTicketLink
if ($ticketLink === null) { if ($ticketLink === null) {
$ticketLink = TicketLink::query()->create([ $ticketLink = TicketLink::query()->create([
'source_ticket_id' => $sourceTicket->id, 'source_ticket_id' => $ticket->id,
'target_ticket_id' => $targetTicket->id, 'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId, 'ticket_link_type_id' => $ticketLinkTypeId,
]); ]);
} else { } else {
$ticketLink->update([ $ticketLink->update([
'source_ticket_id' => $sourceTicket->id, 'source_ticket_id' => $ticket->id,
'target_ticket_id' => $targetTicket->id, 'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId, 'ticket_link_type_id' => $ticketLinkTypeId,
]); ]);
} }
$ticketLink->load('ticketLinkType'); $ticketLink->load('ticketLinkType');
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket); $sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket); $targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
$this->createTicketWorklogEntry->handle( $this->createTicketWorklogEntry->handle(
$ticket, $ticket,
$user, $user,
TicketWorklogEntryType::LinkUpdated, TicketWorklogEntryType::LinkUpdated,
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.", "Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
[ [
'linked_ticket_key' => $linkedTicket->ticket_key, 'linked_ticket_key' => $linkedTicket->ticket_key,
'ticket_link_type' => $ticketLabel, 'ticket_link_type' => $sourceLabel,
], ],
); );
@@ -85,10 +82,10 @@ class StoreProjectTicketLink
$linkedTicket, $linkedTicket,
$user, $user,
TicketWorklogEntryType::LinkUpdated, TicketWorklogEntryType::LinkUpdated,
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.", "Linked {$ticket->ticket_key} as {$targetLabel}.",
[ [
'linked_ticket_key' => $ticket->ticket_key, 'linked_ticket_key' => $ticket->ticket_key,
'ticket_link_type' => $linkedTicketLabel, 'ticket_link_type' => $targetLabel,
], ],
); );
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
/** /**
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items * @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
*/ */
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void public static function syncTicket(Ticket $ticket, array $items): void
{ {
$normalized = self::normalize($items); $normalized = self::normalize($items);
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id'); $existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
@@ -73,8 +73,7 @@ class AcceptanceCriteriaChecklistMapper
: $existingCriteria->get($criterionId); : $existingCriteria->get($criterionId);
while ( while (
$reuseUnidentifiedCriteria $criterion === null
&& $criterion === null
&& $fallbackIndex < $fallbackCriteria->count() && $fallbackIndex < $fallbackCriteria->count()
) { ) {
/** @var AcceptanceCriterion $candidate */ /** @var AcceptanceCriterion $candidate */
@@ -1,78 +0,0 @@
<?php
namespace App\Actions\Tickets\Support;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\Extension\Table\Table;
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
use League\CommonMark\Parser\MarkdownParser;
use League\Uri\Uri;
class TicketDescriptionFormat
{
/** @return array<string, mixed> */
public static function contract(): array
{
return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR));
}
public static function instructions(): string
{
$contract = self::contract();
return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria']))
." Valid description example (Markdown string):\n".$contract['example'];
}
public static function criteriaInstructions(): string
{
return self::contract()['criteria'];
}
public static function safeLink(string $value): bool
{
$contract = self::contract();
$decoded = rawurldecode($value);
if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded)
|| ! preg_match('~'.$contract['link_prefix'].'~i', $value)) {
return false;
}
if (str_starts_with($value, '/') || str_starts_with($value, '#')) {
return true;
}
$url = Uri::tryNew($decoded);
return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535));
}
/** @return list<string> */
public static function unsupportedContent(string $value): array
{
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new GithubFlavoredMarkdownExtension);
$document = (new MarkdownParser($environment))->parse($value);
$unsupported = [];
foreach ($document->iterator() as $node) {
$type = match (true) {
$node instanceof HtmlBlock, $node instanceof HtmlInline => 'html',
$node instanceof Table => 'table',
$node instanceof Image => 'image',
$node instanceof TaskListItemMarker => 'task',
$node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link',
default => null,
};
if ($type !== null) {
$unsupported[] = self::contract()['unsupported_tokens'][$type];
}
}
return array_values(array_unique($unsupported));
}
}
+3 -6
View File
@@ -51,7 +51,6 @@ class UpdateProjectTicket
Ticket $ticket, Ticket $ticket,
User $actor, User $actor,
array $validated, array $validated,
bool $replaceCriteriaWithIds = false,
): bool { ): bool {
$fieldValueAttributes = Arr::get($validated, 'field_values', []); $fieldValueAttributes = Arr::get($validated, 'field_values', []);
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize( $nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
@@ -59,7 +58,7 @@ class UpdateProjectTicket
); );
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated); $ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool { return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
if (! $ticketAttributes['is_blocked']) { if (! $ticketAttributes['is_blocked']) {
$ticketAttributes['blocked_comment'] = null; $ticketAttributes['blocked_comment'] = null;
} }
@@ -92,9 +91,7 @@ class UpdateProjectTicket
/** @var Collection<int, TicketFieldValue> $existingFieldValues */ /** @var Collection<int, TicketFieldValue> $existingFieldValues */
$existingFieldValues = $ticket->fieldValues $existingFieldValues = $ticket->fieldValues
->keyBy('ticket_field_definition_id'); ->keyBy('ticket_field_definition_id');
$currentAcceptanceCriteria = $replaceCriteriaWithIds $currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$changes = []; $changes = [];
$summaryLabels = []; $summaryLabels = [];
@@ -212,7 +209,7 @@ class UpdateProjectTicket
} }
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) { if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds); AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
} }
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle( $fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
@@ -36,12 +36,11 @@ class ProjectTicketLinkController extends ApiController
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated()); $ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
$ticketLink->setAttribute('current_ticket_id', $ticket->id); $ticketLink->setAttribute('current_ticket_id', $ticket->id);
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType'])) return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
->additional([ ->additional([
'meta' => [ 'meta' => [
'message' => "{$label} link was saved.", 'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
], ],
]) ])
->response() ->response()
+1 -5
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', []);
$blockedAreas = $this->updateProjectTemplateLink->reset( $this->updateProjectTemplateLink->reset(
$project->load('projectTemplate'), $project->load('projectTemplate'),
$areas, $areas,
$statusReplacements, $statusReplacements,
@@ -171,10 +171,6 @@ 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,12 +3,9 @@
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;
@@ -23,7 +20,6 @@ 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,
) {} ) {}
@@ -42,22 +38,6 @@ 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);
@@ -101,7 +81,7 @@ class ProjectTemplateController extends Controller
abort_if(! $projectTemplate->is_published, 404); abort_if(! $projectTemplate->is_published, 404);
$templateName = $projectTemplate->name; $templateName = $projectTemplate->name;
$this->deleteProjectTemplate->handle($projectTemplate); $projectTemplate->delete();
return to_route('project-templates.index') return to_route('project-templates.index')
->with('status', "Template {$templateName} was deleted."); ->with('status', "Template {$templateName} was deleted.");
@@ -34,10 +34,9 @@ class ProjectTicketLinkController extends Controller
); );
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated()); $ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
return to_route('projects.tickets.show', [$project, $ticket]) return to_route('projects.tickets.show', [$project, $ticket])
->with('status', "{$label} link was saved."); ->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
} }
public function destroy( public function destroy(
@@ -1,141 +0,0 @@
<?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.');
}
}
}
}];
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can('admin-project', $this->route('project')) ?? false; return $this->user()?->can('access-admin') ?? false;
} }
/** /**
@@ -34,7 +34,11 @@ class ResetProjectTemplateRequest extends FormRequest
Rule::in([$project->key]), Rule::in([$project->key]),
], ],
'status_replacements' => ['sometimes', 'array'], 'status_replacements' => ['sometimes', 'array'],
'status_replacements.*' => ['required'], 'status_replacements.*' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
],
]; ];
} }
@@ -50,34 +54,40 @@ class ResetProjectTemplateRequest extends FormRequest
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$validated = $validator->validated(); $validated = $validator->validated();
$project->loadMissing('projectTemplate'); $areas = array_values(Arr::get(
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []); $validated,
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration))); 'areas',
foreach ($areas as $area) { DuplicateProjectConfiguration::copyableAreas(),
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');
$statuses = $project->ticketStatuses()->withCount('tickets')->get(); $project->loadMissing('projectTemplate');
$allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all(); $configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
$project->projectTemplate?->configuration_snapshot ?? [],
);
$templateStatuses = Arr::get(
$configuration,
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
[],
);
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
$replacements = Arr::get($validated, 'status_replacements', []); $replacements = Arr::get($validated, 'status_replacements', []);
foreach ($replacements as $id => $replacement) {
if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement) foreach ($statuses->slice(count($templateStatuses)) as $status) {
|| ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) { if ($status->tickets_count === 0) {
$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);
if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) { $replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
$validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}."); if (! $allowedReplacementIds->contains($replacementId)) {
$validator->errors()->add(
"status_replacements.{$status->id}",
"Choose a replacement status for {$status->name}.",
);
} }
} }
}, },
@@ -12,8 +12,7 @@ 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
@@ -30,14 +29,10 @@ 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')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))], 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
'copy_areas' => ['present', 'array'], 'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
]; ];
} }
@@ -1,15 +0,0 @@
<?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()];
}
}
@@ -42,7 +42,6 @@ class StoreTicketLinkRequest extends FormRequest
fn ($query) => $query->where('project_id', $project->id), fn ($query) => $query->where('project_id', $project->id),
), ),
], ],
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
]; ];
} }
@@ -3,7 +3,6 @@
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;
@@ -11,8 +10,6 @@ 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;
@@ -28,9 +25,8 @@ 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' => ['present', 'array'], 'copy_areas' => ['required', 'array', 'min:1'],
'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,8 +32,6 @@ 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),
+3 -15
View File
@@ -2,32 +2,24 @@
namespace App\Mcp\Servers; namespace App\Mcp\Servers;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Mcp\Tools\CreateTicket; use App\Mcp\Tools\CreateTicket;
use App\Mcp\Tools\GetProjectConfiguration;
use App\Mcp\Tools\GetTicket; use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListMyAssignedTickets; use App\Mcp\Tools\ListMyAssignedTickets;
use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\ListTicketsByStatus; use App\Mcp\Tools\ListTicketsByStatus;
use App\Mcp\Tools\ListTicketTypes; use App\Mcp\Tools\ListTicketTypes;
use App\Mcp\Tools\MoveTicketStatus; use App\Mcp\Tools\MoveTicketStatus;
use App\Mcp\Tools\SearchTickets;
use App\Mcp\Tools\UpdateTicket;
use App\Mcp\Tools\UpdateTicketAssignee; use App\Mcp\Tools\UpdateTicketAssignee;
use App\Mcp\Tools\UpdateTicketDescription; use App\Mcp\Tools\UpdateTicketDescription;
use Laravel\Mcp\Server; use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version; use Laravel\Mcp\Server\Attributes\Version;
#[Name('KanbanGit MCP')] #[Name('KanbanGit MCP')]
#[Version('0.2.1')] #[Version('0.1.0')]
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
class KanbanGitServer extends Server class KanbanGitServer extends Server
{ {
protected function boot(): void
{
$this->instructions = 'Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
}
protected array $tools = [ protected array $tools = [
ListMyAssignedTickets::class, ListMyAssignedTickets::class,
ListTicketsByStatus::class, ListTicketsByStatus::class,
@@ -37,10 +29,6 @@ class KanbanGitServer extends Server
MoveTicketStatus::class, MoveTicketStatus::class,
CreateTicket::class, CreateTicket::class,
ListTicketTypes::class, ListTicketTypes::class,
ListProjects::class,
GetProjectConfiguration::class,
SearchTickets::class,
UpdateTicket::class,
]; ];
protected array $resources = [ protected array $resources = [
@@ -1,13 +0,0 @@
<?php
namespace App\Mcp\Support;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
trait DescribesTicketWriting
{
public function description(): string
{
return parent::description().' '.TicketDescriptionFormat::instructions();
}
}
-24
View File
@@ -13,7 +13,6 @@ use App\Models\TicketStatus;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request; use Laravel\Mcp\Request;
@@ -79,17 +78,6 @@ class KanbanGitMcp
return $ticket; return $ticket;
} }
public function ticketForKey(User $user, string $ticketKey): Ticket
{
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
}
$this->ensureCanRead($user, $ticket->project);
return $ticket;
}
/** /**
* @throws AuthorizationException * @throws AuthorizationException
*/ */
@@ -213,18 +201,6 @@ class KanbanGitMcp
)); ));
} }
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
public function paginationPayload(LengthAwarePaginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
'has_more_pages' => $paginator->hasMorePages(),
];
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
-110
View File
@@ -1,110 +0,0 @@
<?php
namespace App\Mcp\Support;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Http\Requests\UpdateProjectTicketRequest;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\User;
use App\Rules\VisualTicketDescription;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
class TicketUpdateInput
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp) {}
/** @return array<string, mixed> */
public function validate(Request $request, Ticket $ticket, User $user): array
{
$form = new UpdateProjectTicketRequest;
$route = new Route('PUT', '/', fn () => null);
$route->bind(\Illuminate\Http\Request::create('/', 'PUT'));
$route->setParameter('project', $ticket->project);
$route->setParameter('ticket', $ticket);
$form->setRouteResolver(fn () => $route);
$form->setUserResolver(fn () => $user);
$rules = $form->rules();
foreach ($rules as $key => &$rule) {
if (! str_contains($key, '.')) {
$rule = ['sometimes', ...$rule];
}
}
unset($rule);
$rules['description'][] = new VisualTicketDescription;
$rules['acceptance_criteria'] = ['sometimes', 'array', 'list'];
$rules['acceptance_criteria.*'] = ['array:id,text,checked,indent'];
$rules['acceptance_criteria.*.id'] = ['sometimes', 'integer', 'min:1', 'distinct', Rule::exists('acceptance_criteria', 'id')->where('ticket_id', $ticket->id)];
$rules['acceptance_criteria.*.checked'] = ['sometimes', 'boolean'];
$rules['acceptance_criteria.*.indent'] = ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL];
$rules['field_values'] = ['sometimes', 'array', 'list'];
$rules['field_values.*'] = ['array:ticket_field_definition_id,value_number,value_user_id'];
$rules['field_values.*.ticket_field_definition_id'][] = 'distinct';
$rules['ticket_status_id'] = ['prohibited'];
$rules['acceptance_criteria.*.text'][] = function (string $attribute, mixed $value, \Closure $fail): void {
if (is_string($value) && trim($value) === '') {
$fail('Criterion text must not be blank.');
}
};
$input = $request->validate($rules);
$previousIndent = -1;
foreach (Arr::get($input, 'acceptance_criteria', []) as $index => $item) {
$indent = Arr::get($item, 'indent', 0);
if ($indent > $previousIndent + 1) {
throw ValidationException::withMessages(["acceptance_criteria.{$index}.indent" => 'Indentation cannot skip a level; the first item must have indent 0.']);
}
$previousIndent = $indent;
}
$merged = array_replace($this->kanbanGitMcp->updatePayload($ticket), $input);
$merged['acceptance_criteria'] = Arr::exists($input, 'acceptance_criteria')
? AcceptanceCriteriaChecklistMapper::normalize($input['acceptance_criteria'])
: AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria()->get());
$validator = Validator::make($merged, $form->rules());
$form->setValidator($validator);
$validator->validate();
foreach ($form->after() as $callback) {
$callback($validator);
}
$people = User::query()->visibleToProject($ticket->project)->pluck('id')->all();
foreach (['assignee_user_id', 'author_user_id'] as $field) {
if (Arr::exists($input, $field) && $input[$field] !== null && ! in_array((int) $input[$field], $people, true)) {
$validator->errors()->add($field, 'The person must be able to view this project.');
}
}
$definitions = $ticket->project->ticketFieldDefinitions()->get()->keyBy('id');
foreach (Arr::get($input, 'field_values', []) as $index => $value) {
$definition = $definitions->get($value['ticket_field_definition_id']);
$incompatible = $definition->field_type === 'user' ? 'value_number' : 'value_user_id';
if (Arr::get($value, $incompatible) !== null) {
$validator->errors()->add("field_values.{$index}.{$incompatible}", 'The value must match the custom field type.');
}
if (Arr::get($value, 'value_user_id') !== null && ! in_array((int) $value['value_user_id'], $people, true)) {
$validator->errors()->add("field_values.{$index}.value_user_id", 'The person must be able to view this project.');
}
}
if ($validator->errors()->isNotEmpty()) {
throw new ValidationException($validator);
}
if (Arr::exists($input, 'field_values')) {
$replacement = $definitions->map(fn (TicketFieldDefinition $definition): array => [
'ticket_field_definition_id' => $definition->id,
'value_number' => null,
'value_user_id' => null,
])->all();
foreach ($input['field_values'] as $value) {
$replacement[$value['ticket_field_definition_id']] = $value;
}
$merged['field_values'] = array_values($replacement);
}
return $merged;
}
}
+3 -8
View File
@@ -5,14 +5,11 @@ namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateProjectTicket; use App\Actions\Tickets\CreateProjectTicket;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Enums\Permission; use App\Enums\Permission;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Rules\VisualTicketDescription;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -31,8 +28,6 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class CreateTicket extends Tool class CreateTicket extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly CreateProjectTicket $createProjectTicket, private readonly CreateProjectTicket $createProjectTicket,
@@ -49,7 +44,7 @@ class CreateTicket extends Tool
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id')); $project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
$validated = $request->validate([ $validated = $request->validate([
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', new VisualTicketDescription], 'description' => ['nullable', 'string'],
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)], 'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)], 'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)], 'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
@@ -145,7 +140,7 @@ class CreateTicket extends Tool
return [ return [
'project_id' => $schema->integer()->description('Project ID.')->required(), 'project_id' => $schema->integer()->description('Project ID.')->required(),
'title' => $schema->string()->max(255)->description('Ticket title.')->required(), 'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()), 'description' => $schema->string()->nullable()->description('Ticket description.'),
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(), 'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(), 'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(), 'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
@@ -161,7 +156,7 @@ class CreateTicket extends Tool
'checked' => $schema->boolean()->default(false), 'checked' => $schema->boolean()->default(false),
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0), 'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
])->withoutAdditionalProperties()) ])->withoutAdditionalProperties())
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()), ->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
'field_values' => $schema->array() 'field_values' => $schema->array()
->items($schema->object([ ->items($schema->object([
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(), 'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
-75
View File
@@ -1,75 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('get_project_configuration')]
#[Description('Get compact current configuration for a visible project: ordered columns, statuses with IDs/slugs/column assignment and allowed target status IDs, types, priorities, custom field definitions, and permitted people IDs/names. Reads live effective configuration, without template snapshots or full user/project objects. People are paginated (default 25, maximum 100); use people_page for subsequent pages. Author changes require project admin permission, reported in permissions.')]
#[IsReadOnly]
class GetProjectConfiguration extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate([
'project_id' => ['required', 'integer'],
'people_page' => ['sometimes', 'integer', 'min:1'],
'people_per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$user = $this->kanbanGitMcp->user($request);
$project = $this->kanbanGitMcp->projectForRead($user, $input['project_id']);
$people = User::query()->visibleToProject($project)->orderBy('id')->paginate($input['people_per_page'] ?? 25, ['id', 'name'], 'people_page', $input['people_page'] ?? 1);
$columns = $project->boardColumns()->orderBy('id')->get(['id', 'name', 'position']);
$columnPositions = $columns->pluck('position', 'id');
$statuses = $project->ticketStatuses()->with('allowedTargetStatuses')->get()->sortBy(
fn (TicketStatus $status): array => [$columnPositions->get($status->board_column_id, 0), $status->position, $status->id],
)->values();
return Response::structured([
'data' => [
'project' => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name],
'permissions' => [
'can_write' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'can_change_author' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
],
'board_columns' => $columns->toArray(),
'ticket_statuses' => $statuses->map(fn (TicketStatus $status): array => [
'id' => $status->id, 'name' => $status->name, 'slug' => $status->slug,
'position' => $status->position, 'board_column_id' => $status->board_column_id,
'allowed_target_status_ids' => $status->allowedTargetStatuses->pluck('id')->all(),
])->all(),
'ticket_types' => $project->ticketTypes()->orderBy('id')->get(['id', 'name', 'position', 'is_parent'])->toArray(),
'ticket_priorities' => $project->ticketPriorities()->orderBy('id')->get(['id', 'name', 'position'])->toArray(),
'ticket_field_definitions' => $project->ticketFieldDefinitions()->orderBy('id')->get(['id', 'label', 'field_type', 'position'])->toArray(),
'people' => $people->getCollection()->map(fn (User $person): array => ['id' => $person->id, 'name' => $person->name])->all(),
],
'meta' => ['people' => $this->kanbanGitMcp->paginationPayload($people)],
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'project_id' => $schema->integer()->description('Visible project ID from list_projects.')->required(),
'people_page' => $schema->integer()->min(1)->description('People page number, defaults to 1.'),
'people_per_page' => $schema->integer()->min(1)->max(100)->description('People page size, defaults to 25; maximum 100.'),
];
}
}
+5 -8
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 by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')] #[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
#[IsReadOnly] #[IsReadOnly]
class GetTicket extends Tool class GetTicket extends Tool
{ {
@@ -25,14 +25,11 @@ class GetTicket extends Tool
public function handle(Request $request): Response|ResponseFactory public function handle(Request $request): Response|ResponseFactory
{ {
$validated = $request->validate([ $validated = $request->validate([
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'], 'ticket_id' => ['required', 'integer'],
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
]); ]);
$user = $this->kanbanGitMcp->user($request); $user = $this->kanbanGitMcp->user($request);
$ticket = isset($validated['ticket_id']) $ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
return Response::structured([ return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket), 'data' => $this->kanbanGitMcp->ticketPayload($ticket),
@@ -46,8 +43,8 @@ class GetTicket extends Tool
{ {
return [ return [
'ticket_id' => $schema->integer() 'ticket_id' => $schema->integer()
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'), ->description('Ticket ID.')
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'), ->required(),
]; ];
} }
} }
-46
View File
@@ -1,46 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('list_projects')]
#[Description('List visible projects independently of assigned tickets. Returns only ID, key and name, ordered by ID, with bounded pagination. page starts at 1; per_page defaults to 25 and may not exceed 100.')]
#[IsReadOnly]
class ListProjects extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate(['page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100']]);
$user = $this->kanbanGitMcp->user($request);
$projects = Project::query()->whereIn('id', $this->permissionService->viewableProjectIds($user))
->orderBy('id')->paginate($input['per_page'] ?? 25, ['id', 'key', 'name'], 'page', $input['page'] ?? 1);
return Response::structured([
'data' => $projects->getCollection()->map(fn (Project $project): array => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name])->all(),
'meta' => $this->kanbanGitMcp->paginationPayload($projects),
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
];
}
}
-3
View File
@@ -3,7 +3,6 @@
namespace App\Mcp\Tools; namespace App\Mcp\Tools;
use App\Actions\Tickets\MoveProjectTicket; use App\Actions\Tickets\MoveProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
@@ -20,8 +19,6 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class MoveTicketStatus extends Tool class MoveTicketStatus extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly MoveProjectTicket $moveProjectTicket, private readonly MoveProjectTicket $moveProjectTicket,
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Ticket;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('search_tickets')]
#[Description('Search visible tickets with combined filters: exact full ticket key, literal text in title/description, project, status, type, assignee and parent. Omitted filters do not restrict results; assignee_user_id/parent_ticket_id null selects unassigned/top-level tickets. Returns compact ID, key, title, project ID and status with deterministic ID order and bounded pagination (default 25, maximum 100).')]
#[IsReadOnly]
class SearchTickets extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate([
'ticket_key' => ['sometimes', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
'query' => ['sometimes', 'string', 'max:255'],
'project_id' => ['sometimes', 'integer'],
'ticket_status_id' => ['sometimes', 'integer'],
'ticket_type_id' => ['sometimes', 'integer'],
'assignee_user_id' => ['sometimes', 'nullable', 'integer'],
'parent_ticket_id' => ['sometimes', 'nullable', 'integer'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$user = $this->kanbanGitMcp->user($request);
$this->kanbanGitMcp->projectForRead($user, $input['project_id'] ?? null);
$query = Ticket::query()->whereIn('project_id', $this->permissionService->viewableProjectIds($user));
foreach (['ticket_key', 'project_id', 'ticket_status_id', 'ticket_type_id', 'assignee_user_id', 'parent_ticket_id'] as $filter) {
if (Arr::exists($input, $filter)) {
$query->where($filter, $input[$filter]);
}
}
if (Arr::exists($input, 'query')) {
$pattern = '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $input['query']).'%';
$query->where(fn (Builder $q) => $q->whereRaw("title LIKE ? ESCAPE '!'", [$pattern])->orWhereRaw("description LIKE ? ESCAPE '!'", [$pattern]));
}
$tickets = $query->with('ticketStatus:id,name,slug,board_column_id')->orderBy('id')
->paginate($input['per_page'] ?? 25, ['id', 'ticket_key', 'title', 'project_id', 'ticket_status_id'], 'page', $input['page'] ?? 1);
return Response::structured([
'data' => $tickets->getCollection()->map(fn (Ticket $ticket): array => [
'id' => $ticket->id,
'ticket_key' => $ticket->ticket_key,
'title' => $ticket->title,
'project_id' => $ticket->project_id,
'ticket_status_id' => $ticket->ticket_status_id,
'status' => $ticket->ticketStatus === null ? null : ['id' => $ticket->ticketStatus->id, 'name' => $ticket->ticketStatus->name, 'slug' => $ticket->ticketStatus->slug],
])->all(),
'meta' => $this->kanbanGitMcp->paginationPayload($tickets),
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'ticket_key' => $schema->string()->description('Exact full key, e.g. KAN-67.'),
'query' => $schema->string()->max(255)->description('Literal substring in title or description.'),
'project_id' => $schema->integer()->description('Visible project ID.'),
'ticket_status_id' => $schema->integer()->description('Status ID from get_project_configuration.'),
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
'assignee_user_id' => $schema->integer()->nullable()->description('Assignee ID; null selects unassigned tickets.'),
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent ID; null selects top-level tickets.'),
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
];
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp;
use App\Mcp\Support\TicketUpdateInput;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[Name('update_ticket')]
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
#[IsDestructive(false)]
class UpdateTicket extends Tool
{
use DescribesTicketWriting;
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly TicketUpdateInput $ticketUpdateInput,
private readonly UpdateProjectTicket $updateProjectTicket,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
$user = $this->kanbanGitMcp->user($request);
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
'meta' => ['updated' => $updated],
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
'description' => $schema->string()->nullable()->description('Description; null clears it. '.TicketDescriptionFormat::instructions()),
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
'acceptance_criteria' => $schema->array()->items($schema->object([
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid. '.TicketDescriptionFormat::criteriaInstructions()),
'field_values' => $schema->array()->items($schema->object([
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
];
}
}
-3
View File
@@ -3,7 +3,6 @@
namespace App\Mcp\Tools; namespace App\Mcp\Tools;
use App\Actions\Tickets\UpdateProjectTicket; use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
@@ -20,8 +19,6 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class UpdateTicketAssignee extends Tool class UpdateTicketAssignee extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket, private readonly UpdateProjectTicket $updateProjectTicket,
+3 -9
View File
@@ -2,14 +2,10 @@
namespace App\Mcp\Tools; namespace App\Mcp\Tools;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Actions\Tickets\UpdateProjectTicket; use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp; use App\Mcp\Support\KanbanGitMcp;
use App\Rules\VisualTicketDescription;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request; use Laravel\Mcp\Request;
use Laravel\Mcp\Response; use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\ResponseFactory;
@@ -23,8 +19,6 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)] #[IsDestructive(false)]
class UpdateTicketDescription extends Tool class UpdateTicketDescription extends Tool
{ {
use DescribesTicketWriting;
public function __construct( public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp, private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket, private readonly UpdateProjectTicket $updateProjectTicket,
@@ -33,7 +27,7 @@ class UpdateTicketDescription extends Tool
public function handle(Request $request): Response|ResponseFactory public function handle(Request $request): Response|ResponseFactory
{ {
$validated = $request->validate([ $validated = $request->validate([
'description' => ['nullable', 'string', new VisualTicketDescription], 'description' => ['nullable', 'string'],
'ticket_id' => ['required', 'integer'], 'ticket_id' => ['required', 'integer'],
]); ]);
@@ -41,7 +35,7 @@ class UpdateTicketDescription extends Tool
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
$payload = [ $payload = [
...$this->kanbanGitMcp->updatePayload($ticket), ...$this->kanbanGitMcp->updatePayload($ticket),
...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []), 'description' => $validated['description'] ?? null,
]; ];
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload); $wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
@@ -64,7 +58,7 @@ class UpdateTicketDescription extends Tool
return [ return [
'description' => $schema->string() 'description' => $schema->string()
->nullable() ->nullable()
->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()), ->description('New ticket description. Pass null to clear it.'),
'ticket_id' => $schema->integer() 'ticket_id' => $schema->integer()
->description('Ticket ID.') ->description('Ticket ID.')
->required(), ->required(),
+1 -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(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])] #[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class BoardColumn extends Model class BoardColumn extends Model
{ {
/** @use HasFactory<BoardColumnFactory> */ /** @use HasFactory<BoardColumnFactory> */
@@ -5,44 +5,11 @@ 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(['configuration_key', #[Fillable([
'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(['configuration_key', #[Fillable([
'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(['configuration_key', 'field_type', 'label', 'position', 'project_id', 'project_template_id'])] #[Fillable(['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(['configuration_key', 'label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])] #[Fillable(['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(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])] #[Fillable(['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(['configuration_key', 'board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])] #[Fillable(['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(['configuration_key', 'name', 'position', 'project_id', 'project_template_id', 'is_parent'])] #[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
class TicketType extends Model class TicketType extends Model
{ {
/** @use HasFactory<TicketTypeFactory> */ /** @use HasFactory<TicketTypeFactory> */
@@ -30,9 +30,6 @@ 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
@@ -1,21 +0,0 @@
<?php
namespace App\Rules;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class VisualTicketDescription implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
$unsupported = TicketDescriptionFormat::unsupportedContent($value);
if ($unsupported !== []) {
$fail('The description contains unsupported '.implode(', ', $unsupported).'. Remove these contents or show literal examples inside code. Use ordinary Markdown and safe links; put acceptance criteria only in acceptance_criteria. No ticket fields were changed.');
}
}
}
Generated
+22 -26
View File
@@ -1451,20 +1451,20 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v13.34.0", "version": "v13.26.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4" "reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4", "url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4", "reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
"composer-runtime-api": "^2.2", "composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5", "doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4", "dragonmantank/cron-expression": "^3.4",
@@ -1576,20 +1576,18 @@
"league/flysystem-sftp-v3": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1",
"mockery/mockery": "^1.6.10", "mockery/mockery": "^1.6.10",
"opis/json-schema": "^2.4.1", "opis/json-schema": "^2.4.1",
"orchestra/testbench-core": "^11.5.0", "orchestra/testbench-core": "^11.0.0",
"pda/pheanstalk": "^7.0.0 || ^8.0.0", "pda/pheanstalk": "^7.0.0 || ^8.0.0",
"php-http/discovery": "^1.15", "php-http/discovery": "^1.15",
"phpstan/phpstan": "2.2.15", "phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
"predis/predis": "^2.3 || ^3.0", "predis/predis": "^2.3 || ^3.0",
"rector/rector": "2.6.3", "rector/rector": "^2.3",
"resend/resend-php": "^1.0", "resend/resend-php": "^1.0",
"symfony/cache": "^7.4.0 || ^8.0.0", "symfony/cache": "^7.4.0 || ^8.0.0",
"symfony/http-client": "^7.4.0 || ^8.0.0", "symfony/http-client": "^7.4.0 || ^8.0.0",
"symfony/mercure": "^0.8.0",
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
"symfony/translation": "^7.4.0 || ^8.0.0", "symfony/translation": "^7.4.0 || ^8.0.0"
"web-token/jwt-library": "^4.1"
}, },
"suggest": { "suggest": {
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
@@ -1625,10 +1623,8 @@
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).", "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -1678,7 +1674,7 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2026-09-29T12:46:21+00:00" "time": "2026-08-18T20:31:28+00:00"
}, },
{ {
"name": "laravel/mcp", "name": "laravel/mcp",
@@ -2007,16 +2003,16 @@
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.10.3", "version": "2.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/commonmark.git", "url": "https://github.com/thephpleague/commonmark.git",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f" "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f", "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f", "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2110,7 +2106,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-09-21T13:07:34+00:00" "time": "2026-08-11T16:06:25+00:00"
}, },
{ {
"name": "league/config", "name": "league/config",
@@ -2196,16 +2192,16 @@
}, },
{ {
"name": "league/flysystem", "name": "league/flysystem",
"version": "3.36.0", "version": "3.35.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/flysystem.git", "url": "https://github.com/thephpleague/flysystem.git",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25" "reference": "b277b5dc3d56650b68904117124e79c851e12376"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25", "reference": "b277b5dc3d56650b68904117124e79c851e12376",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2273,9 +2269,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/flysystem/issues", "issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0" "source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
}, },
"time": "2026-09-02T08:00:27+00:00" "time": "2026-07-06T14:42:07+00:00"
}, },
{ {
"name": "league/flysystem-aws-s3-v3", "name": "league/flysystem-aws-s3-v3",
@@ -5,7 +5,6 @@ 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>
@@ -20,7 +19,6 @@ 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,7 +7,6 @@ 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>
@@ -22,7 +21,6 @@ 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,7 +6,6 @@ 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>
@@ -21,7 +20,6 @@ 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,7 +5,6 @@ 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>
@@ -20,7 +19,6 @@ 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,7 +5,6 @@ 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>
@@ -22,7 +21,6 @@ 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,7 +5,6 @@ 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>
@@ -20,7 +19,6 @@ 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,7 +5,6 @@ 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>
@@ -20,7 +19,6 @@ 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,7 +5,6 @@ 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>
@@ -20,7 +19,6 @@ 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,
@@ -1,165 +0,0 @@
<?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.8", "version": "0.2.6",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -25,5 +25,5 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@tauri-apps/cli": "2.11.4"
}, },
"releaseNotes": "Fix blocked navigation after changing project creation options or other forms on iOS by displaying native confirmations for unsaved changes. Pending confirmations close safely when changing workspaces." "releaseNotes": "Manage local workspace settings in the web app on macOS and iOS, with shared macOS update controls and native recovery for older or unavailable servers."
} }
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.8" version = "0.2.6"
dependencies = [ dependencies = [
"semver", "semver",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.8" version = "0.2.6"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -43,7 +43,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private var settingsAvailable = false private var settingsAvailable = false
private var toolbarHeight: NSLayoutConstraint! private var toolbarHeight: NSLayoutConstraint!
private var appearance: UISegmentedControl! private var appearance: UISegmentedControl!
private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)?
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
@@ -52,8 +51,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
deinit { javaScriptConfirmation?.completion(false) }
private func text(_ key: String) -> String { private func text(_ key: String) -> String {
NSLocalizedString(key, comment: "KanbanGit iOS workspace") NSLocalizedString(key, comment: "KanbanGit iOS workspace")
} }
@@ -202,7 +199,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
func open(_ url: URL) { func open(_ url: URL) {
if server?.url == url, connection.ready { showWorkspace(); return } if server?.url == url, connection.ready { showWorkspace(); return }
completeJavaScriptConfirmation(false)
let selected = WorkspaceServer.selecting(url, current: server) let selected = WorkspaceServer.selecting(url, current: server)
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return } guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey) defaults.set(data, forKey: serverKey)
@@ -269,7 +265,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
@objc private func showSettings() { @objc private func showSettings() {
completeJavaScriptConfirmation(false)
loading.stopAnimating() loading.stopAnimating()
view.endEditing(true) view.endEditing(true)
launcher.isHidden = false launcher.isHidden = false
@@ -325,39 +320,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
} }
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
guard webView === workspace, viewIfLoaded?.window != nil,
presentedViewController == nil, javaScriptConfirmation == nil else {
completionHandler(false)
return
}
let alert = UIAlertController(title: frame.request.url?.host ?? text("appName"),
message: message, preferredStyle: .alert)
let cancel = UIAlertAction(title: text("cancel"), style: .cancel) { [weak self] _ in
self?.completeJavaScriptConfirmation(false)
}
alert.addAction(cancel)
alert.addAction(UIAlertAction(title: text("ok"), style: .default) { [weak self] _ in
self?.completeJavaScriptConfirmation(true)
})
alert.preferredAction = cancel
javaScriptConfirmation = (alert, completionHandler)
present(alert, animated: true)
}
func completeJavaScriptConfirmation(_ accepted: Bool) {
guard let confirmation = javaScriptConfirmation else { return }
javaScriptConfirmation = nil
if confirmation.alert.presentingViewController == nil {
confirmation.completion(accepted)
} else {
confirmation.alert.dismiss(animated: false) {
confirmation.completion(accepted)
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
guard server?.allows(url) == true else { guard server?.allows(url) == true else {
@@ -9,7 +9,6 @@
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server."; "switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
"confirmSwitch" = "Switch server"; "confirmSwitch" = "Switch server";
"cancel" = "Cancel"; "cancel" = "Cancel";
"ok" = "OK";
"connecting" = "Connecting to your server…"; "connecting" = "Connecting to your server…";
"connected" = "Your workspace is open. Use Server settings to change servers."; "connected" = "Your workspace is open. Use Server settings to change servers.";
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept."; "connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
@@ -135,94 +135,6 @@ final class WorkspaceTests: XCTestCase {
"Changing workspaces must retain native gesture navigation") "Changing workspaces must retain native gesture navigation")
} }
@MainActor
func testDirtyFormNavigationPresentsNativeConfirmation() async throws {
let animationsEnabled = UIView.areAnimationsEnabled
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(animationsEnabled) }
let name = "ios-confirmation-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true; window.rootViewController = nil }
let url = URL(string: "https://example.invalid/")!
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
<a id="back" href="#back">Back</a><script>
window.visits = 0;
document.querySelector('#back').onclick = event => {
event.preventDefault();
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
};
</script></body></html>
""")
try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'")
_ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true")
let pristineVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(pristineVisits as? Int, 1)
XCTAssertNil(controller.presentedViewController)
_ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true")
var expectedVisits = 1
for (index, accepted) in [false, true, false, true].enumerated() {
let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
let alert = try await waitForConfirmation(controller)
XCTAssertEqual(alert.title, "example.invalid")
XCTAssertEqual(alert.message, "Discard unsaved changes?")
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default])
XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft")
if index == 3 {
await withCheckedContinuation { continuation in
alert.dismiss(animated: false) { continuation.resume() }
}
}
controller.completeJavaScriptConfirmation(accepted)
controller.completeJavaScriptConfirmation(!accepted)
_ = try await click.value
if accepted { expectedVisits += 1 }
let visits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each")
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
XCTAssertEqual(draft as? String, "Unsaved project")
XCTAssertNil(controller.presentedViewController)
}
let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert)
controller.present(otherAlert, animated: false)
let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')")
XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript")
XCTAssertTrue(controller.presentedViewController === otherAlert)
await withCheckedContinuation { continuation in
otherAlert.dismiss(animated: false) { continuation.resume() }
}
let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
_ = try await waitForConfirmation(controller)
controller.open(URL(string: "https://other.invalid/")!)
_ = try await pendingClick.value
XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation")
let oldVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(oldVisits as? Int, expectedVisits)
let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')")
XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations")
XCTAssertNil(controller.presentedViewController)
}
@MainActor
private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController {
for _ in 0..<100 {
if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert }
try await Task.sleep(for: .milliseconds(50))
}
return try XCTUnwrap(controller.presentedViewController as? UIAlertController,
"Dirty forms must offer a visible confirmation instead of silently cancelling navigation")
}
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws { func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
for body in [ for body in [
#"{"version":2,"id":1,"action":"getState","payload":{}}"#, #"{"version":2,"id":1,"action":"getState","payload":{}}"#,
@@ -33,13 +33,6 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
remoteInfo = "kanbangit-native_iOS";
};
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = { 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */; containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
@@ -55,6 +48,7 @@
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; }; 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; }; 1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; }; 2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -73,7 +67,6 @@
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; }; 946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; }; 9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; };
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; }; AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; }; BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
@@ -196,7 +189,7 @@
59D2DE9FD94E6D2D64CB0117 /* Products */ = { 59D2DE9FD94E6D2D64CB0117 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */, 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */, A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */, 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
); );
@@ -255,7 +248,7 @@
packageProductDependencies = ( packageProductDependencies = (
); );
productName = "kanbangit-native_iOS"; productName = "kanbangit-native_iOS";
productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */; productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = { 988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
@@ -286,7 +279,6 @@
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
); );
name = WorkspaceTests; name = WorkspaceTests;
packageProductDependencies = ( packageProductDependencies = (
@@ -426,11 +418,6 @@
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */; target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */; targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
}; };
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
@@ -464,7 +451,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -480,7 +467,7 @@
); );
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.8; MARKETING_VERSION = 0.2.6;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -641,7 +628,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -657,7 +644,7 @@
); );
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.8; MARKETING_VERSION = 0.2.6;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -704,7 +691,6 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
}; };
name = debug; name = debug;
}; };
@@ -724,7 +710,6 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
}; };
name = release; name = release;
}; };
+2 -6
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.8" MARKETING_VERSION: "0.2.6"
CURRENT_PROJECT_VERSION: "7" CURRENT_PROJECT_VERSION: "6"
configs: configs:
debug: debug:
CODE_SIGN_IDENTITY: Apple Development CODE_SIGN_IDENTITY: Apple Development
@@ -40,7 +40,6 @@ targets:
kanbangit-native_iOS: kanbangit-native_iOS:
type: application type: application
platform: iOS platform: iOS
productName: KanbanGit
sources: sources:
- path: Sources - path: Sources
- path: ../../../ui/workspace-navigation.js - path: ../../../ui/workspace-navigation.js
@@ -116,8 +115,6 @@ targets:
WorkspaceTests: WorkspaceTests:
type: bundle.unit-test type: bundle.unit-test
platform: iOS platform: iOS
dependencies:
- target: kanbangit-native_iOS
sources: sources:
- path: ../../../ui/workspace-navigation.js - path: ../../../ui/workspace-navigation.js
- path: ../../../ui/workspace-settings.js - path: ../../../ui/workspace-settings.js
@@ -129,7 +126,6 @@ targets:
base: base:
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
DEVELOPMENT_TEAM: ZZX2KZGTHU DEVELOPMENT_TEAM: ZZX2KZGTHU
CODE_SIGN_STYLE: Manual CODE_SIGN_STYLE: Manual
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
-49
View File
@@ -1,55 +1,6 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h> #import <WebKit/WebKit.h>
#import <objc/runtime.h>
@interface KanbanWorkspaceUIDelegate : NSObject <WKUIDelegate>
@property(nonatomic, strong) id<WKUIDelegate> originalDelegate;
@end
@implementation KanbanWorkspaceUIDelegate
- (BOOL)respondsToSelector:(SEL)selector {
return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector];
}
- (id)forwardingTargetForSelector:(SEL)selector {
if ([self.originalDelegate respondsToSelector:selector]) {
return self.originalDelegate;
}
return [super forwardingTargetForSelector:selector];
}
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
NSWindow *window = webView.window;
if (!window || window.attachedSheet) {
completionHandler(NO);
return;
}
NSAlert *alert = [NSAlert new];
alert.alertStyle = NSAlertStyleWarning;
alert.messageText = frame.request.URL.host ?: @"KanbanGit";
alert.informativeText = message;
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"];
[alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
completionHandler(response == NSAlertFirstButtonReturn);
}];
}
@end
static char workspaceUIDelegateKey;
void install_workspace_navigation(void *pointer) { void install_workspace_navigation(void *pointer) {
WKWebView *webview = (__bridge WKWebView *)pointer; WKWebView *webview = (__bridge WKWebView *)pointer;
webview.allowsBackForwardNavigationGestures = YES; webview.allowsBackForwardNavigationGestures = YES;
if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) {
return;
}
KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new];
delegate.originalDelegate = webview.UIDelegate;
// WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view.
objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
webview.UIDelegate = delegate;
} }
+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.8", "version": "0.2.6",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -25,7 +25,7 @@
], ],
"macOS": { "macOS": {
"minimumSystemVersion": "14.0", "minimumSystemVersion": "14.0",
"bundleVersion": "12", "bundleVersion": "11",
"dmg": { "dmg": {
"windowSize": { "windowSize": {
"width": 660, "width": 660,
+1 -1
View File
@@ -5,7 +5,7 @@
"iOS": { "iOS": {
"minimumSystemVersion": "17.0", "minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU", "developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "7" "bundleVersion": "6"
} }
} }
} }
+1 -92
View File
@@ -17,53 +17,6 @@ static void waitUntil(BOOL (^condition)(void), NSString *message) {
check(condition(), message); check(condition(), message);
} }
static id evaluate(WKWebView *webview, NSString *script) {
__block BOOL finished = NO;
__block id result;
[webview evaluateJavaScript:script completionHandler:^(id value, NSError *error) {
check(error == nil, error.localizedDescription);
result = value;
finished = YES;
}];
waitUntil(^BOOL { return finished; }, @"JavaScript must finish after the confirmation is dismissed");
return result;
}
static NSButton *findButton(NSView *view, NSString *title) {
if ([view isKindOfClass:NSButton.class] && [((NSButton *)view).title isEqualToString:title]) {
return (NSButton *)view;
}
for (NSView *child in view.subviews) {
NSButton *button = findButton(child, title);
if (button) { return button; }
}
return nil;
}
static BOOL containsText(NSView *view, NSString *text) {
if ([view isKindOfClass:NSTextField.class] && [((NSTextField *)view).stringValue isEqualToString:text]) {
return YES;
}
for (NSView *child in view.subviews) {
if (containsText(child, text)) { return YES; }
}
return NO;
}
@interface WorkspaceUI : NSObject <WKUIDelegate>
@property(nonatomic) BOOL closed;
@property(nonatomic) BOOL uploaded;
@end
@implementation WorkspaceUI
- (void)webViewDidClose:(WKWebView *)webView { self.closed = YES; }
- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray<NSURL *> *))completionHandler {
self.uploaded = YES;
completionHandler(nil);
}
@end
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate> @interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
@property(nonatomic) NSUInteger finished; @property(nonatomic) NSUInteger finished;
@end @end
@@ -93,18 +46,9 @@ int main(void) {
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"]; [configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration]; WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
webview.navigationDelegate = pages; webview.navigationDelegate = pages;
WorkspaceUI *originalUI = [WorkspaceUI new];
webview.UIDelegate = originalUI;
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures"); check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
install_workspace_navigation((__bridge void *)webview); install_workspace_navigation((__bridge void *)webview);
id<WKUIDelegate> installedUI = webview.UIDelegate;
install_workspace_navigation((__bridge void *)webview); install_workspace_navigation((__bridge void *)webview);
check(webview.UIDelegate == installedUI, @"Installing workspace behavior twice must retain the same delegate");
check([installedUI respondsToSelector:@selector(webViewDidClose:)], @"Existing popup callbacks must remain available");
[installedUI webViewDidClose:webview];
check(originalUI.closed, @"Existing popup callbacks must reach the original delegate");
check([installedUI respondsToSelector:@selector(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:)],
@"Existing file upload callbacks must remain available");
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures"); check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter"); check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate"); check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
@@ -125,41 +69,6 @@ int main(void) {
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward, check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
@"Forward must restore the second page"); @"Forward must restore the second page");
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation"); check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
window.releasedWhenClosed = NO;
window.title = @"KanbanGit navigation test";
window.contentView = webview;
[window orderFront:nil];
evaluate(webview, @"document.body.innerHTML = `<input id='duplicate' type='radio'><input id='draft' value='Unsaved project'><input id='upload' type='file'><a id='back' href='#back'>Back</a>`; window.visits = 0; document.querySelector('#back').onclick = event => { event.preventDefault(); if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++; }; true;");
evaluate(webview, @"document.querySelector('#upload').click()");
waitUntil(^BOOL { return originalUI.uploaded; }, @"File selection must still reach the original UI delegate");
evaluate(webview, @"document.querySelector('#back').click()");
check([evaluate(webview, @"window.visits") intValue] == 1, @"Pristine forms must navigate without a confirmation");
evaluate(webview, @"document.querySelector('#duplicate').click()");
for (NSString *choice in @[@"Cancel", @"OK", @"Cancel", @"OK"]) {
__block BOOL finished = NO;
[webview evaluateJavaScript:@"document.querySelector('#back').click()" completionHandler:^(id result, NSError *error) {
check(error == nil, error.localizedDescription);
finished = YES;
}];
waitUntil(^BOOL { return window.attachedSheet != nil; }, @"Dirty form navigation must show a native confirmation");
check(!finished, @"The original click must wait for the user's decision");
check(containsText(window.attachedSheet.contentView, @"workspace"), @"The confirmation must identify the requesting website");
check(containsText(window.attachedSheet.contentView, @"Discard unsaved changes?"), @"The confirmation must show the application's message");
NSButton *button = findButton(window.attachedSheet.contentView, choice);
check(button != nil, @"The native confirmation must offer OK and Cancel");
[button performClick:nil];
waitUntil(^BOOL { return finished && window.attachedSheet == nil; }, @"Dismissal must release the original navigation");
check([evaluate(webview, @"document.querySelector('#draft').value") isEqualToString:@"Unsaved project"],
@"Cancelling a confirmation must preserve the draft");
}
check([evaluate(webview, @"window.visits") intValue] == 3, @"Only accepted visits must run, exactly once each");
window.contentView = nil;
check(![evaluate(webview, @"confirm('Detached view')") boolValue], @"A detached view must cancel and release JavaScript immediately");
[window orderOut:nil];
[window close];
puts("PASS: WebKit interactive gesture configuration, full-page back/forward, native confirmations and delegate forwarding");
} }
} }
+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 preserves history and confirms dirty form navigation without losing delegate callbacks', 'WebKit owns interactive navigation and preserves native back/forward history',
{ skip: process.platform !== 'darwin' }, { skip: process.platform !== 'darwin' },
() => { () => {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-')); const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
@@ -48,7 +48,7 @@ test(
assert.equal( assert.equal(
result.status, result.status,
0, 0,
result.stderr || result.error?.message || result.signal, result.stderr || result.error?.message,
); );
assert.match(result.stdout, /PASS: WebKit/); assert.match(result.stdout, /PASS: WebKit/);
} finally { } finally {
+2 -2
View File
@@ -15,7 +15,7 @@
"dev": "vite", "dev": "vite",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"lint:check": "eslint .", "lint:check": "eslint .",
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit" "types:check": "vue-tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@@ -62,7 +62,7 @@
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1", "tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5", "tw-animate-css": "^1.2.5",
"vue": "^3.5.43", "vue": "^3.5.13",
"vue-input-otp": "^0.3.2" "vue-input-otp": "^0.3.2"
}, },
"optionalDependencies": { "optionalDependencies": {
+141 -167
View File
@@ -203,17 +203,6 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
'@vue/server-renderer@<3.5.42': ^3.5.42
axios@>=1.13.0 <1.20.0: ^1.20.0
axios@>=1.15.0 <1.20.0: ^1.20.0
axios@>=1.15.1 <1.20.0: ^1.20.0
axios@>=1.15.2 <1.20.0: ^1.20.0
axios@>=1.16.1 <1.20.0: ^1.20.0
axios@>=1.17.0 <1.20.0: ^1.20.0
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
importers: importers:
.: .:
@@ -223,7 +212,7 @@ importers:
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
'@inertiajs/vue3': '@inertiajs/vue3':
specifier: ^2.3.7 specifier: ^2.3.7
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.43(typescript@5.9.3)) version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))
'@tiptap/core': '@tiptap/core':
specifier: ^3.31.3 specifier: ^3.31.3
version: 3.31.3(@tiptap/pm@3.31.3) version: 3.31.3(@tiptap/pm@3.31.3)
@@ -238,7 +227,7 @@ importers:
version: 3.31.3 version: 3.31.3
'@tiptap/vue-3': '@tiptap/vue-3':
specifier: ^3.31.3 specifier: ^3.31.3
version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.43(typescript@5.9.3)) version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))
'@vueuse/core': '@vueuse/core':
specifier: ^12.8.2 specifier: ^12.8.2
version: 12.8.2(typescript@5.9.3) version: 12.8.2(typescript@5.9.3)
@@ -253,10 +242,10 @@ importers:
version: 3.0.0(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1)) version: 3.0.0(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))
lucide-vue-next: lucide-vue-next:
specifier: ^0.468.0 specifier: ^0.468.0
version: 0.468.0(vue@3.5.43(typescript@5.9.3)) version: 0.468.0(vue@3.5.30(typescript@5.9.3))
reka-ui: reka-ui:
specifier: ^2.6.1 specifier: ^2.6.1
version: 2.9.2(vue@3.5.43(typescript@5.9.3)) version: 2.9.2(vue@3.5.30(typescript@5.9.3))
tailwind-merge: tailwind-merge:
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.5.0 version: 3.5.0
@@ -267,11 +256,11 @@ importers:
specifier: ^1.2.5 specifier: ^1.2.5
version: 1.4.0 version: 1.4.0
vue: vue:
specifier: ^3.5.43 specifier: ^3.5.13
version: 3.5.43(typescript@5.9.3) version: 3.5.30(typescript@5.9.3)
vue-input-otp: vue-input-otp:
specifier: ^0.3.2 specifier: ^0.3.2
version: 0.3.2(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)) version: 0.3.2(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.39.4 specifier: ^9.39.4
@@ -296,7 +285,7 @@ importers:
version: 8.58.1(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) version: 8.58.1(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.43(typescript@5.9.3)) version: 6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.30(typescript@5.9.3))
'@vue/eslint-config-typescript': '@vue/eslint-config-typescript':
specifier: ^14.3.0 specifier: ^14.3.0
version: 14.7.0(eslint-plugin-vue@9.33.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1))(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) version: 14.7.0(eslint-plugin-vue@9.33.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1))(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
@@ -373,21 +362,21 @@ importers:
packages: packages:
'@babel/helper-string-parser@7.29.7': '@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7': '@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/parser@7.29.9': '@babel/parser@7.29.2':
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/types@7.29.8': '@babel/types@7.29.0':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@emnapi/core@1.9.1': '@emnapi/core@1.9.1':
@@ -1112,17 +1101,17 @@ packages:
typescript: typescript:
optional: true optional: true
'@vue/compiler-core@3.5.43': '@vue/compiler-core@3.5.30':
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
'@vue/compiler-dom@3.5.43': '@vue/compiler-dom@3.5.30':
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
'@vue/compiler-sfc@3.5.43': '@vue/compiler-sfc@3.5.30':
resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
'@vue/compiler-ssr@3.5.43': '@vue/compiler-ssr@3.5.30':
resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
'@vue/compiler-vue2@2.7.16': '@vue/compiler-vue2@2.7.16':
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
@@ -1146,20 +1135,22 @@ packages:
typescript: typescript:
optional: true optional: true
'@vue/reactivity@3.5.43': '@vue/reactivity@3.5.30':
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
'@vue/runtime-core@3.5.43': '@vue/runtime-core@3.5.30':
resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
'@vue/runtime-dom@3.5.43': '@vue/runtime-dom@3.5.30':
resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
'@vue/server-renderer@3.5.43': '@vue/server-renderer@3.5.30':
resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
peerDependencies:
vue: 3.5.30
'@vue/shared@3.5.43': '@vue/shared@3.5.30':
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
'@vueuse/core@12.8.2': '@vueuse/core@12.8.2':
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
@@ -1253,8 +1244,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
axios@1.20.0: axios@1.19.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -1266,14 +1257,14 @@ packages:
boolbase@1.0.0: boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@1.1.21: brace-expansion@1.1.18:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@2.1.7: brace-expansion@2.1.4:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@5.0.12: brace-expansion@5.0.9:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
braces@3.0.3: braces@3.0.3:
@@ -2167,11 +2158,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
nanoid@3.3.20:
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-postinstall@0.3.4: napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -2270,10 +2256,6 @@ packages:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
postcss@8.5.29:
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -2490,8 +2472,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'} engines: {node: '>=8'}
shell-quote@1.12.0: shell-quote@1.9.0:
resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
side-channel-list@1.0.1: side-channel-list@1.0.1:
@@ -2510,8 +2492,8 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
source-map-js@1.2.2: source-map-js@1.2.1:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
stable-hash-x@0.2.0: stable-hash-x@0.2.0:
@@ -2738,8 +2720,8 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=5.0.0' typescript: '>=5.0.0'
vue@3.5.43: vue@3.5.30:
resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
peerDependenciesMeta: peerDependenciesMeta:
@@ -2800,18 +2782,18 @@ packages:
snapshots: snapshots:
'@babel/helper-string-parser@7.29.7': {} '@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.9': '@babel/parser@7.29.2':
dependencies: dependencies:
'@babel/types': 7.29.8 '@babel/types': 7.29.0
'@babel/types@7.29.8': '@babel/types@7.29.0':
dependencies: dependencies:
'@babel/helper-string-parser': 7.29.7 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.28.5
'@emnapi/core@1.9.1': '@emnapi/core@1.9.1':
dependencies: dependencies:
@@ -2886,11 +2868,11 @@ snapshots:
'@floating-ui/utils@0.2.11': {} '@floating-ui/utils@0.2.11': {}
'@floating-ui/vue@1.1.11(vue@3.5.43(typescript@5.9.3))': '@floating-ui/vue@1.1.11(vue@3.5.30(typescript@5.9.3))':
dependencies: dependencies:
'@floating-ui/dom': 1.7.6 '@floating-ui/dom': 1.7.6
'@floating-ui/utils': 0.2.11 '@floating-ui/utils': 0.2.11
vue-demi: 0.14.10(vue@3.5.43(typescript@5.9.3)) vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3))
transitivePeerDependencies: transitivePeerDependencies:
- '@vue/composition-api' - '@vue/composition-api'
- vue - vue
@@ -2914,7 +2896,7 @@ snapshots:
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)': '@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
dependencies: dependencies:
'@types/lodash-es': 4.17.12 '@types/lodash-es': 4.17.12
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
lodash-es: 4.18.1 lodash-es: 4.18.1
qs: 6.16.0 qs: 6.16.0
@@ -2922,13 +2904,13 @@ snapshots:
- debug - debug
- supports-color - supports-color
'@inertiajs/vue3@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.43(typescript@5.9.3))': '@inertiajs/vue3@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))':
dependencies: dependencies:
'@inertiajs/core': 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) '@inertiajs/core': 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
'@types/lodash-es': 4.17.12 '@types/lodash-es': 4.17.12
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
lodash-es: 4.18.1 lodash-es: 4.18.1
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
- supports-color - supports-color
@@ -3063,7 +3045,7 @@ snapshots:
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.32.0 lightningcss: 1.32.0
magic-string: 0.30.21 magic-string: 0.30.21
source-map-js: 1.2.2 source-map-js: 1.2.1
tailwindcss: 4.2.2 tailwindcss: 4.2.2
'@tailwindcss/oxide-android-arm64@4.2.2': '@tailwindcss/oxide-android-arm64@4.2.2':
@@ -3126,10 +3108,10 @@ snapshots:
'@tanstack/virtual-core@3.13.23': {} '@tanstack/virtual-core@3.13.23': {}
'@tanstack/vue-virtual@3.13.23(vue@3.5.43(typescript@5.9.3))': '@tanstack/vue-virtual@3.13.23(vue@3.5.30(typescript@5.9.3))':
dependencies: dependencies:
'@tanstack/virtual-core': 3.13.23 '@tanstack/virtual-core': 3.13.23
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
dependencies: dependencies:
@@ -3293,12 +3275,12 @@ snapshots:
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3 '@tiptap/pm': 3.31.3
'@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.43(typescript@5.9.3))': '@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))':
dependencies: dependencies:
'@floating-ui/dom': 1.7.6 '@floating-ui/dom': 1.7.6
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3 '@tiptap/pm': 3.31.3
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
optionalDependencies: optionalDependencies:
'@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
@@ -3476,11 +3458,11 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1': '@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true optional: true
'@vitejs/plugin-vue@6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.43(typescript@5.9.3))': '@vitejs/plugin-vue@6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.30(typescript@5.9.3))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2 '@rolldown/pluginutils': 1.0.0-rc.2
vite: 8.2.2(@types/node@22.19.15)(jiti@2.6.1) vite: 8.2.2(@types/node@22.19.15)(jiti@2.6.1)
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
'@volar/language-core@2.4.15': '@volar/language-core@2.4.15':
dependencies: dependencies:
@@ -3496,35 +3478,35 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
'@vue/compiler-core@3.5.43': '@vue/compiler-core@3.5.30':
dependencies: dependencies:
'@babel/parser': 7.29.9 '@babel/parser': 7.29.2
'@vue/shared': 3.5.43 '@vue/shared': 3.5.30
entities: 7.0.1 entities: 7.0.1
estree-walker: 2.0.2 estree-walker: 2.0.2
source-map-js: 1.2.2 source-map-js: 1.2.1
'@vue/compiler-dom@3.5.43': '@vue/compiler-dom@3.5.30':
dependencies: dependencies:
'@vue/compiler-core': 3.5.43 '@vue/compiler-core': 3.5.30
'@vue/shared': 3.5.43 '@vue/shared': 3.5.30
'@vue/compiler-sfc@3.5.43': '@vue/compiler-sfc@3.5.30':
dependencies: dependencies:
'@babel/parser': 7.29.9 '@babel/parser': 7.29.2
'@vue/compiler-core': 3.5.43 '@vue/compiler-core': 3.5.30
'@vue/compiler-dom': 3.5.43 '@vue/compiler-dom': 3.5.30
'@vue/compiler-ssr': 3.5.43 '@vue/compiler-ssr': 3.5.30
'@vue/shared': 3.5.43 '@vue/shared': 3.5.30
estree-walker: 2.0.2 estree-walker: 2.0.2
magic-string: 0.30.21 magic-string: 0.30.21
postcss: 8.5.29 postcss: 8.5.26
source-map-js: 1.2.2 source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.43': '@vue/compiler-ssr@3.5.30':
dependencies: dependencies:
'@vue/compiler-dom': 3.5.43 '@vue/compiler-dom': 3.5.30
'@vue/shared': 3.5.43 '@vue/shared': 3.5.30
'@vue/compiler-vue2@2.7.16': '@vue/compiler-vue2@2.7.16':
dependencies: dependencies:
@@ -3547,9 +3529,9 @@ snapshots:
'@vue/language-core@2.2.12(typescript@5.9.3)': '@vue/language-core@2.2.12(typescript@5.9.3)':
dependencies: dependencies:
'@volar/language-core': 2.4.15 '@volar/language-core': 2.4.15
'@vue/compiler-dom': 3.5.43 '@vue/compiler-dom': 3.5.30
'@vue/compiler-vue2': 2.7.16 '@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.43 '@vue/shared': 3.5.30
alien-signals: 1.0.13 alien-signals: 1.0.13
minimatch: 9.0.9 minimatch: 9.0.9
muggle-string: 0.4.1 muggle-string: 0.4.1
@@ -3557,45 +3539,45 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
'@vue/reactivity@3.5.43': '@vue/reactivity@3.5.30':
dependencies: dependencies:
'@vue/shared': 3.5.43 '@vue/shared': 3.5.30
'@vue/runtime-core@3.5.43': '@vue/runtime-core@3.5.30':
dependencies: dependencies:
'@vue/reactivity': 3.5.43 '@vue/reactivity': 3.5.30
'@vue/shared': 3.5.43 '@vue/shared': 3.5.30
'@vue/runtime-dom@3.5.43': '@vue/runtime-dom@3.5.30':
dependencies: dependencies:
'@vue/reactivity': 3.5.43 '@vue/reactivity': 3.5.30
'@vue/runtime-core': 3.5.43 '@vue/runtime-core': 3.5.30
'@vue/shared': 3.5.43 '@vue/shared': 3.5.30
csstype: 3.2.3 csstype: 3.2.3
'@vue/server-renderer@3.5.43': '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))':
dependencies: dependencies:
'@vue/compiler-ssr': 3.5.43 '@vue/compiler-ssr': 3.5.30
'@vue/runtime-dom': 3.5.43 '@vue/shared': 3.5.30
'@vue/shared': 3.5.43 vue: 3.5.30(typescript@5.9.3)
'@vue/shared@3.5.43': {} '@vue/shared@3.5.30': {}
'@vueuse/core@12.8.2(typescript@5.9.3)': '@vueuse/core@12.8.2(typescript@5.9.3)':
dependencies: dependencies:
'@types/web-bluetooth': 0.0.21 '@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 12.8.2 '@vueuse/metadata': 12.8.2
'@vueuse/shared': 12.8.2(typescript@5.9.3) '@vueuse/shared': 12.8.2(typescript@5.9.3)
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@vueuse/core@14.2.1(vue@3.5.43(typescript@5.9.3))': '@vueuse/core@14.2.1(vue@3.5.30(typescript@5.9.3))':
dependencies: dependencies:
'@types/web-bluetooth': 0.0.21 '@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.2.1 '@vueuse/metadata': 14.2.1
'@vueuse/shared': 14.2.1(vue@3.5.43(typescript@5.9.3)) '@vueuse/shared': 14.2.1(vue@3.5.30(typescript@5.9.3))
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
'@vueuse/metadata@12.8.2': {} '@vueuse/metadata@12.8.2': {}
@@ -3603,13 +3585,13 @@ snapshots:
'@vueuse/shared@12.8.2(typescript@5.9.3)': '@vueuse/shared@12.8.2(typescript@5.9.3)':
dependencies: dependencies:
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@vueuse/shared@14.2.1(vue@3.5.43(typescript@5.9.3))': '@vueuse/shared@14.2.1(vue@3.5.30(typescript@5.9.3))':
dependencies: dependencies:
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
acorn-jsx@5.3.2(acorn@8.16.0): acorn-jsx@5.3.2(acorn@8.16.0):
dependencies: dependencies:
@@ -3702,7 +3684,7 @@ snapshots:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
dependencies: dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1)) follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
form-data: 4.0.6 form-data: 4.0.6
@@ -3718,16 +3700,16 @@ snapshots:
boolbase@1.0.0: {} boolbase@1.0.0: {}
brace-expansion@1.1.21: brace-expansion@1.1.18:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
concat-map: 0.0.1 concat-map: 0.0.1
brace-expansion@2.1.7: brace-expansion@2.1.4:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.12: brace-expansion@5.0.9:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@@ -3787,7 +3769,7 @@ snapshots:
dependencies: dependencies:
chalk: 4.1.2 chalk: 4.1.2
rxjs: 7.8.2 rxjs: 7.8.2
shell-quote: 1.12.0 shell-quote: 1.9.0
supports-color: 8.1.1 supports-color: 8.1.1
tree-kill: 1.2.2 tree-kill: 1.2.2
yargs: 17.7.2 yargs: 17.7.2
@@ -4470,7 +4452,7 @@ snapshots:
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
dependencies: dependencies:
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
lodash-es: 4.18.1 lodash-es: 4.18.1
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
@@ -4598,9 +4580,9 @@ snapshots:
lodash@4.18.1: {} lodash@4.18.1: {}
lucide-vue-next@0.468.0(vue@3.5.43(typescript@5.9.3)): lucide-vue-next@0.468.0(vue@3.5.30(typescript@5.9.3)):
dependencies: dependencies:
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
@@ -4625,15 +4607,15 @@ snapshots:
minimatch@10.2.4: minimatch@10.2.4:
dependencies: dependencies:
brace-expansion: 5.0.12 brace-expansion: 5.0.9
minimatch@3.1.5: minimatch@3.1.5:
dependencies: dependencies:
brace-expansion: 1.1.21 brace-expansion: 1.1.18
minimatch@9.0.9: minimatch@9.0.9:
dependencies: dependencies:
brace-expansion: 2.1.7 brace-expansion: 2.1.4
minimist@1.2.8: {} minimist@1.2.8: {}
@@ -4643,8 +4625,6 @@ snapshots:
nanoid@3.3.18: {} nanoid@3.3.18: {}
nanoid@3.3.20: {}
napi-postinstall@0.3.4: {} napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
@@ -4742,13 +4722,7 @@ snapshots:
dependencies: dependencies:
nanoid: 3.3.18 nanoid: 3.3.18
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.2 source-map-js: 1.2.1
postcss@8.5.29:
dependencies:
nanoid: 3.3.20
picocolors: 1.1.1
source-map-js: 1.2.2
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
@@ -4867,19 +4841,19 @@ snapshots:
gopd: 1.2.0 gopd: 1.2.0
set-function-name: 2.0.2 set-function-name: 2.0.2
reka-ui@2.9.2(vue@3.5.43(typescript@5.9.3)): reka-ui@2.9.2(vue@3.5.30(typescript@5.9.3)):
dependencies: dependencies:
'@floating-ui/dom': 1.7.6 '@floating-ui/dom': 1.7.6
'@floating-ui/vue': 1.1.11(vue@3.5.43(typescript@5.9.3)) '@floating-ui/vue': 1.1.11(vue@3.5.30(typescript@5.9.3))
'@internationalized/date': 3.12.0 '@internationalized/date': 3.12.0
'@internationalized/number': 3.6.5 '@internationalized/number': 3.6.5
'@tanstack/vue-virtual': 3.13.23(vue@3.5.43(typescript@5.9.3)) '@tanstack/vue-virtual': 3.13.23(vue@3.5.30(typescript@5.9.3))
'@vueuse/core': 14.2.1(vue@3.5.43(typescript@5.9.3)) '@vueuse/core': 14.2.1(vue@3.5.30(typescript@5.9.3))
'@vueuse/shared': 14.2.1(vue@3.5.43(typescript@5.9.3)) '@vueuse/shared': 14.2.1(vue@3.5.30(typescript@5.9.3))
aria-hidden: 1.2.6 aria-hidden: 1.2.6
defu: 6.1.7 defu: 6.1.7
ohash: 2.0.11 ohash: 2.0.11
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- '@vue/composition-api' - '@vue/composition-api'
@@ -4979,7 +4953,7 @@ snapshots:
shebang-regex@3.0.0: {} shebang-regex@3.0.0: {}
shell-quote@1.12.0: {} shell-quote@1.9.0: {}
side-channel-list@1.0.1: side-channel-list@1.0.1:
dependencies: dependencies:
@@ -5009,7 +4983,7 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
source-map-js@1.2.2: {} source-map-js@1.2.1: {}
stable-hash-x@0.2.0: {} stable-hash-x@0.2.0: {}
@@ -5211,9 +5185,9 @@ snapshots:
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.43(typescript@5.9.3)): vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)):
dependencies: dependencies:
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1): vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1):
dependencies: dependencies:
@@ -5240,11 +5214,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vue-input-otp@0.3.2(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)): vue-input-otp@0.3.2(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)):
dependencies: dependencies:
'@vueuse/core': 12.8.2(typescript@5.9.3) '@vueuse/core': 12.8.2(typescript@5.9.3)
reka-ui: 2.9.2(vue@3.5.43(typescript@5.9.3)) reka-ui: 2.9.2(vue@3.5.30(typescript@5.9.3))
vue: 3.5.43(typescript@5.9.3) vue: 3.5.30(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- '@vue/composition-api' - '@vue/composition-api'
- typescript - typescript
@@ -5255,13 +5229,13 @@ snapshots:
'@vue/language-core': 2.2.12(typescript@5.9.3) '@vue/language-core': 2.2.12(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
vue@3.5.43(typescript@5.9.3): vue@3.5.30(typescript@5.9.3):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.43 '@vue/compiler-dom': 3.5.30
'@vue/compiler-sfc': 3.5.43 '@vue/compiler-sfc': 3.5.30
'@vue/runtime-dom': 3.5.43 '@vue/runtime-dom': 3.5.30
'@vue/server-renderer': 3.5.43 '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3))
'@vue/shared': 3.5.43 '@vue/shared': 3.5.30
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
-18
View File
@@ -1,22 +1,4 @@
minimumReleaseAge: 10080 minimumReleaseAge: 10080
# Security patches for GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p.
minimumReleaseAgeExclude:
- brace-expansion@1.1.21
- brace-expansion@2.1.7
- brace-expansion@5.0.12
allowBuilds: allowBuilds:
unrs-resolver: true unrs-resolver: true
vue-demi: true vue-demi: true
overrides:
'@vue/server-renderer@<3.5.42': ^3.5.42
axios@>=1.13.0 <1.20.0: ^1.20.0
axios@>=1.15.0 <1.20.0: ^1.20.0
axios@>=1.15.1 <1.20.0: ^1.20.0
axios@>=1.15.2 <1.20.0: ^1.20.0
axios@>=1.16.1 <1.20.0: ^1.20.0
axios@>=1.17.0 <1.20.0: ^1.20.0
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
audit:
ignore:
- GHSA-vfj7-8cjw-p6xm
@@ -3,6 +3,7 @@ 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';
@@ -11,13 +12,7 @@ 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 { import { index, update } from '@/routes/project-templates';
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';
@@ -27,18 +22,11 @@ import type { ProjectTemplateModel } from '@/types/models/project-template-model
const props = defineProps<{ const props = defineProps<{
copyAreaOptions: ProjectDuplicateCopyAreaOption[]; copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project?: ProjectModel | null; project?: ProjectModel;
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(
@@ -46,23 +34,14 @@ const form = useForm(
{ {
name: name:
props.template?.name ?? props.template?.name ??
(props.project t('templates.defaultName', { project: props.project?.name ?? '' }),
? t('templates.defaultName', { project: props.project.name })
: ''),
slug: slug:
props.template?.slug ?? props.template?.slug ??
publishingBase.value?.slug ?? `${props.project?.key.toLowerCase()}-template`,
(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);
@@ -74,10 +53,13 @@ const copyAreasError = computed(
)?.[1], )?.[1],
); );
const cancelTarget = computed(() => const cancelTarget = computed(() =>
props.project ? show(props.project.id) : index(), props.template ? index() : show(props.project!.id),
); );
function submit(): void { function submit(): void {
if (linkedCount.value > 0 && !form.confirm_linked_projects_update) { if (
!source.value ||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
) {
return; return;
} }
form.transform((data) => form.transform((data) =>
@@ -85,7 +67,6 @@ 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:
@@ -93,21 +74,10 @@ 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 props.template ? update(props.template.id) : store(props.project!.id),
? update(props.template.id)
: props.project
? store(props.project.id)
: storeStandalone(),
{ preserveScroll: true }, { preserveScroll: true },
); );
} }
@@ -120,6 +90,12 @@ 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]"
> >
@@ -139,7 +115,7 @@ function submit(): void {
name="name" name="name"
required required
maxlength="255" maxlength="255"
:disabled="form.processing" :disabled="form.processing || !source"
:placeholder="t('templates.namePlaceholder')" :placeholder="t('templates.namePlaceholder')"
:invalid="!!form.errors.name" :invalid="!!form.errors.name"
aria-describedby="name-error" aria-describedby="name-error"
@@ -163,11 +139,7 @@ function submit(): void {
name="slug" name="slug"
required required
maxlength="255" maxlength="255"
:disabled=" :disabled="!!template || form.processing || !source"
!!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"
@@ -180,7 +152,7 @@ function submit(): void {
> >
{{ {{
t( t(
template || publishingBase template
? 'templates.slugLocked' ? 'templates.slugLocked'
: 'templates.slugHelp', : 'templates.slugHelp',
) )
@@ -194,7 +166,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" :disabled="form.processing || !source"
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">
@@ -259,14 +231,6 @@ 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"
@@ -346,7 +310,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" :disabled="form.processing || !source"
: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"
@@ -371,7 +335,8 @@ function submit(): void {
:loading="form.processing" :loading="form.processing"
:loading-label="t('forms.saving')" :loading-label="t('forms.saving')"
:disabled=" :disabled="
linkedCount > 0 && !form.confirm_linked_projects_update !source ||
(linkedCount > 0 && !form.confirm_linked_projects_update)
" "
>{{ >{{
t(template ? 'templates.saveChanges' : 'templates.saveNew') t(template ? 'templates.saveChanges' : 'templates.saveNew')
@@ -1,662 +0,0 @@
<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,7 +19,6 @@ 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<{
@@ -28,7 +27,10 @@ const props = defineProps<{
templateState?: ProjectTemplateState | null; templateState?: ProjectTemplateState | null;
}>(); }>();
const { t } = useLocalization(); const { t } = useLocalization();
const form = useForm<TemplateResetForm>({ const form = useForm<{
areas: string[];
status_replacements: Record<string, number | undefined>;
}>({
areas: [], areas: [],
status_replacements: {}, status_replacements: {},
}); });
@@ -243,15 +245,6 @@ 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>
@@ -12,12 +12,8 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import type { import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
TicketDetailRelationshipsProps,
TicketLinkFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>(); const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>(); defineEmits<{ editParent: [] }>();
@@ -37,17 +33,17 @@ const canAddLink = computed(
props.ticketLinkTypes.length > 0 && props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0, props.ticketLinkOptions.length > 0,
); );
const form = useForm<TicketLinkFormData>({ const form = useForm({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '', ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
link_direction: 'source',
linked_ticket_id: '', linked_ticket_id: '',
}); });
const protection = useFormProtection(form, { const protection = useFormProtection(form, {
isDirty: () => props.canEditTicket && form.isDirty, isDirty: () => props.canEditTicket && form.isDirty,
}); });
const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions( const selectedType = computed(() =>
() => props.ticketLinkTypes, props.ticketLinkTypes.find(
form, (type) => String(type.id) === String(form.ticket_link_type_id),
),
); );
const selectedTicket = computed(() => const selectedTicket = computed(() =>
props.ticketLinkOptions.find( props.ticketLinkOptions.find(
@@ -228,14 +224,16 @@ function submit(): void {
> >
<TicketFormField <TicketFormField
id="ticket_link_type_id" id="ticket_link_type_id"
v-model="selectedAction" v-model="form.ticket_link_type_id"
kind="select" kind="select"
:label="t('ticketDetail.linkAction')" :label="t('ticketDetail.linkType')"
:options="actions" :options="
:error=" ticketLinkTypes.map((type) => ({
form.errors.ticket_link_type_id || value: type.id,
form.errors.link_direction label: type.label,
}))
" "
:error="form.errors.ticket_link_type_id"
:disabled="form.processing" :disabled="form.processing"
required required
/> />
@@ -262,19 +260,22 @@ function submit(): void {
/> />
</fieldset> </fieldset>
<p <p
v-if="selectedActionLabel && selectedTicket" v-if="selectedType && selectedTicket"
aria-live="polite" aria-live="polite"
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm" class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
> >
<span class="font-mono text-xs">{{ <span class="font-mono text-xs">{{
ticket.ticket_key ticket.ticket_key
}}</span }}</span
><span>{{ selectedActionLabel }}</span ><span>{{ selectedType.label }}</span
><ArrowRight class="size-4" aria-hidden="true" /><span ><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs" class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span >{{ selectedTicket.ticket_key }}</span
> >
</p> </p>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.directionHelp') }}
</p>
<Button <Button
type="submit" type="submit"
:disabled="form.processing || !selectedTicket" :disabled="form.processing || !selectedTicket"
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3'; import { Link, useForm } from '@inertiajs/vue3';
import { Unlink } from 'lucide-vue-next'; import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController'; import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
@@ -18,6 +20,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ removed: [] }>(); const emit = defineEmits<{ removed: [] }>();
const { t } = useLocalization(); const { t } = useLocalization();
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
const form = useForm({}); const form = useForm({});
const protection = useFormProtection(form); const protection = useFormProtection(form);
function remove(): void { function remove(): void {
@@ -44,6 +47,27 @@ function remove(): void {
:data-ticket-link="link.id" :data-ticket-link="link.id"
> >
<div class="flex min-w-0 flex-wrap items-center gap-2"> <div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge
variant="outline"
class="text-muted-foreground max-w-full whitespace-normal"
>
<ArrowUpRight
v-if="outgoing"
class="size-3.5 shrink-0"
aria-hidden="true"
/><ArrowDownLeft
v-else
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{{
t(
outgoing
? 'ticketRelationships.outgoing'
: 'ticketRelationships.incoming',
)
}}
</Badge>
<span class="text-muted-foreground font-mono text-xs">{{ <span class="text-muted-foreground font-mono text-xs">{{
ticketKey ticketKey
}}</span> }}</span>
@@ -1,57 +0,0 @@
import { computed, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type {
TicketLinkActionOption,
TicketLinkFormData,
} from '@/types/ticket-detail';
export function useTicketLinkActions(
ticketLinkTypes: MaybeRefOrGetter<TicketLinkTypeModel[]>,
form: TicketLinkFormData,
) {
const actions = computed<TicketLinkActionOption[]>(() =>
toValue(ticketLinkTypes).flatMap((type) => {
const choices: TicketLinkActionOption[] = [
{
value: `${type.id}:source`,
label: type.label,
ticketLinkTypeId: type.id,
direction: 'source',
},
];
if (
type.counterpart_label &&
type.counterpart_label !== type.label
) {
choices.push({
value: `${type.id}:counterpart`,
label: type.counterpart_label,
ticketLinkTypeId: type.id,
direction: 'counterpart',
});
}
return choices;
}),
);
const selectedAction = computed({
get: () => `${form.ticket_link_type_id}:${form.link_direction}`,
set: (value: string | number | null) => {
const action = actions.value.find(
(option) => option.value === value,
);
if (action) {
form.ticket_link_type_id = action.ticketLinkTypeId;
form.link_direction = action.direction;
}
},
});
const selectedActionLabel = computed(
() =>
actions.value.find(
(action) => action.value === selectedAction.value,
)?.label,
);
return { actions, selectedAction, selectedActionLabel };
}
@@ -1,21 +0,0 @@
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;
}
@@ -1,17 +0,0 @@
{
"format": "Markdown stored verbatim and supported by the Tiptap WYSIWYG editor and ticket detail view.",
"supported": "Supported: plain text, paragraphs, line breaks, headings, bold, italic, strikethrough, ordinary ordered/unordered lists (including nested lists), blockquotes, inline/fenced/indented code, horizontal rules, and safe links.",
"unsupported": "Not visually supported: task checkboxes, tables, HTML, images, or unsafe links. Newly supplied incompatible descriptions reject the entire mutation with a description field error; existing descriptions are preserved when description is omitted.",
"criteria": "Put acceptance criteria exclusively in the structured acceptance_criteria field. Do not repeat them in description as task checkboxes or a copied criteria list. No criteria are automatically extracted or moved.",
"links": "Safe link destinations: http://, https://, mailto:, a root-relative path starting with a single /, or a #fragment. Protocol-relative URLs, other schemes, whitespace, control characters, and backslashes (including percent-encoded forms) are not allowed.",
"example": "## Goal\n\nExplain the **context** and approach.\n\n- Implement the change\n- Verify [the guide](https://example.com/guide)\n\n> Keep the scope clear.\n\nUse `code` when needed.",
"unsupported_tokens": {
"html": "HTML",
"table": "tables",
"image": "images",
"task": "task checkboxes",
"unsafe_link": "unsafe links"
},
"link_prefix": "^(?:https?://|mailto:|/(?!/)|#)",
"unsafe_link_characters": "[\\s\\\\\\x00-\\x1f\\x7f]"
}
+17 -33
View File
@@ -2,40 +2,30 @@ import type { JSONContent } from '@tiptap/core';
import { Markdown, MarkdownManager } from '@tiptap/markdown'; import { Markdown, MarkdownManager } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { h } from 'vue'; import { h } from 'vue';
import descriptionFormat from './ticket-description-format.json';
import type { VNodeChild } from 'vue'; import type { VNodeChild } from 'vue';
export function safeDescriptionLink(value: unknown): string | null { export function safeDescriptionLink(value: unknown): string | null {
if ( if (
typeof value !== 'string' || typeof value !== 'string' ||
new RegExp(descriptionFormat.unsafe_link_characters, 'u').test(value) /[\s\\]/u.test(value) ||
[...value].some(
(character) =>
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
)
) { ) {
return null; return null;
} }
try {
if (
new RegExp(descriptionFormat.unsafe_link_characters, 'u').test(
decodeURIComponent(value),
)
) {
return null;
}
} catch {
return null;
}
if (/^(https?:\/\/|mailto:)/i.test(value)) { if (/^(https?:\/\/|mailto:)/i.test(value)) {
try { try {
new URL(value); const url = new URL(value);
return new RegExp(descriptionFormat.link_prefix, 'i').test(value) return ['http:', 'https:', 'mailto:'].includes(url.protocol)
? value ? value
: null; : null;
} catch { } catch {
return null; return null;
} }
} }
return new RegExp(descriptionFormat.link_prefix, 'i').test(value) return /^(\/(?!\/)|#)/.test(value) ? value : null;
? value
: null;
} }
export function descriptionExtensions() { export function descriptionExtensions() {
@@ -65,21 +55,15 @@ const markdown = new MarkdownManager({
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */ /** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
export function supportsVisualDescription(value: string): boolean { export function supportsVisualDescription(value: string): boolean {
let supported = true; let supported = true;
markdown.instance.walkTokens( markdown.instance.walkTokens(markdown.instance.lexer(value), (token) => {
markdown.instance.lexer(value, { gfm: true, breaks: true }), if (
(token) => { ['html', 'table', 'image'].includes(token.type) ||
if ( (token.type === 'list_item' && token.task) ||
Object.hasOwn( (token.type === 'link' && !safeDescriptionLink(token.href))
descriptionFormat.unsupported_tokens, ) {
token.type, supported = false;
) || }
(token.type === 'list_item' && token.task) || });
(token.type === 'link' && !safeDescriptionLink(token.href))
) {
supported = false;
}
},
);
return supported; return supported;
} }
+11 -43
View File
@@ -248,38 +248,6 @@ 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.',
@@ -296,7 +264,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': 'Independent template', 'templates.sourceUnavailable': 'Source project unavailable',
'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',
@@ -304,7 +272,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':
'Edit the template configuration and review the impact on linked projects.', 'Refresh the template from its source project 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',
@@ -317,15 +285,13 @@ 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. Statuses, columns and transitions inherit independently.', 'Only selected groups are saved. Related groups required by the workflow are included automatically.',
'templates.editGroupsHelp': 'templates.editGroupsHelp':
'Edit the selected groups directly. Linked projects keep their existing local overrides.', 'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.',
'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 are never copied. Saving a project as a template links that project to the new template.', 'Tickets and other operational ticket data are never copied. The source project stays unchanged.',
'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',
@@ -1247,7 +1213,7 @@ export const en = {
'ticketDetail.linkedTickets': 'Linked tickets', 'ticketDetail.linkedTickets': 'Linked tickets',
'ticketDetail.linkedTicketsDescription': 'ticketDetail.linkedTicketsDescription':
'Track cross-ticket relationships with project-defined link labels.', 'Track cross-ticket relationships with project-defined link labels.',
'ticketDetail.linkAction': 'Action', 'ticketDetail.linkType': 'Link type',
'ticketDetail.linkedTicket': 'Linked ticket', 'ticketDetail.linkedTicket': 'Linked ticket',
'ticketDetail.saveLink': 'Save link', 'ticketDetail.saveLink': 'Save link',
'ticketDetail.noLinks': 'ticketDetail.noLinks':
@@ -1261,6 +1227,10 @@ export const en = {
'Open a child ticket to change its parent. Expand a group to see its descendants.', 'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link', 'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket', 'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.directionHelp':
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}', 'ticketRelationships.removeLink': 'Remove link to {key}',
'ticketRelationships.noTypes': 'ticketRelationships.noTypes':
'No link types are configured for this project.', 'No link types are configured for this project.',
@@ -1365,10 +1335,8 @@ 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 a replacement for each used local status. Its tickets and local status transitions move to that replacement.', 'Choose where tickets in local statuses should move before those statuses are removed.',
'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,58 +6,31 @@ 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[]>(() => [
props.project { title: t('navigation.projects'), href: index() },
? [ { title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.projects'), href: index() }, { title: t('projects.saveTemplate'), href: create(props.project.id) },
{ 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 <Head :title="t('templates.createTitle', { project: project.name })" />
: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 ?? 'empty'" :key="project.id"
: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, create } from '@/routes/project-templates'; import { destroy, edit, index } 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="create()" ><Link :href="createProject()"
><Plus aria-hidden="true" />{{ ><Plus aria-hidden="true" />{{
t('templates.createEmpty') t('projects.create')
}}</Link }}</Link
></Button ></Button
> >
+1 -4
View File
@@ -88,10 +88,7 @@ const settingsItems = computed(() =>
</p> </p>
</div> </div>
<Button <Button
v-if=" v-if="$page.props.auth.permissions.projectCreate"
$page.props.auth.permissions.projectCreate &&
canManageAccess
"
as-child as-child
variant="outline" variant="outline"
class="self-start" class="self-start"
+2 -5
View File
@@ -67,8 +67,7 @@ export type ProjectTemplatesIndexPageProps = {
export type ProjectTemplatesCreatePageProps = { export type ProjectTemplatesCreatePageProps = {
copyAreaOptions: ProjectDuplicateCopyAreaOption[]; copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project: ProjectModel | null; project: ProjectModel;
initialConfiguration?: Record<string, unknown>;
}; };
export type ProjectTemplatesEditPageProps = { export type ProjectTemplatesEditPageProps = {
@@ -83,8 +82,6 @@ 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;
@@ -98,7 +95,7 @@ export type ProjectTemplateState = {
name: string; name: string;
ticketCount: number; ticketCount: number;
}>; }>;
replacementOptions: Array<{ id: number | string; name: string }>; replacementOptions: Array<{ id: number; name: string }>;
} }
>; >;
templateConfiguration?: Record<string, unknown>; templateConfiguration?: Record<string, unknown>;
@@ -1,77 +0,0 @@
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>;
};
-11
View File
@@ -67,14 +67,3 @@ export type TicketBranchFormData = {
branch_name: string; branch_name: string;
repository_connection_id: string; repository_connection_id: string;
}; };
export type TicketLinkFormData = {
ticket_link_type_id: number | string;
link_direction: 'source' | 'counterpart';
linked_ticket_id: number | string;
};
export type TicketLinkActionOption = {
value: string;
label: string;
ticketLinkTypeId: number;
direction: TicketLinkFormData['link_direction'];
};
-2
View File
@@ -7,8 +7,6 @@ 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