Compare commits

...
Author SHA1 Message Date
Twyco 1291a0fcad fix(KAN-14): preserve template inheritance and project data
CI/CD / preconditions (pull_request) Successful in 4s
CI/CD / test_and_check_app (pull_request) Failing after 2m14s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-15 11:38:17 +02:00
Twyco 5769d84526 Merge pull request 'Fix/ios unsaved form navigation' (#180) from fix/ios-unsaved-form-navigation into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m47s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m24s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 26s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m9s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 22s
Reviewed-on: #180
2026-10-15 07:55:42 +00:00
Twyco 99264c7bc8 fix: pnpm packages
CI/CD / preconditions (pull_request) Successful in 4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 2m55s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 2m16s
2026-10-15 09:50:04 +02:00
Twyco 1b448e363c fix: restore iOS navigation with unsaved forms 2026-10-15 09:46:19 +02:00
Twyco 65e9df6d6d fix: pnpm packages
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m34s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m49s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 2m15s
CI/CD / Publish immutable image (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Canceled after 31s
2026-10-15 09:22:09 +02:00
74 changed files with 3006 additions and 475 deletions
@@ -2,6 +2,7 @@
namespace App\Actions\ProjectBoard; namespace App\Actions\ProjectBoard;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions
'position' => $index + 1, 'position' => $index + 1,
])->saveQuietly(); ])->saveQuietly();
}); });
app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses');
} }
private function statusContextLabel(TicketStatus $ticketStatus): string private function statusContextLabel(TicketStatus $ticketStatus): string
@@ -2,6 +2,7 @@
namespace App\Actions\ProjectBoard; namespace App\Actions\ProjectBoard;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -50,5 +51,9 @@ trait ReordersBoardColumns
'position' => $index + 1, 'position' => $index + 1,
])->saveQuietly(); ])->saveQuietly();
}); });
$project = $columns->first()?->project;
if ($project !== null) {
app(RefreshProjectBaseTemplate::class)->handle($project, 'board_columns');
}
} }
} }
@@ -5,8 +5,10 @@ namespace App\Actions\ProjectBoard;
use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager; use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateTicketStatusTransitions class UpdateTicketStatusTransitions
{ {
@@ -20,22 +22,25 @@ class UpdateTicketStatusTransitions
*/ */
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
{ {
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', [])) return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus {
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId) Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail();
->unique() $allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->values() ->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
->all(); ->unique()
->values()
->all();
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds); $ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
$this->projectTemplateOverrideManager->mark( $this->projectTemplateOverrideManager->mark(
$ticketStatus->project, $ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
); );
$this->refreshProjectBaseTemplate->handle( $this->refreshProjectBaseTemplate->handle(
$ticketStatus->project, $ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
); );
return $ticketStatus->fresh(); return $ticketStatus->fresh();
}, 3);
} }
} }
@@ -7,6 +7,7 @@ use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateProjectTicketMetadataSettings class UpdateProjectTicketMetadataSettings
{ {
@@ -20,19 +21,22 @@ class UpdateProjectTicketMetadataSettings
*/ */
public function handle(Project $project, array $validated): Project public function handle(Project $project, array $validated): Project
{ {
$project->update([ return DB::transaction(function () use ($project, $validated): Project {
'show_author' => Arr::get($validated, 'show_author', false), Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
'show_story_points' => Arr::get($validated, 'show_story_points', false), $project->update([
]); 'show_author' => Arr::get($validated, 'show_author', false),
$this->projectTemplateOverrideManager->mark( 'show_story_points' => Arr::get($validated, 'show_story_points', false),
$project, ]);
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, $this->projectTemplateOverrideManager->mark(
); $project,
$this->refreshProjectBaseTemplate->handle( DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
$project, );
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, $this->refreshProjectBaseTemplate->handle(
); $project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
return $project->fresh(); return $project->fresh();
}, 3);
} }
} }
+2
View File
@@ -36,6 +36,7 @@ class CreateProject
if ($creationMode === 'template' && $sourceTemplateId !== null) { if ($creationMode === 'template' && $sourceTemplateId !== null) {
$sourceTemplate = ProjectTemplate::query() $sourceTemplate = ProjectTemplate::query()
->published() ->published()
->lockForUpdate()
->findOrFail((int) $sourceTemplateId); ->findOrFail((int) $sourceTemplateId);
$project->forceFill([ $project->forceFill([
@@ -72,6 +73,7 @@ class CreateProject
$this->duplicateProjectConfiguration->apply( $this->duplicateProjectConfiguration->apply(
$project->fresh() ?? $project, $project->fresh() ?? $project,
$sourceTemplate->configuration_snapshot ?? [], $sourceTemplate->configuration_snapshot ?? [],
adoptExisting: true,
); );
} }
@@ -15,11 +15,18 @@ class CreateProjectBaseTemplate
{ {
$copyAreas = DuplicateProjectConfiguration::copyableAreas(); $copyAreas = DuplicateProjectConfiguration::copyableAreas();
$slug = strtolower($project->key).'-base-template';
$suffix = 1;
$candidate = $slug;
while (ProjectTemplate::query()->where('slug', $candidate)->exists()) {
$candidate = $slug.'-'.++$suffix;
}
return ProjectTemplate::query()->create([ return ProjectTemplate::query()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'source_project_id' => $project->id, 'source_project_id' => $project->id,
'name' => "{$project->name} base template", 'name' => "{$project->name} base template",
'slug' => strtolower($project->key).'-base-template', 'slug' => $candidate,
'is_published' => false, 'is_published' => false,
'copy_areas' => $copyAreas, 'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas), 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
+43 -10
View File
@@ -5,28 +5,61 @@ namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate; use App\Models\ProjectTemplate;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class CreateProjectTemplate class CreateProjectTemplate
{ {
public function __construct( public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
) {} ) {}
/** @param array<string, mixed> $attributes */
public function empty(array $attributes): ProjectTemplate
{
$areas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
return ProjectTemplate::query()->create([
'name' => Arr::get($attributes, 'name'),
'slug' => Arr::get($attributes, 'slug'),
'is_published' => true,
'copy_areas' => $areas,
'configuration_snapshot' => collect($this->duplicateProjectConfiguration->normalizeConfiguration(
Arr::get($attributes, 'configuration_snapshot', $this->projectConfigurationIdentity->defaults()),
))->only($areas)->all(),
]);
}
/** /**
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes * @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
*/ */
public function handle(Project $sourceProject, array $attributes): ProjectTemplate public function handle(Project $sourceProject, array $attributes): ProjectTemplate
{ {
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); return DB::transaction(function () use ($sourceProject, $attributes): ProjectTemplate {
$sourceProject = Project::query()->whereKey($sourceProject->id)->lockForUpdate()->firstOrFail();
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
$template = $sourceProject->projectTemplate;
if ($template === null || $template->is_published || $template->project_id !== $sourceProject->id) {
$template = new ProjectTemplate;
$template->slug = Arr::get($attributes, 'slug');
}
$template->forceFill([
'project_id' => null,
'source_project_id' => $sourceProject->id,
'name' => Arr::get($attributes, 'name'),
'is_published' => true,
'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
])->save();
$sourceProject->forceFill([
'project_template_id' => $template->id,
'template_overrides' => array_values(array_diff(DuplicateProjectConfiguration::copyableAreas(), $copyAreas)),
])->saveQuietly();
foreach ($sourceProject->configurationRelations() as $relation) {
$sourceProject->{$relation}()->update(['project_template_id' => $template->id]);
}
return ProjectTemplate::query()->create([ return $template;
'project_id' => null, }, 3);
'source_project_id' => $sourceProject->id,
'name' => $attributes['name'],
'slug' => $attributes['slug'],
'is_published' => true,
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
]);
} }
} }
@@ -0,0 +1,31 @@
<?php
namespace App\Actions\Projects;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class DeleteProjectTemplate
{
public function __construct(
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function handle(ProjectTemplate $template): void
{
DB::transaction(function () use ($template): void {
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$projects = $template->linkedProjects()->orderBy('id')->lockForUpdate()->get();
$template->delete();
foreach ($projects as $project) {
$base = $project->baseTemplate()->first() ?? $this->createProjectBaseTemplate->handle($project);
$base->forceFill(['configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, DuplicateProjectConfiguration::copyableAreas())])->saveQuietly();
$project->forceFill(['project_template_id' => $base->id, 'template_overrides' => []])->saveQuietly();
foreach ($project->configurationRelations() as $relation) {
$project->{$relation}()->update(['project_template_id' => $base->id]);
}
}
}, 3);
}
}
@@ -2,9 +2,13 @@
namespace App\Actions\Projects; namespace App\Actions\Projects;
use App\Actions\Notifications\NormalizeNotificationFilters;
use App\Models\Project; use App\Models\Project;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class DuplicateProjectConfiguration class DuplicateProjectConfiguration
{ {
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
public function __construct( public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
) {} ) {}
/** @return list<string> */ /** @return list<string> */
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
unset($configuration[self::COPY_BOARD_WORKFLOW]); unset($configuration[self::COPY_BOARD_WORKFLOW]);
} }
return $configuration; return $this->projectConfigurationIdentity->normalize($configuration);
} }
/** @param list<string> $copyAreas @return array<string, mixed> */ /** @param list<string> $copyAreas @return array<string, mixed> */
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
]; ];
} }
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys( $columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column, int $index): array => [$column->id => $index], static fn ($column): array => [$column->id => $column->configuration_key],
); );
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys( $statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status, int $index): array => [$status->id => $index], static fn ($status): array => [$status->id => $status->configuration_key],
); );
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) { if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map( $configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
static fn ($column): array => ['name' => $column->name, 'position' => $column->position], static fn ($column): array => ['configuration_key' => $column->configuration_key, 'name' => $column->name, 'position' => $column->position],
)->all(); )->all();
} }
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) { if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map( $configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => [ static fn ($status): array => [
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id), 'configuration_key' => $status->configuration_key,
'board_column_key' => $status->board_column_id === null ? null : $columnKeys->get($status->board_column_id),
'name' => $status->name, 'name' => $status->name,
'position' => $status->position, 'position' => $status->position,
], ],
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) { if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map( $configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map( static fn ($status): array => [
static fn (int $id): ?int => $statusIndexes->get($id), 'status_key' => $status->configuration_key,
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(), 'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?string => $statusKeys->get($id),
)->filter()->sort()->values()->all(),
],
)->all(); )->all();
} }
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value, 'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [], 'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url, 'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
'filters' => $item->filters ?? [], 'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
], ],
]; ];
$relations = [ $relations = [
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
foreach ($simpleAreas as $area => $mapper) { foreach ($simpleAreas as $area => $mapper) {
if (in_array($area, $selectedAreas, true)) { if (in_array($area, $selectedAreas, true)) {
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all(); $configuration[$area] = $sourceProject->{$relations[$area]}->values()->map(
static fn ($item): array => ['configuration_key' => $item->configuration_key, ...$mapper($item)],
)->all();
} }
} }
@@ -200,95 +211,124 @@ class DuplicateProjectConfiguration
/** /**
* @param array<string, mixed> $configuration * @param array<string, mixed> $configuration
* @param array<int|string, int|string> $statusReplacements * @param array<int|string, int|string> $statusReplacements
* @return list<string> areas which retained referenced local values * @return list<string> Areas which retain referenced project configuration.
*/ */
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array public function apply(Project $targetProject, array $configuration, array $statusReplacements = [], bool $adoptExisting = false): array
{
return DB::transaction(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
$targetProject = Project::query()->whereKey($targetProject->id)->lockForUpdate()->firstOrFail();
return $this->applyLocked($targetProject, $configuration, $statusReplacements, $adoptExisting);
}, 3);
}
/** @param array<string, mixed> $configuration @param array<int|string, int|string> $statusReplacements @return list<string> */
private function applyLocked(Project $targetProject, array $configuration, array $statusReplacements, bool $adoptExisting): array
{ {
$configuration = $this->normalizeConfiguration($configuration); $configuration = $this->normalizeConfiguration($configuration);
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
$blockedAreas = []; $blockedAreas = [];
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) { if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly(); $targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
} }
if (isset($configuration[self::COPY_BOARD_COLUMNS])) { if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
$blocked = $this->syncModels( $currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
$targetProject->boardColumns()->get(), $nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
$configuration[self::COPY_BOARD_COLUMNS], $columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
fn (array $item) => $targetProject->boardColumns()->create($item), $statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(), static fn ($status): bool => $nextStatuses->has($status->configuration_key)
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(), || $status->tickets_count === 0 || array_key_exists($status->id, $statusReplacements),
); );
if ($blocked) { $canDeleteColumn = static fn (Model $column): bool => $currentStatuses->where('board_column_id', $column->id)->every(
static function ($status) use ($column, $statusesCanChange, $nextStatuses, $columnKeys): bool {
if (! $statusesCanChange) {
return false;
}
$next = $nextStatuses->get($status->configuration_key);
$nextColumn = $next['board_column_key'] ?? null;
return $next === null || ($nextColumn !== $column->configuration_key
&& ($nextColumn === null || in_array($nextColumn, $columnKeys, true)));
},
);
if ($this->syncModels($targetProject, self::COPY_BOARD_COLUMNS, $configuration[self::COPY_BOARD_COLUMNS],
$canDeleteColumn, $adoptExisting)) {
$blockedAreas[] = self::COPY_BOARD_COLUMNS; $blockedAreas[] = self::COPY_BOARD_COLUMNS;
} }
} }
if (isset($configuration[self::COPY_TICKET_STATUSES])) { if (isset($configuration[self::COPY_TICKET_STATUSES])) {
$columns = $targetProject->boardColumns()->get()->values(); $columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array { $existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
$columnIndex = $item['board_column_index'] ?? null; $items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id; $columnKey = $item['board_column_key'] ?? null;
unset($item['board_column_index']); $item['board_column_id'] = $columnKey === null
? null
: ($columns->get($columnKey)?->id ?? $existingStatuses->get($item['configuration_key'])?->board_column_id);
$item['slug'] = Str::slug($item['name']);
unset($item['board_column_key']);
return $item; return $item;
})->all(); })->all();
$blocked = $this->syncModels( $templateKeys = array_column($items, 'configuration_key');
$targetProject->ticketStatuses()->get(), $canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
$items, || array_key_exists($status->id, $statusReplacements);
fn (array $item) => $targetProject->ticketStatuses()->create($item), $beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(), $replacement = $statusReplacements[$status->id] ?? null;
function (Model $status) use ($statusReplacements): bool { if ($replacement === null) {
if (! $status->tickets()->exists()) { return;
return true; }
} $replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
$replacementId = $statusReplacements[$status->id] ?? null; ->where('configuration_key', (string) $replacement)->first();
if ($replacementId === null) { if ($replacementStatus === null && is_numeric($replacement)) {
return false; $replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
} }
$status->tickets()->update(['ticket_status_id' => $replacementId]); if ($replacementStatus === null) {
throw ValidationException::withMessages([
return true; 'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
}, ]);
); }
if ($blocked) { $status->tickets()->update(['ticket_status_id' => $replacementStatus->id]);
$this->transferStatusTransitions($targetProject, $status, $replacementStatus, $statusReplacements, $templateKeys);
};
if ($this->syncModels($targetProject, self::COPY_TICKET_STATUSES, $items, $canDelete, $adoptExisting, $beforeDelete)) {
$blockedAreas[] = self::COPY_TICKET_STATUSES; $blockedAreas[] = self::COPY_TICKET_STATUSES;
} }
} }
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) { if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
$statuses = $targetProject->ticketStatuses()->get()->values(); $statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) { foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
$status = $statuses->get($index); $status = $statuses->get($transition['status_key']);
if ($status !== null) { if ($status !== null) {
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all()); $status->allowedTargetStatuses()->sync(collect($transition['target_keys'])->map(
static fn (string $key): ?int => $statuses->get($key)?->id,
)->filter()->all());
} }
} }
} }
$relations = [ $guards = [
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()], self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()], self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()], self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()], self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null], self::COPY_REPOSITORY_CONNECTIONS => null,
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null], self::COPY_NOTIFICATION_ACTIONS => null,
]; ];
foreach ($guards as $area => $canDelete) {
foreach ($relations as $area => [$relation, $canDelete]) {
if (! isset($configuration[$area])) { if (! isset($configuration[$area])) {
continue; continue;
} }
$blocked = $this->syncModels( $items = $configuration[$area];
$targetProject->{$relation}()->get(), if ($area === self::COPY_NOTIFICATION_ACTIONS) {
$configuration[$area], $statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
fn (array $item) => $targetProject->{$relation}()->create($item), $items = array_map(fn (array $item): array => [
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(), ...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
$canDelete, ], $items);
); }
if ($blocked) { if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
$blockedAreas[] = $area; $blockedAreas[] = $area;
} }
} }
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]); $targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
} }
return array_values(array_unique($blockedAreas)); return $blockedAreas;
}); });
foreach ($blockedAreas as $area) { foreach ($blockedAreas as $area) {
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area); $this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
} }
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
/** @param list<string> $copyAreas */ /** @param list<string> $copyAreas */
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
{ {
$this->apply($targetProject, $this->export($sourceProject, $copyAreas)); $this->apply($targetProject, $this->export($sourceProject, $copyAreas), adoptExisting: true);
}
/** @param list<array<string, mixed>> $filters @param array<int|string, int|string> $mapping @return list<array<string, mixed>> */
public function mapNotificationFilters(array $filters, array $mapping): array
{
return array_map(static function (array $filter) use ($mapping): array {
if (! in_array($filter['attribute'], ['status.from.id', 'status.to.id'], true)) {
return $filter;
}
$map = static fn (string $value): string => (string) ($mapping[$value] ?? $value);
$filter['value'] = is_array($filter['value'])
? array_map($map, $filter['value'])
: ($filter['value'] === null ? null : $map($filter['value']));
return $filter;
}, app(NormalizeNotificationFilters::class)->handle($filters));
}
/** @param array<int|string, int|string> $replacements @param list<string> $templateKeys */
private function transferStatusTransitions(Project $project, TicketStatus $removed, TicketStatus $replacement, array $replacements, array $templateKeys): void
{
$statuses = $project->ticketStatuses()->get();
$targets = $statuses->whereIn('configuration_key', $templateKeys);
$mapping = [];
foreach ($statuses as $status) {
if (in_array($status->configuration_key, $templateKeys, true)) {
$mapping[$status->id] = $status->id;
continue;
}
$target = $replacements[$status->id] ?? null;
if (is_scalar($target)) {
$mapping[$status->id] = $targets->firstWhere('configuration_key', (string) $target)?->id
?? (is_numeric($target) ? $targets->firstWhere('id', (int) $target)?->id : null);
}
}
$mapping[$removed->id] = $replacement->id;
$edges = DB::table('ticket_status_transitions')->where('from_ticket_status_id', $removed->id)
->orWhere('to_ticket_status_id', $removed->id)->get();
foreach ($edges as $edge) {
$from = $mapping[$edge->from_ticket_status_id] ?? null;
$to = $mapping[$edge->to_ticket_status_id] ?? null;
if ($from !== null && $to !== null && $from !== $to) {
$statuses->firstWhere('id', $from)->allowedTargetStatuses()->syncWithoutDetaching([$to]);
}
}
} }
/** /**
* @param EloquentCollection<int, Model> $models * @param list<array<string, mixed>> $items
* @param array<int, array<string, mixed>> $items
*/ */
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool private function syncModels(Project $project, string $area, array $items, ?callable $canDelete, bool $adoptExisting, ?callable $beforeDelete = null): bool
{ {
foreach ($items as $index => $item) { $relation = ProjectConfigurationIdentity::relations()[$area];
$model = $models->get($index); $models = $project->{$relation}()->get();
$model === null ? $create($item) : $update($model, $item); $byKey = $models->keyBy('configuration_key');
$matched = [];
$assignments = [];
foreach ($items as $item) {
$model = $byKey->get($item['configuration_key']);
if ($model === null && $adoptExisting) {
$candidates = $models->filter(fn (Model $candidate): bool => ! in_array($candidate->id, $matched, true)
&& $this->projectConfigurationIdentity->signature($area, $candidate->attributesToArray()) === $this->projectConfigurationIdentity->signature($area, $item));
$model = $candidates->count() === 1 ? $candidates->first() : null;
}
if ($model !== null) {
$matched[] = $model->id;
if ($area === self::COPY_TICKET_FIELDS && $model->field_type !== $item['field_type'] && $model->ticketFieldValues()->exists()) {
return true;
}
if ($area === self::COPY_TICKET_TYPES && $model->is_parent && ! ($item['is_parent'] ?? false)
&& $model->tickets()->whereHas('childTickets')->exists()) {
return true;
}
}
$assignments[] = [$model, $item];
}
$removed = $models->reject(static fn (Model $model): bool => in_array($model->id, $matched, true));
foreach ($removed as $model) {
if ($canDelete !== null && ! $canDelete($model)) {
return true;
}
} }
$blocked = false; if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
foreach ($models->slice(count($items)) as $model) { foreach ($models as $model) {
if ($canDelete !== null && ! $canDelete($model)) { $model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
$blocked = true; }
}
continue; if ($models->isNotEmpty() && $models->first()->getAttribute('position') !== null) {
$offset = max((int) $models->max('position'), (int) collect($items)->max('position')) + $models->count() + count($items) + 1;
foreach ($models->values() as $index => $model) {
$model->forceFill(['position' => $offset + $index])->saveQuietly();
}
}
foreach ($assignments as [$model, $item]) {
$model === null ? $project->{$relation}()->create($item) : $model->forceFill($item)->saveQuietly();
}
foreach ($removed as $model) {
if ($beforeDelete !== null) {
$beforeDelete($model);
} }
$model->deleteQuietly(); $model->deleteQuietly();
} }
return $blocked; return false;
} }
} }
@@ -0,0 +1,95 @@
<?php
namespace App\Actions\Projects;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
class ProjectConfigurationIdentity
{
/** @return array<string, string> */
public static function relations(): array
{
return [
'board_columns' => 'boardColumns', 'ticket_statuses' => 'ticketStatuses',
'ticket_types' => 'ticketTypes', 'ticket_priorities' => 'ticketPriorities',
'ticket_fields' => 'ticketFieldDefinitions', 'ticket_link_types' => 'ticketLinkTypes',
'repository_connections' => 'repositoryConnections', 'notification_actions' => 'notificationActions',
];
}
/** @param array<string, mixed> $configuration @return array<string, mixed> */
public function normalize(array $configuration): array
{
foreach (self::relations() as $area => $relation) {
if (! array_key_exists($area, $configuration)) {
continue;
}
foreach ($configuration[$area] as $index => &$item) {
$item['configuration_key'] ??= (string) Uuid::uuid5(
Uuid::NAMESPACE_URL, 'kanbangit:'.$area.':'.$index.':'.$this->signature($area, $item),
);
}
unset($item);
}
$columns = array_column($configuration['board_columns'] ?? [], 'configuration_key');
foreach ($configuration['ticket_statuses'] ?? [] as $index => $status) {
if (! array_key_exists('board_column_key', $status)) {
$columnIndex = $status['board_column_index'] ?? null;
$status['board_column_key'] = $columnIndex === null ? null : ($columns[$columnIndex] ?? null);
}
unset($status['board_column_index']);
$configuration['ticket_statuses'][$index] = $status;
}
$statuses = array_column($configuration['ticket_statuses'] ?? [], 'configuration_key');
foreach ($configuration['status_transitions'] ?? [] as $index => $transition) {
if (array_key_exists('status_key', $transition)) {
continue;
}
if (! isset($statuses[$index])) {
continue;
}
$configuration['status_transitions'][$index] = [
'status_key' => $statuses[$index],
'target_keys' => array_values(array_filter(array_map(
static fn (int $target): ?string => $statuses[$target] ?? null, $transition,
))),
];
}
return $configuration;
}
/** @param array<string, mixed> $item */
public function signature(string $area, array $item): string
{
return $area === 'repository_connections'
? ($item['provider'] ?? '').':'.($item['base_url'] ?? '').':'.($item['repository_full_name'] ?? '')
: (string) ($item['name'] ?? $item['label'] ?? '');
}
/** @return array<string, mixed> */
public function defaults(): array
{
$configuration = array_fill_keys(DuplicateProjectConfiguration::copyableAreas(), []);
$configuration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
$configuration['ticket_statuses'] = [[
'configuration_key' => (string) Str::uuid(), 'name' => 'Backlog',
'position' => 1, 'board_column_key' => null,
]];
$configuration['ticket_types'] = [
['configuration_key' => (string) Str::uuid(), 'name' => 'Task', 'position' => 1, 'is_parent' => false],
['configuration_key' => (string) Str::uuid(), 'name' => 'Release', 'position' => 2, 'is_parent' => true],
];
$configuration['ticket_priorities'] = [[
'configuration_key' => (string) Str::uuid(), 'name' => 'Medium', 'position' => 1,
]];
$configuration['status_transitions'] = [[
'status_key' => $configuration['ticket_statuses'][0]['configuration_key'], 'target_keys' => [],
]];
return $configuration;
}
}
@@ -3,6 +3,7 @@
namespace App\Actions\Projects; namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use Illuminate\Support\Facades\DB;
class ProjectTemplateOverrideManager class ProjectTemplateOverrideManager
{ {
@@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager
return; return;
} }
$project->loadMissing('projectTemplate'); DB::transaction(function () use ($project, $area): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) { $project->load('projectTemplate');
return; if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
} return;
}
$overrides = array_values(array_unique([ $project->forceFill(['template_overrides' => array_values(array_unique([
...($project->template_overrides ?? []), ...($project->template_overrides ?? []), $area,
$area, ]))])->saveQuietly();
])); }, 3);
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
} }
/** /**
@@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager
*/ */
public function clear(Project $project, array $areas): void public function clear(Project $project, array $areas): void
{ {
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas)); DB::transaction(function () use ($project, $areas): void {
$project->forceFill(['template_overrides' => $remaining])->saveQuietly(); $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$project->forceFill(['template_overrides' => array_values(array_diff($project->template_overrides ?? [], $areas))])->saveQuietly();
}, 3);
} }
public function withoutRecording(callable $callback): mixed public function withoutRecording(callable $callback): mixed
@@ -3,6 +3,8 @@
namespace App\Actions\Projects; namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class RefreshProjectBaseTemplate class RefreshProjectBaseTemplate
{ {
@@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate
public function handle(Project $project, string $area): void public function handle(Project $project, string $area): void
{ {
$project->loadMissing('projectTemplate'); DB::transaction(function () use ($project, $area): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
if ($project->projectTemplate === null || $project->projectTemplate->is_published) { $template = ProjectTemplate::query()->find($project->project_template_id);
return; if ($template === null || $template->is_published) {
} return;
}
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration( $template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$project->projectTemplate->configuration_snapshot ?? [], $snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
); $template->forceFill(['configuration_snapshot' => array_replace(
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
$project->projectTemplate->forceFill([ )])->saveQuietly();
'configuration_snapshot' => array_replace( }, 3);
$snapshot,
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
),
])->saveQuietly();
} }
} }
@@ -71,7 +71,9 @@ class ResolveProjectTemplateState
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value')); $inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
return [ return [
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)), 'effectiveConfiguration' => $currentConfiguration,
'localConfiguration' => array_diff_key($currentConfiguration, $templateConfiguration),
'localAreas' => collect(array_diff(array_keys($currentConfiguration), array_keys($templateConfiguration)))->map(fn (string $area): array => ['label' => $this->labelForArea($area), 'value' => $area])->values()->all(),
'inheritedAreas' => $inheritedAreas, 'inheritedAreas' => $inheritedAreas,
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues), 'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
'inheritsFromTemplate' => $projectTemplate->is_published, 'inheritsFromTemplate' => $projectTemplate->is_published,
@@ -96,24 +98,20 @@ class ResolveProjectTemplateState
return []; return [];
} }
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? []; $templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null;
if (! is_array($templateStatuses)) { if (! is_array($templateStatuses)) {
return []; return [];
} }
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values(); $statuses = $project->ticketStatuses()->withCount('tickets')->get();
$replacementOptions = $statuses->take(count($templateStatuses))->map( $templateKeys = array_column($templateStatuses, 'configuration_key');
static fn ($status): array => ['id' => $status->id, 'name' => $status->name], $replacementOptions = collect($templateStatuses)->map(
static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']],
)->all(); )->all();
$removedStatuses = $statuses->slice(count($templateStatuses)) $removedStatuses = $statuses
->filter(static fn ($status): bool => $status->tickets_count > 0) ->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0)
->map(static fn ($status): array => [ ->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count])
'id' => $status->id, ->values()->all();
'name' => $status->name,
'ticketCount' => $status->tickets_count,
])
->values()
->all();
return $removedStatuses === [] ? [] : [ return $removedStatuses === [] ? [] : [
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [ DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
+22 -25
View File
@@ -19,37 +19,34 @@ class UpdateProjectTemplate
*/ */
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
{ {
$projectTemplate->loadMissing('sourceProject'); return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate {
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
$sourceProject = $projectTemplate->sourceProject; $previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []);
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.'); $copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? []));
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); Arr::get($attributes, 'configuration_snapshot', $previousConfiguration),
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas); ))->only($copyAreas)->all();
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration( $affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
$projectTemplate->configuration_snapshot ?? [], ->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
); ->values()->all();
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
->values()
->all();
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
$projectTemplate->forceFill([ $projectTemplate->forceFill([
'name' => $attributes['name'], 'name' => Arr::get($attributes, 'name'),
'copy_areas' => $normalizedCopyAreas, 'copy_areas' => $copyAreas,
'configuration_snapshot' => $nextConfiguration, 'configuration_snapshot' => $nextConfiguration,
])->save(); ])->save();
Project::query() Project::query()
->where('project_template_id', $projectTemplate->id) ->where('project_template_id', $projectTemplate->id)
->orderBy('id') ->orderBy('id')
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void { ->chunkById(100, function ($linkedProjects) use ($projectTemplate, $affectedAreas, $nextConfiguration, $previousConfiguration): void {
foreach ($linkedProjects as $linkedProject) { foreach ($linkedProjects as $linkedProject) {
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail(); $linkedProject = Project::query()
->whereKey($linkedProject->id)
->where('project_template_id', $projectTemplate->id)
->lockForUpdate()->first();
if ($linkedProject === null) {
continue;
}
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas); $currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
$overrides = $linkedProject->template_overrides ?? []; $overrides = $linkedProject->template_overrides ?? [];
$replacementConfiguration = []; $replacementConfiguration = [];
@@ -81,8 +78,8 @@ class UpdateProjectTemplate
} }
} }
}); });
});
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects'); return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
}, 3);
} }
} }
@@ -5,6 +5,7 @@ namespace App\Actions\Projects;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate; use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class UpdateProjectTemplateLink class UpdateProjectTemplateLink
{ {
@@ -16,6 +17,7 @@ class UpdateProjectTemplateLink
public function switch(Project $project, ProjectTemplate $template): void public function switch(Project $project, ProjectTemplate $template): void
{ {
DB::transaction(function () use ($project, $template): void { DB::transaction(function () use ($project, $template): void {
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration( $configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [], $template->configuration_snapshot ?? [],
@@ -42,19 +44,27 @@ class UpdateProjectTemplateLink
* @param list<string> $areas * @param list<string> $areas
* @param array<int|string, int|string> $statusReplacements * @param array<int|string, int|string> $statusReplacements
*/ */
public function reset(Project $project, array $areas, array $statusReplacements = []): void public function reset(Project $project, array $areas, array $statusReplacements = []): array
{ {
$template = $project->projectTemplate; $template = $project->projectTemplate;
if ($template === null) { if ($template === null) {
return; return [];
} }
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void { return DB::transaction(function () use ($project, $template, $areas, $statusReplacements): array {
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration( if ($project->project_template_id !== $template->id) {
$template->configuration_snapshot ?? [], throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']);
))->only($areas)->all(); }
$baseline = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
$configuration = collect($baseline)->only($areas)->all();
if (in_array('ticket_statuses', $areas, true)
&& ! in_array('status_transitions', $project->template_overrides ?? [], true)
&& isset($baseline['status_transitions'])) {
$configuration['status_transitions'] = $baseline['status_transitions'];
}
$blockedAreas = $this->duplicateProjectConfiguration->apply( $blockedAreas = $this->duplicateProjectConfiguration->apply(
$project, $project,
$configuration, $configuration,
@@ -64,6 +74,8 @@ class UpdateProjectTemplateLink
$project->fresh(), $project->fresh(),
array_values(array_diff(array_keys($configuration), $blockedAreas)), array_values(array_diff(array_keys($configuration), $blockedAreas)),
); );
});
return $blockedAreas;
}, 3);
} }
} }
+5 -1
View File
@@ -161,7 +161,7 @@ class ProjectController extends Controller
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration))); $areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
$statusReplacements = Arr::get($validated, 'status_replacements', []); $statusReplacements = Arr::get($validated, 'status_replacements', []);
$this->updateProjectTemplateLink->reset( $blockedAreas = $this->updateProjectTemplateLink->reset(
$project->load('projectTemplate'), $project->load('projectTemplate'),
$areas, $areas,
$statusReplacements, $statusReplacements,
@@ -171,6 +171,10 @@ class ProjectController extends Controller
? "Selected settings were reset to the {$template->name} template." ? "Selected settings were reset to the {$template->name} template."
: "{$project->name} was reset to the {$template->name} template baseline."; : "{$project->name} was reset to the {$template->name} template baseline.";
if ($blockedAreas !== []) {
$status = 'Some settings remain overridden because tickets or other configuration still use them.';
}
return back()->with('status', $status); return back()->with('status', $status);
} }
@@ -3,9 +3,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\Projects\CreateProjectTemplate; use App\Actions\Projects\CreateProjectTemplate;
use App\Actions\Projects\DeleteProjectTemplate;
use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Actions\Projects\UpdateProjectTemplate; use App\Actions\Projects\UpdateProjectTemplate;
use App\Http\Requests\StoreProjectTemplateRequest; use App\Http\Requests\StoreProjectTemplateRequest;
use App\Http\Requests\StoreStandaloneProjectTemplateRequest;
use App\Http\Requests\UpdateProjectTemplateRequest; use App\Http\Requests\UpdateProjectTemplateRequest;
use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource; use App\Http\Resources\ProjectTemplateResource;
@@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller
{ {
public function __construct( public function __construct(
private readonly CreateProjectTemplate $createProjectTemplate, private readonly CreateProjectTemplate $createProjectTemplate,
private readonly DeleteProjectTemplate $deleteProjectTemplate,
private readonly UpdateProjectTemplate $updateProjectTemplate, private readonly UpdateProjectTemplate $updateProjectTemplate,
) {} ) {}
@@ -38,6 +42,22 @@ class ProjectTemplateController extends Controller
]); ]);
} }
public function createEmpty(ProjectConfigurationIdentity $identity): Response
{
return Inertia::render('project-templates/Create', [
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
'project' => null,
'initialConfiguration' => $identity->defaults(),
]);
}
public function storeEmpty(StoreStandaloneProjectTemplateRequest $request): RedirectResponse
{
$template = $this->createProjectTemplate->empty($request->validated());
return to_route('project-templates.edit', $template)->with('status', "Template {$template->name} was created.");
}
public function edit(ProjectTemplate $projectTemplate): Response public function edit(ProjectTemplate $projectTemplate): Response
{ {
abort_if(! $projectTemplate->is_published, 404); abort_if(! $projectTemplate->is_published, 404);
@@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller
abort_if(! $projectTemplate->is_published, 404); abort_if(! $projectTemplate->is_published, 404);
$templateName = $projectTemplate->name; $templateName = $projectTemplate->name;
$projectTemplate->delete(); $this->deleteProjectTemplate->handle($projectTemplate);
return to_route('project-templates.index') return to_route('project-templates.index')
->with('status', "Template {$templateName} was deleted."); ->with('status', "Template {$templateName} was deleted.");
@@ -0,0 +1,141 @@
<?php
namespace App\Http\Requests\Concerns;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType;
use App\Enums\NotificationFilterAttribute;
use App\Enums\NotificationFilterOperator;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
trait ValidatesProjectTemplateConfiguration
{
/** @return array<string, mixed> */
protected function configurationRules(): array
{
$rules = ['configuration_snapshot' => ['sometimes', 'array:'.implode(',', DuplicateProjectConfiguration::copyableAreas())]];
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
$path = 'configuration_snapshot.'.$area;
$rules[$path] = ['sometimes', 'array', 'max:200'];
$rules[$path.'.*.configuration_key'] = ['required', 'uuid', 'distinct:strict'];
}
$rules['configuration_snapshot.project_settings'] = ['sometimes', 'array:show_author,show_story_points'];
foreach (['show_author', 'show_story_points'] as $field) {
$rules['configuration_snapshot.project_settings.'.$field] = ['required_with:configuration_snapshot.project_settings', 'boolean'];
}
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities'] as $area) {
$rules["configuration_snapshot.{$area}.*.name"] = ['required', 'string', 'max:255'];
}
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_fields', 'ticket_link_types'] as $area) {
$rules["configuration_snapshot.{$area}.*.position"] = ['required', 'integer', 'min:1', 'max:10000'];
if ($area !== 'ticket_statuses') {
$rules["configuration_snapshot.{$area}.*.position"][] = 'distinct:strict';
}
}
$fields = [
'board_columns' => 'configuration_key,name,position',
'ticket_statuses' => 'configuration_key,name,position,board_column_key',
'ticket_types' => 'configuration_key,name,position,is_parent',
'ticket_priorities' => 'configuration_key,name,position',
'ticket_fields' => 'configuration_key,label,position,field_type',
'ticket_link_types' => 'configuration_key,label,counterpart_label,position',
'repository_connections' => 'configuration_key,provider,base_url,provider_repository_id,owner_name,repository_name,repository_full_name,repository_url',
'notification_actions' => 'configuration_key,name,enabled,event_type,channel,channel_configuration,message_template,delivery_url,filters',
];
foreach ($fields as $area => $allowed) {
$rules["configuration_snapshot.{$area}.*"] = ['array:'.$allowed];
}
$rules['configuration_snapshot.ticket_statuses.*.board_column_key'] = ['nullable', 'uuid'];
$rules['configuration_snapshot.ticket_types.*.is_parent'] = ['required', 'boolean'];
$rules['configuration_snapshot.ticket_fields.*.field_type'] = ['required', Rule::in(['number', 'user'])];
$rules['configuration_snapshot.ticket_fields.*.label'] = ['required', 'string', 'max:255'];
$rules['configuration_snapshot.ticket_link_types.*.label'] = ['required', 'string', 'max:255'];
$rules['configuration_snapshot.ticket_link_types.*.counterpart_label'] = ['required', 'string', 'max:255'];
$rules['configuration_snapshot.status_transitions'] = ['sometimes', 'array', 'max:200'];
$rules['configuration_snapshot.status_transitions.*'] = ['array:status_key,target_keys'];
$rules['configuration_snapshot.status_transitions.*.status_key'] = ['required', 'uuid', 'distinct:strict'];
$rules['configuration_snapshot.status_transitions.*.target_keys'] = ['present', 'array', 'max:200'];
$rules['configuration_snapshot.status_transitions.*.target_keys.*'] = ['uuid'];
foreach (['provider_repository_id', 'owner_name', 'repository_name', 'repository_full_name'] as $field) {
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'string', 'max:255'];
}
$rules['configuration_snapshot.repository_connections.*.provider'] = ['required', Rule::in(['gitea'])];
foreach (['base_url', 'repository_url'] as $field) {
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'url:http,https', 'max:255'];
}
$prefix = 'configuration_snapshot.notification_actions.*.';
$rules[$prefix.'name'] = ['required', 'string', 'max:255'];
$rules[$prefix.'enabled'] = ['required', 'boolean'];
$rules[$prefix.'event_type'] = ['required', Rule::in(NotificationEventType::values())];
$rules[$prefix.'channel'] = ['required', Rule::in(NotificationChannel::values())];
$rules[$prefix.'delivery_url'] = ['required', 'url:http,https', 'max:2048'];
$rules[$prefix.'message_template'] = ['nullable', 'string', 'max:2000'];
$rules[$prefix.'channel_configuration'] = ['sometimes', 'array:allowed_mentions'];
$rules[$prefix.'channel_configuration.allowed_mentions'] = ['sometimes', 'array'];
$rules[$prefix.'channel_configuration.allowed_mentions.*'] = [Rule::in(['everyone', 'roles', 'users'])];
$rules[$prefix.'filters'] = ['sometimes', 'array', 'max:25'];
$rules[$prefix.'filters.*'] = ['array:attribute,operator,value'];
$rules[$prefix.'filters.*.attribute'] = ['required', Rule::in(NotificationFilterAttribute::values())];
$rules[$prefix.'filters.*.operator'] = ['required', Rule::in(NotificationFilterOperator::values())];
$rules[$prefix.'filters.*.value'] = ['nullable'];
return $rules;
}
/** @return array<int, callable(Validator): void> */
public function after(): array
{
return [function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$validated = $validator->validated();
$configuration = Arr::get($validated, 'configuration_snapshot', []);
$areas = Arr::get($validated, 'copy_areas', []);
$columns = array_column(Arr::get($configuration, 'board_columns', []), 'configuration_key');
$statuses = array_column(Arr::get($configuration, 'ticket_statuses', []), 'configuration_key');
foreach (Arr::get($configuration, 'ticket_statuses', []) as $index => $status) {
$key = Arr::get($status, 'board_column_key');
if ($key !== null && in_array('board_columns', $areas, true) && ! in_array($key, $columns, true)) {
$validator->errors()->add("configuration_snapshot.ticket_statuses.{$index}.board_column_key", 'Choose a column from this template.');
}
}
foreach (Arr::get($configuration, 'status_transitions', []) as $index => $transition) {
if (! in_array(Arr::get($transition, 'status_key'), $statuses, true)
|| array_diff(Arr::get($transition, 'target_keys', []), $statuses) !== []) {
$validator->errors()->add("configuration_snapshot.status_transitions.{$index}", 'Choose statuses from this template.');
}
if (in_array('status_transitions', $areas, true) && ! in_array('ticket_statuses', $areas, true)
&& Arr::get($transition, 'target_keys', []) !== []) {
$validator->errors()->add('copy_areas', 'Include ticket statuses when defining template transitions.');
}
}
$repositoryIdentifiers = [];
foreach (Arr::get($configuration, 'repository_connections', []) as $index => $connection) {
$identifier = serialize([$connection['provider'], $connection['base_url'], $connection['provider_repository_id']]);
if (isset($repositoryIdentifiers[$identifier])) {
$validator->errors()->add("configuration_snapshot.repository_connections.{$index}.provider_repository_id", 'This repository is already included in the template.');
}
$repositoryIdentifiers[$identifier] = true;
}
foreach (Arr::get($configuration, 'notification_actions', []) as $index => $action) {
if (Arr::get($action, 'channel') === 'discord' && blank(Arr::get($action, 'message_template'))) {
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.message_template", 'A Discord message is required.');
}
foreach (Arr::get($action, 'filters', []) as $filterIndex => $filter) {
$operator = Arr::get($filter, 'operator');
$value = Arr::get($filter, 'value');
$valid = is_string($value) || $value === null || (is_array($value) && collect($value)->every(static fn ($item): bool => is_string($item)));
if (! $valid || (in_array($operator, ['equals', 'not_equals', 'in'], true) && blank($value))
|| (in_array($operator, ['exists', 'not_exists'], true) && filled($value))) {
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.filters.{$filterIndex}.value", 'Choose a value that matches the filter operator.');
}
}
}
}];
}
}
@@ -34,11 +34,7 @@ class ResetProjectTemplateRequest extends FormRequest
Rule::in([$project->key]), Rule::in([$project->key]),
], ],
'status_replacements' => ['sometimes', 'array'], 'status_replacements' => ['sometimes', 'array'],
'status_replacements.*' => [ 'status_replacements.*' => ['required'],
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
],
]; ];
} }
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$validated = $validator->validated(); $validated = $validator->validated();
$areas = array_values(Arr::get( $project->loadMissing('projectTemplate');
$validated, $configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
'areas', $areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
DuplicateProjectConfiguration::copyableAreas(), foreach ($areas as $area) {
)); if (! array_key_exists($area, $configuration)) {
$validator->errors()->add('areas', 'This template does not define the selected settings area.');
}
}
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) { if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
return; return;
} }
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
$project->loadMissing('projectTemplate'); $statuses = $project->ticketStatuses()->withCount('tickets')->get();
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration( $allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
$project->projectTemplate?->configuration_snapshot ?? [],
);
$templateStatuses = Arr::get(
$configuration,
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
[],
);
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
$replacements = Arr::get($validated, 'status_replacements', []); $replacements = Arr::get($validated, 'status_replacements', []);
foreach ($replacements as $id => $replacement) {
foreach ($statuses->slice(count($templateStatuses)) as $status) { if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
if ($status->tickets_count === 0) { || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
$validator->errors()->add("status_replacements.{$id}", 'Choose a replacement from this template for a status in this project.');
}
}
foreach ($statuses as $status) {
if (in_array($status->configuration_key, $templateKeys, true) || $status->tickets_count === 0) {
continue; continue;
} }
$replacement = Arr::get($replacements, (string) $status->id);
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0); if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
if (! $allowedReplacementIds->contains($replacementId)) { $validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
$validator->errors()->add(
"status_replacements.{$status->id}",
"Choose a replacement status for {$status->name}.",
);
} }
} }
}, },
@@ -12,7 +12,8 @@ class StoreProjectTemplateRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can('create-project') ?? false; return ($this->user()?->can('create-project') ?? false)
&& ($this->route('project') === null || $this->user()->can('admin-project', $this->route('project')));
} }
protected function prepareForValidation(): void protected function prepareForValidation(): void
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
$project = $this->route('project');
$baseTemplate = $project?->projectTemplate;
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')], 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))],
'copy_areas' => ['required', 'array', 'min:1'], 'copy_areas' => ['present', 'array'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
]; ];
} }
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Requests;
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
class StoreStandaloneProjectTemplateRequest extends StoreProjectTemplateRequest
{
use ValidatesProjectTemplateConfiguration;
public function rules(): array
{
return [...parent::rules(), ...$this->configurationRules()];
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
use App\Models\ProjectTemplate; use App\Models\ProjectTemplate;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
class UpdateProjectTemplateRequest extends FormRequest class UpdateProjectTemplateRequest extends FormRequest
{ {
use ValidatesProjectTemplateConfiguration;
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can('create-project') ?? false; return $this->user()?->can('create-project') ?? false;
@@ -25,8 +28,9 @@ class UpdateProjectTemplateRequest extends FormRequest
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false; $requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
return [ return [
...$this->configurationRules(),
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'copy_areas' => ['required', 'array', 'min:1'], 'copy_areas' => ['present', 'array'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [ 'confirm_linked_projects_update' => [
Rule::excludeIf(! $requiresImpactConfirmation), Rule::excludeIf(! $requiresImpactConfirmation),
+2
View File
@@ -32,6 +32,8 @@ class ProjectResource extends JsonResource
? null ? null
: [ : [
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []), 'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
'local_configuration' => Arr::get($templateState, 'localConfiguration', []),
'local_areas' => Arr::get($templateState, 'localAreas', []),
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []), 'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []), 'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false), 'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])] #[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
class BoardColumn extends Model class BoardColumn extends Model
{ {
/** @use HasFactory<BoardColumnFactory> */ /** @use HasFactory<BoardColumnFactory> */
@@ -5,11 +5,44 @@ namespace App\Models\Concerns;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate; use App\Models\ProjectTemplate;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
trait SyncsProjectTemplateOwnership trait SyncsProjectTemplateOwnership
{ {
/** @param array<string, mixed> $options */
public function save(array $options = []): bool
{
return DB::transaction(function () use ($options): bool {
$this->syncProjectTemplateOwnership();
if ($this->getAttribute('project_id') !== null) {
$project = Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
if ($project?->project_template_id !== null) {
$this->setAttribute('project_template_id', $project->project_template_id);
}
}
return parent::save($options);
}, 3);
}
public function delete(): ?bool
{
return DB::transaction(function (): ?bool {
if ($this->getAttribute('project_id') !== null) {
Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
}
return parent::delete();
}, 3);
}
protected function syncProjectTemplateOwnership(): void protected function syncProjectTemplateOwnership(): void
{ {
if ($this->getAttribute('configuration_key') === null) {
$this->setAttribute('configuration_key', (string) Str::uuid());
}
$projectTemplateId = $this->getAttribute('project_template_id'); $projectTemplateId = $this->getAttribute('project_template_id');
$projectId = $this->getAttribute('project_id'); $projectId = $this->getAttribute('project_id');
+1 -1
View File
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([ #[Fillable(['configuration_key',
'channel', 'channel',
'channel_configuration', 'channel_configuration',
'delivery_url', 'delivery_url',
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable(['configuration_key',
'base_url', 'base_url',
'owner_name', 'owner_name',
'project_id', 'project_id',
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])] #[Fillable(['configuration_key', 'field_type', 'label', 'position', 'project_id', 'project_template_id'])]
class TicketFieldDefinition extends Model class TicketFieldDefinition extends Model
{ {
/** /**
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])] #[Fillable(['configuration_key', 'label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
class TicketLinkType extends Model class TicketLinkType extends Model
{ {
/** @use HasFactory<TicketLinkTypeFactory> */ /** @use HasFactory<TicketLinkTypeFactory> */
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])] #[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
class TicketPriority extends Model class TicketPriority extends Model
{ {
/** @use HasFactory<TicketPriorityFactory> */ /** @use HasFactory<TicketPriorityFactory> */
+1 -1
View File
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])] #[Fillable(['configuration_key', 'board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
class TicketStatus extends Model class TicketStatus extends Model
{ {
/** @use HasFactory<TicketStatusFactory> */ /** @use HasFactory<TicketStatusFactory> */
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])] #[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
class TicketType extends Model class TicketType extends Model
{ {
/** @use HasFactory<TicketTypeFactory> */ /** @use HasFactory<TicketTypeFactory> */
@@ -30,6 +30,9 @@ class ProjectConfigurationObserver
public function deleted(Model $model): void public function deleted(Model $model): void
{ {
$this->markOverride($model); $this->markOverride($model);
if ($model instanceof TicketStatus && $model->project !== null) {
$this->refreshProjectBaseTemplate->handle($model->project, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS);
}
} }
private function markOverride(Model $model): void private function markOverride(Model $model): void
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<BoardColumn> * @extends Factory<BoardColumn>
@@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'name' => fake()->unique()->words(2, true), 'name' => fake()->unique()->words(2, true),
'position' => 1, 'position' => 1,
'project_id' => Project::factory(), 'project_id' => Project::factory(),
@@ -7,6 +7,7 @@ use App\Enums\NotificationEventType;
use App\Models\NotificationAction; use App\Models\NotificationAction;
use App\Models\Project; use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<NotificationAction> * @extends Factory<NotificationAction>
@@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'channel' => NotificationChannel::Webhook, 'channel' => NotificationChannel::Webhook,
'channel_configuration' => [], 'channel_configuration' => [],
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(), 'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
@@ -6,6 +6,7 @@ use App\Actions\Git\GitProviderKey;
use App\Models\Project; use App\Models\Project;
use App\Models\RepositoryConnection; use App\Models\RepositoryConnection;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<RepositoryConnection> * @extends Factory<RepositoryConnection>
@@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'base_url' => 'https://git.'.fake()->domainName(), 'base_url' => 'https://git.'.fake()->domainName(),
'owner_name' => fake()->userName(), 'owner_name' => fake()->userName(),
'project_id' => Project::factory(), 'project_id' => Project::factory(),
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketFieldDefinition; use App\Models\TicketFieldDefinition;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketFieldDefinition> * @extends Factory<TicketFieldDefinition>
@@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES), 'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
'label' => fake()->unique()->words(2, true), 'label' => fake()->unique()->words(2, true),
'position' => 1, 'position' => 1,
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketLinkType; use App\Models\TicketLinkType;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketLinkType> * @extends Factory<TicketLinkType>
@@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory
$label = fake()->unique()->words(2, true); $label = fake()->unique()->words(2, true);
return [ return [
'configuration_key' => (string) Str::uuid(),
'label' => $label, 'label' => $label,
'counterpart_label' => $label, 'counterpart_label' => $label,
'position' => 1, 'position' => 1,
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketPriority; use App\Models\TicketPriority;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketPriority> * @extends Factory<TicketPriority>
@@ -19,6 +20,7 @@ class TicketPriorityFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']), 'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
'position' => 1, 'position' => 1,
'project_id' => Project::factory(), 'project_id' => Project::factory(),
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketStatus> * @extends Factory<TicketStatus>
@@ -19,6 +20,7 @@ class TicketStatusFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'board_column_id' => null, 'board_column_id' => null,
'name' => fake()->unique()->words(2, true), 'name' => fake()->unique()->words(2, true),
'position' => 1, 'position' => 1,
+2
View File
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketType; use App\Models\TicketType;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** /**
* @extends Factory<TicketType> * @extends Factory<TicketType>
@@ -19,6 +20,7 @@ class TicketTypeFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'configuration_key' => (string) Str::uuid(),
'is_parent' => false, 'is_parent' => false,
'name' => fake()->unique()->jobTitle(), 'name' => fake()->unique()->jobTitle(),
'position' => 1, 'position' => 1,
@@ -0,0 +1,165 @@
<?php
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
private const TABLES = [
'board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities',
'ticket_field_definitions', 'ticket_link_types', 'repository_connections', 'notification_actions',
];
/**
* Run the migrations.
*/
public function up(): void
{
foreach (self::TABLES as $name) {
Schema::table($name, function (Blueprint $table): void {
$table->uuid('configuration_key')->nullable()->index();
});
DB::table($name)->orderBy('id')->chunkById(100, function ($rows) use ($name): void {
foreach ($rows as $row) {
DB::table($name)->where('id', $row->id)->update(['configuration_key' => (string) Str::uuid()]);
}
});
}
$configuration = app(DuplicateProjectConfiguration::class);
$identity = app(ProjectConfigurationIdentity::class);
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration, $identity): void {
$snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []);
$source = $template->sourceProject;
if (! $template->is_published && $template->project !== null) {
$snapshot = $configuration->export($template->project, DuplicateProjectConfiguration::copyableAreas());
} elseif ($source !== null) {
$sourceConfiguration = $configuration->export($source, array_keys($snapshot));
$replacements = [];
$usedKeys = [];
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
foreach ($snapshot[$area] ?? [] as $index => $item) {
$matches = collect($sourceConfiguration[$area] ?? [])->filter(
fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item),
);
$baselineMatches = collect($snapshot[$area])->filter(fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item));
if ($matches->count() > 1 || $baselineMatches->count() > 1) {
$matches = $matches->filter(static fn (array $candidate): bool => ($candidate['position'] ?? null) === ($item['position'] ?? null));
if ($area === 'ticket_statuses') {
$columnKey = $replacements[$item['board_column_key'] ?? ''] ?? ($item['board_column_key'] ?? null);
$matches = $matches->filter(static fn (array $candidate): bool => ($candidate['board_column_key'] ?? null) === $columnKey);
}
}
if ($matches->count() === 1 && ! in_array($matches->first()['configuration_key'], $usedKeys[$area] ?? [], true)) {
$usedKeys[$area][] = $matches->first()['configuration_key'];
$replacements[$item['configuration_key']] = $matches->first()['configuration_key'];
$snapshot[$area][$index]['configuration_key'] = $matches->first()['configuration_key'];
}
}
}
foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) {
$snapshot['ticket_statuses'][$index]['board_column_key'] = $replacements[$status['board_column_key'] ?? ''] ?? ($status['board_column_key'] ?? null);
}
foreach ($snapshot['status_transitions'] ?? [] as $index => $transition) {
$snapshot['status_transitions'][$index] = [
'status_key' => $replacements[$transition['status_key']] ?? $transition['status_key'],
'target_keys' => array_map(static fn (string $key): string => $replacements[$key] ?? $key, $transition['target_keys']),
];
}
}
if ($source !== null) {
$statusKeys = $source->ticketStatuses()->pluck('configuration_key', 'id')->all();
foreach ($snapshot['notification_actions'] ?? [] as $index => $action) {
$snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $statusKeys);
}
}
$template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly();
});
Project::query()->with('projectTemplate')->eachById(function (Project $project) use ($identity): void {
$snapshot = $project->projectTemplate?->configuration_snapshot ?? [];
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
$models = $project->{$relation}()->get();
$usedIds = [];
$columnKeys = $area === 'ticket_statuses' ? $project->boardColumns()->pluck('configuration_key', 'id')->all() : [];
foreach ($snapshot[$area] ?? [] as $item) {
$matches = $models->filter(fn ($model): bool => $identity->signature($area, $model->attributesToArray()) === $identity->signature($area, $item));
$matches = $matches->reject(static fn ($model): bool => in_array($model->id, $usedIds, true));
if ($matches->count() > 1) {
$matches = $matches->filter(static fn ($model): bool => $model->position === ($item['position'] ?? null));
if ($area === 'ticket_statuses') {
$matches = $matches->filter(static fn ($model): bool => ($columnKeys[$model->board_column_id] ?? null) === ($item['board_column_key'] ?? null));
}
}
if ($matches->count() === 1) {
$model = $matches->first();
$usedIds[] = $model->id;
DB::table($model->getTable())->where('id', $model->id)->update(['configuration_key' => $item['configuration_key']]);
}
}
}
});
foreach (self::TABLES as $name) {
Schema::table($name, function (Blueprint $table): void {
$table->unique(['project_id', 'configuration_key']);
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$configuration = app(DuplicateProjectConfiguration::class);
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
$snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []);
$columnIndexes = array_flip(array_column($snapshot['board_columns'] ?? [], 'configuration_key'));
$statusIndexes = array_flip(array_column($snapshot['ticket_statuses'] ?? [], 'configuration_key'));
if (isset($snapshot['status_transitions'])) {
$transitions = array_fill(0, count($statusIndexes), []);
foreach ($snapshot['status_transitions'] as $transition) {
$index = $statusIndexes[$transition['status_key']] ?? null;
if ($index !== null) {
$transitions[$index] = array_values(array_filter(array_map(
static fn (string $key): ?int => $statusIndexes[$key] ?? null,
$transition['target_keys'],
), static fn (?int $index): bool => $index !== null));
}
}
$snapshot['status_transitions'] = $transitions;
}
foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) {
$status['board_column_index'] = $columnIndexes[$status['board_column_key'] ?? ''] ?? null;
unset($status['board_column_key']);
$snapshot['ticket_statuses'][$index] = $status;
}
$sourceStatusIds = $template->sourceProject?->ticketStatuses()->pluck('id', 'configuration_key')->all() ?? [];
foreach ($snapshot['notification_actions'] ?? [] as $index => $action) {
$snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $sourceStatusIds);
}
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
foreach ($snapshot[$area] ?? [] as $index => $item) {
unset($item['configuration_key']);
$snapshot[$area][$index] = $item;
}
}
$template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly();
});
foreach (self::TABLES as $name) {
Schema::table($name, function (Blueprint $table): void {
$table->dropUnique(['project_id', 'configuration_key']);
$table->dropIndex(['configuration_key']);
$table->dropColumn('configuration_key');
});
}
}
};
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.2.7", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -25,5 +25,5 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@tauri-apps/cli": "2.11.4"
}, },
"releaseNotes": "Fix blocked navigation after changing project creation options or other forms on macOS by displaying native confirmations for unsaved changes." "releaseNotes": "Fix blocked navigation after changing project creation options or other forms on iOS by displaying native confirmations for unsaved changes. Pending confirmations close safely when changing workspaces."
} }
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.7" version = "0.2.8"
dependencies = [ dependencies = [
"semver", "semver",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.7" version = "0.2.8"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -43,6 +43,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private var settingsAvailable = false private var settingsAvailable = false
private var toolbarHeight: NSLayoutConstraint! private var toolbarHeight: NSLayoutConstraint!
private var appearance: UISegmentedControl! private var appearance: UISegmentedControl!
private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)?
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
@@ -51,6 +52,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
deinit { javaScriptConfirmation?.completion(false) }
private func text(_ key: String) -> String { private func text(_ key: String) -> String {
NSLocalizedString(key, comment: "KanbanGit iOS workspace") NSLocalizedString(key, comment: "KanbanGit iOS workspace")
} }
@@ -199,6 +202,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
func open(_ url: URL) { func open(_ url: URL) {
if server?.url == url, connection.ready { showWorkspace(); return } if server?.url == url, connection.ready { showWorkspace(); return }
completeJavaScriptConfirmation(false)
let selected = WorkspaceServer.selecting(url, current: server) let selected = WorkspaceServer.selecting(url, current: server)
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return } guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey) defaults.set(data, forKey: serverKey)
@@ -265,6 +269,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
@objc private func showSettings() { @objc private func showSettings() {
completeJavaScriptConfirmation(false)
loading.stopAnimating() loading.stopAnimating()
view.endEditing(true) view.endEditing(true)
launcher.isHidden = false launcher.isHidden = false
@@ -320,6 +325,39 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
} }
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
guard webView === workspace, viewIfLoaded?.window != nil,
presentedViewController == nil, javaScriptConfirmation == nil else {
completionHandler(false)
return
}
let alert = UIAlertController(title: frame.request.url?.host ?? text("appName"),
message: message, preferredStyle: .alert)
let cancel = UIAlertAction(title: text("cancel"), style: .cancel) { [weak self] _ in
self?.completeJavaScriptConfirmation(false)
}
alert.addAction(cancel)
alert.addAction(UIAlertAction(title: text("ok"), style: .default) { [weak self] _ in
self?.completeJavaScriptConfirmation(true)
})
alert.preferredAction = cancel
javaScriptConfirmation = (alert, completionHandler)
present(alert, animated: true)
}
func completeJavaScriptConfirmation(_ accepted: Bool) {
guard let confirmation = javaScriptConfirmation else { return }
javaScriptConfirmation = nil
if confirmation.alert.presentingViewController == nil {
confirmation.completion(accepted)
} else {
confirmation.alert.dismiss(animated: false) {
confirmation.completion(accepted)
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
guard server?.allows(url) == true else { guard server?.allows(url) == true else {
@@ -9,6 +9,7 @@
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server."; "switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
"confirmSwitch" = "Switch server"; "confirmSwitch" = "Switch server";
"cancel" = "Cancel"; "cancel" = "Cancel";
"ok" = "OK";
"connecting" = "Connecting to your server…"; "connecting" = "Connecting to your server…";
"connected" = "Your workspace is open. Use Server settings to change servers."; "connected" = "Your workspace is open. Use Server settings to change servers.";
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept."; "connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
@@ -135,6 +135,94 @@ final class WorkspaceTests: XCTestCase {
"Changing workspaces must retain native gesture navigation") "Changing workspaces must retain native gesture navigation")
} }
@MainActor
func testDirtyFormNavigationPresentsNativeConfirmation() async throws {
let animationsEnabled = UIView.areAnimationsEnabled
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(animationsEnabled) }
let name = "ios-confirmation-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true; window.rootViewController = nil }
let url = URL(string: "https://example.invalid/")!
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
<a id="back" href="#back">Back</a><script>
window.visits = 0;
document.querySelector('#back').onclick = event => {
event.preventDefault();
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
};
</script></body></html>
""")
try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'")
_ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true")
let pristineVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(pristineVisits as? Int, 1)
XCTAssertNil(controller.presentedViewController)
_ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true")
var expectedVisits = 1
for (index, accepted) in [false, true, false, true].enumerated() {
let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
let alert = try await waitForConfirmation(controller)
XCTAssertEqual(alert.title, "example.invalid")
XCTAssertEqual(alert.message, "Discard unsaved changes?")
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default])
XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft")
if index == 3 {
await withCheckedContinuation { continuation in
alert.dismiss(animated: false) { continuation.resume() }
}
}
controller.completeJavaScriptConfirmation(accepted)
controller.completeJavaScriptConfirmation(!accepted)
_ = try await click.value
if accepted { expectedVisits += 1 }
let visits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each")
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
XCTAssertEqual(draft as? String, "Unsaved project")
XCTAssertNil(controller.presentedViewController)
}
let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert)
controller.present(otherAlert, animated: false)
let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')")
XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript")
XCTAssertTrue(controller.presentedViewController === otherAlert)
await withCheckedContinuation { continuation in
otherAlert.dismiss(animated: false) { continuation.resume() }
}
let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
_ = try await waitForConfirmation(controller)
controller.open(URL(string: "https://other.invalid/")!)
_ = try await pendingClick.value
XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation")
let oldVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(oldVisits as? Int, expectedVisits)
let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')")
XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations")
XCTAssertNil(controller.presentedViewController)
}
@MainActor
private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController {
for _ in 0..<100 {
if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert }
try await Task.sleep(for: .milliseconds(50))
}
return try XCTUnwrap(controller.presentedViewController as? UIAlertController,
"Dirty forms must offer a visible confirmation instead of silently cancelling navigation")
}
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws { func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
for body in [ for body in [
#"{"version":2,"id":1,"action":"getState","payload":{}}"#, #"{"version":2,"id":1,"action":"getState","payload":{}}"#,
@@ -33,6 +33,13 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
remoteInfo = "kanbangit-native_iOS";
};
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = { 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */; containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
@@ -48,7 +55,6 @@
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; }; 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; }; 1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; }; 2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -67,6 +73,7 @@
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; }; 946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; }; 9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; };
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; }; AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; }; BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
@@ -189,7 +196,7 @@
59D2DE9FD94E6D2D64CB0117 /* Products */ = { 59D2DE9FD94E6D2D64CB0117 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */, 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */,
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */, A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */, 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
); );
@@ -248,7 +255,7 @@
packageProductDependencies = ( packageProductDependencies = (
); );
productName = "kanbangit-native_iOS"; productName = "kanbangit-native_iOS";
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */; productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = { 988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
@@ -279,6 +286,7 @@
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
); );
name = WorkspaceTests; name = WorkspaceTests;
packageProductDependencies = ( packageProductDependencies = (
@@ -418,6 +426,11 @@
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */; target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */; targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
}; };
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
@@ -451,7 +464,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -467,7 +480,7 @@
); );
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.7; MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -628,7 +641,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -644,7 +657,7 @@
); );
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.7; MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -691,6 +704,7 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
}; };
name = debug; name = debug;
}; };
@@ -710,6 +724,7 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
}; };
name = release; name = release;
}; };
+6 -2
View File
@@ -18,8 +18,8 @@ settingGroups:
DEVELOPMENT_TEAM: ZZX2KZGTHU DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
MARKETING_VERSION: "0.2.7" MARKETING_VERSION: "0.2.8"
CURRENT_PROJECT_VERSION: "6" CURRENT_PROJECT_VERSION: "7"
configs: configs:
debug: debug:
CODE_SIGN_IDENTITY: Apple Development CODE_SIGN_IDENTITY: Apple Development
@@ -40,6 +40,7 @@ targets:
kanbangit-native_iOS: kanbangit-native_iOS:
type: application type: application
platform: iOS platform: iOS
productName: KanbanGit
sources: sources:
- path: Sources - path: Sources
- path: ../../../ui/workspace-navigation.js - path: ../../../ui/workspace-navigation.js
@@ -115,6 +116,8 @@ targets:
WorkspaceTests: WorkspaceTests:
type: bundle.unit-test type: bundle.unit-test
platform: iOS platform: iOS
dependencies:
- target: kanbangit-native_iOS
sources: sources:
- path: ../../../ui/workspace-navigation.js - path: ../../../ui/workspace-navigation.js
- path: ../../../ui/workspace-settings.js - path: ../../../ui/workspace-settings.js
@@ -126,6 +129,7 @@ targets:
base: base:
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
DEVELOPMENT_TEAM: ZZX2KZGTHU DEVELOPMENT_TEAM: ZZX2KZGTHU
CODE_SIGN_STYLE: Manual CODE_SIGN_STYLE: Manual
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
+1 -1
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.7", "version": "0.2.8",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+1 -1
View File
@@ -5,7 +5,7 @@
"iOS": { "iOS": {
"minimumSystemVersion": "17.0", "minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU", "developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "6" "bundleVersion": "7"
} }
} }
} }
+1 -1
View File
@@ -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.13", "vue": "^3.5.43",
"vue-input-otp": "^0.3.2" "vue-input-otp": "^0.3.2"
}, },
"optionalDependencies": { "optionalDependencies": {
+142 -124
View File
@@ -204,12 +204,15 @@ settings:
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides: overrides:
'@vue/server-renderer@<3.5.42': ^3.5.42
axios@>=1.13.0 <1.20.0: ^1.20.0 axios@>=1.13.0 <1.20.0: ^1.20.0
axios@>=1.15.0 <1.20.0: ^1.20.0 axios@>=1.15.0 <1.20.0: ^1.20.0
axios@>=1.15.1 <1.20.0: ^1.20.0 axios@>=1.15.1 <1.20.0: ^1.20.0
axios@>=1.15.2 <1.20.0: ^1.20.0 axios@>=1.15.2 <1.20.0: ^1.20.0
axios@>=1.16.1 <1.20.0: ^1.20.0 axios@>=1.16.1 <1.20.0: ^1.20.0
axios@>=1.17.0 <1.20.0: ^1.20.0 axios@>=1.17.0 <1.20.0: ^1.20.0
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
importers: importers:
@@ -220,7 +223,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.30(typescript@5.9.3)) version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.43(typescript@5.9.3))
'@tiptap/core': '@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)
@@ -235,7 +238,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.30(typescript@5.9.3)) version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.43(typescript@5.9.3))
'@vueuse/core': '@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)
@@ -250,10 +253,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.30(typescript@5.9.3)) version: 0.468.0(vue@3.5.43(typescript@5.9.3))
reka-ui: reka-ui:
specifier: ^2.6.1 specifier: ^2.6.1
version: 2.9.2(vue@3.5.30(typescript@5.9.3)) version: 2.9.2(vue@3.5.43(typescript@5.9.3))
tailwind-merge: tailwind-merge:
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.5.0 version: 3.5.0
@@ -264,11 +267,11 @@ importers:
specifier: ^1.2.5 specifier: ^1.2.5
version: 1.4.0 version: 1.4.0
vue: vue:
specifier: ^3.5.13 specifier: ^3.5.43
version: 3.5.30(typescript@5.9.3) version: 3.5.43(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.30(typescript@5.9.3)) version: 0.3.2(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.39.4 specifier: ^9.39.4
@@ -293,7 +296,7 @@ importers:
version: 8.58.1(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) 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.30(typescript@5.9.3)) version: 6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.43(typescript@5.9.3))
'@vue/eslint-config-typescript': '@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)
@@ -370,21 +373,21 @@ importers:
packages: packages:
'@babel/helper-string-parser@7.27.1': '@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5': '@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2': '@babel/parser@7.29.9':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/types@7.29.0': '@babel/types@7.29.8':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@emnapi/core@1.9.1': '@emnapi/core@1.9.1':
@@ -1109,17 +1112,17 @@ packages:
typescript: typescript:
optional: true optional: true
'@vue/compiler-core@3.5.30': '@vue/compiler-core@3.5.43':
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
'@vue/compiler-dom@3.5.30': '@vue/compiler-dom@3.5.43':
resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
'@vue/compiler-sfc@3.5.30': '@vue/compiler-sfc@3.5.43':
resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==}
'@vue/compiler-ssr@3.5.30': '@vue/compiler-ssr@3.5.43':
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==}
'@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==}
@@ -1143,22 +1146,20 @@ packages:
typescript: typescript:
optional: true optional: true
'@vue/reactivity@3.5.30': '@vue/reactivity@3.5.43':
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
'@vue/runtime-core@3.5.30': '@vue/runtime-core@3.5.43':
resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
'@vue/runtime-dom@3.5.30': '@vue/runtime-dom@3.5.43':
resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
'@vue/server-renderer@3.5.30': '@vue/server-renderer@3.5.43':
resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
peerDependencies:
vue: 3.5.30
'@vue/shared@3.5.30': '@vue/shared@3.5.43':
resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
'@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==}
@@ -2166,6 +2167,11 @@ 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}
@@ -2264,6 +2270,10 @@ 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'}
@@ -2480,8 +2490,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'} engines: {node: '>=8'}
shell-quote@1.9.0: shell-quote@1.12.0:
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
side-channel-list@1.0.1: side-channel-list@1.0.1:
@@ -2500,8 +2510,8 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
source-map-js@1.2.1: source-map-js@1.2.2:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
stable-hash-x@0.2.0: stable-hash-x@0.2.0:
@@ -2728,8 +2738,8 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=5.0.0' typescript: '>=5.0.0'
vue@3.5.30: vue@3.5.43:
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
peerDependenciesMeta: peerDependenciesMeta:
@@ -2790,18 +2800,18 @@ packages:
snapshots: snapshots:
'@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.2': '@babel/parser@7.29.9':
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.8
'@babel/types@7.29.0': '@babel/types@7.29.8':
dependencies: dependencies:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.29.7
'@emnapi/core@1.9.1': '@emnapi/core@1.9.1':
dependencies: dependencies:
@@ -2876,11 +2886,11 @@ snapshots:
'@floating-ui/utils@0.2.11': {} '@floating-ui/utils@0.2.11': {}
'@floating-ui/vue@1.1.11(vue@3.5.30(typescript@5.9.3))': '@floating-ui/vue@1.1.11(vue@3.5.43(typescript@5.9.3))':
dependencies: 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.30(typescript@5.9.3)) vue-demi: 0.14.10(vue@3.5.43(typescript@5.9.3))
transitivePeerDependencies: transitivePeerDependencies:
- '@vue/composition-api' - '@vue/composition-api'
- vue - vue
@@ -2912,13 +2922,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.30(typescript@5.9.3))': '@inertiajs/vue3@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.43(typescript@5.9.3))':
dependencies: 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.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
- supports-color - supports-color
@@ -3053,7 +3063,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.1 source-map-js: 1.2.2
tailwindcss: 4.2.2 tailwindcss: 4.2.2
'@tailwindcss/oxide-android-arm64@4.2.2': '@tailwindcss/oxide-android-arm64@4.2.2':
@@ -3116,10 +3126,10 @@ snapshots:
'@tanstack/virtual-core@3.13.23': {} '@tanstack/virtual-core@3.13.23': {}
'@tanstack/vue-virtual@3.13.23(vue@3.5.30(typescript@5.9.3))': '@tanstack/vue-virtual@3.13.23(vue@3.5.43(typescript@5.9.3))':
dependencies: dependencies:
'@tanstack/virtual-core': 3.13.23 '@tanstack/virtual-core': 3.13.23
vue: 3.5.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
dependencies: dependencies:
@@ -3283,12 +3293,12 @@ snapshots:
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/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.30(typescript@5.9.3))': '@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.43(typescript@5.9.3))':
dependencies: 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.30(typescript@5.9.3) vue: 3.5.43(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)
@@ -3466,11 +3476,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.30(typescript@5.9.3))': '@vitejs/plugin-vue@6.0.5(vite@8.2.2(@types/node@22.19.15)(jiti@2.6.1))(vue@3.5.43(typescript@5.9.3))':
dependencies: 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.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
'@volar/language-core@2.4.15': '@volar/language-core@2.4.15':
dependencies: dependencies:
@@ -3486,35 +3496,35 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
'@vue/compiler-core@3.5.30': '@vue/compiler-core@3.5.43':
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.9
'@vue/shared': 3.5.30 '@vue/shared': 3.5.43
entities: 7.0.1 entities: 7.0.1
estree-walker: 2.0.2 estree-walker: 2.0.2
source-map-js: 1.2.1 source-map-js: 1.2.2
'@vue/compiler-dom@3.5.30': '@vue/compiler-dom@3.5.43':
dependencies: dependencies:
'@vue/compiler-core': 3.5.30 '@vue/compiler-core': 3.5.43
'@vue/shared': 3.5.30 '@vue/shared': 3.5.43
'@vue/compiler-sfc@3.5.30': '@vue/compiler-sfc@3.5.43':
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.9
'@vue/compiler-core': 3.5.30 '@vue/compiler-core': 3.5.43
'@vue/compiler-dom': 3.5.30 '@vue/compiler-dom': 3.5.43
'@vue/compiler-ssr': 3.5.30 '@vue/compiler-ssr': 3.5.43
'@vue/shared': 3.5.30 '@vue/shared': 3.5.43
estree-walker: 2.0.2 estree-walker: 2.0.2
magic-string: 0.30.21 magic-string: 0.30.21
postcss: 8.5.26 postcss: 8.5.29
source-map-js: 1.2.1 source-map-js: 1.2.2
'@vue/compiler-ssr@3.5.30': '@vue/compiler-ssr@3.5.43':
dependencies: dependencies:
'@vue/compiler-dom': 3.5.30 '@vue/compiler-dom': 3.5.43
'@vue/shared': 3.5.30 '@vue/shared': 3.5.43
'@vue/compiler-vue2@2.7.16': '@vue/compiler-vue2@2.7.16':
dependencies: dependencies:
@@ -3537,9 +3547,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.30 '@vue/compiler-dom': 3.5.43
'@vue/compiler-vue2': 2.7.16 '@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.30 '@vue/shared': 3.5.43
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
@@ -3547,45 +3557,45 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
'@vue/reactivity@3.5.30': '@vue/reactivity@3.5.43':
dependencies: dependencies:
'@vue/shared': 3.5.30 '@vue/shared': 3.5.43
'@vue/runtime-core@3.5.30': '@vue/runtime-core@3.5.43':
dependencies: dependencies:
'@vue/reactivity': 3.5.30 '@vue/reactivity': 3.5.43
'@vue/shared': 3.5.30 '@vue/shared': 3.5.43
'@vue/runtime-dom@3.5.30': '@vue/runtime-dom@3.5.43':
dependencies: dependencies:
'@vue/reactivity': 3.5.30 '@vue/reactivity': 3.5.43
'@vue/runtime-core': 3.5.30 '@vue/runtime-core': 3.5.43
'@vue/shared': 3.5.30 '@vue/shared': 3.5.43
csstype: 3.2.3 csstype: 3.2.3
'@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': '@vue/server-renderer@3.5.43':
dependencies: dependencies:
'@vue/compiler-ssr': 3.5.30 '@vue/compiler-ssr': 3.5.43
'@vue/shared': 3.5.30 '@vue/runtime-dom': 3.5.43
vue: 3.5.30(typescript@5.9.3) '@vue/shared': 3.5.43
'@vue/shared@3.5.30': {} '@vue/shared@3.5.43': {}
'@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.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@vueuse/core@14.2.1(vue@3.5.30(typescript@5.9.3))': '@vueuse/core@14.2.1(vue@3.5.43(typescript@5.9.3))':
dependencies: 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.30(typescript@5.9.3)) '@vueuse/shared': 14.2.1(vue@3.5.43(typescript@5.9.3))
vue: 3.5.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
'@vueuse/metadata@12.8.2': {} '@vueuse/metadata@12.8.2': {}
@@ -3593,13 +3603,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.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@vueuse/shared@14.2.1(vue@3.5.30(typescript@5.9.3))': '@vueuse/shared@14.2.1(vue@3.5.43(typescript@5.9.3))':
dependencies: dependencies:
vue: 3.5.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
acorn-jsx@5.3.2(acorn@8.16.0): acorn-jsx@5.3.2(acorn@8.16.0):
dependencies: dependencies:
@@ -3777,7 +3787,7 @@ snapshots:
dependencies: dependencies:
chalk: 4.1.2 chalk: 4.1.2
rxjs: 7.8.2 rxjs: 7.8.2
shell-quote: 1.9.0 shell-quote: 1.12.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
@@ -4588,9 +4598,9 @@ snapshots:
lodash@4.18.1: {} lodash@4.18.1: {}
lucide-vue-next@0.468.0(vue@3.5.30(typescript@5.9.3)): lucide-vue-next@0.468.0(vue@3.5.43(typescript@5.9.3)):
dependencies: dependencies:
vue: 3.5.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
@@ -4633,6 +4643,8 @@ 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: {}
@@ -4730,7 +4742,13 @@ snapshots:
dependencies: dependencies:
nanoid: 3.3.18 nanoid: 3.3.18
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.2
postcss@8.5.29:
dependencies:
nanoid: 3.3.20
picocolors: 1.1.1
source-map-js: 1.2.2
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
@@ -4849,19 +4867,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.30(typescript@5.9.3)): reka-ui@2.9.2(vue@3.5.43(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.30(typescript@5.9.3)) '@floating-ui/vue': 1.1.11(vue@3.5.43(typescript@5.9.3))
'@internationalized/date': 3.12.0 '@internationalized/date': 3.12.0
'@internationalized/number': 3.6.5 '@internationalized/number': 3.6.5
'@tanstack/vue-virtual': 3.13.23(vue@3.5.30(typescript@5.9.3)) '@tanstack/vue-virtual': 3.13.23(vue@3.5.43(typescript@5.9.3))
'@vueuse/core': 14.2.1(vue@3.5.30(typescript@5.9.3)) '@vueuse/core': 14.2.1(vue@3.5.43(typescript@5.9.3))
'@vueuse/shared': 14.2.1(vue@3.5.30(typescript@5.9.3)) '@vueuse/shared': 14.2.1(vue@3.5.43(typescript@5.9.3))
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.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- '@vue/composition-api' - '@vue/composition-api'
@@ -4961,7 +4979,7 @@ snapshots:
shebang-regex@3.0.0: {} shebang-regex@3.0.0: {}
shell-quote@1.9.0: {} shell-quote@1.12.0: {}
side-channel-list@1.0.1: side-channel-list@1.0.1:
dependencies: dependencies:
@@ -4991,7 +5009,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.1: {} source-map-js@1.2.2: {}
stable-hash-x@0.2.0: {} stable-hash-x@0.2.0: {}
@@ -5193,9 +5211,9 @@ snapshots:
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)): vue-demi@0.14.10(vue@3.5.43(typescript@5.9.3)):
dependencies: dependencies:
vue: 3.5.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)(supports-color@8.1.1))(supports-color@8.1.1): 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:
@@ -5222,11 +5240,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vue-input-otp@0.3.2(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): vue-input-otp@0.3.2(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)):
dependencies: 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.30(typescript@5.9.3)) reka-ui: 2.9.2(vue@3.5.43(typescript@5.9.3))
vue: 3.5.30(typescript@5.9.3) vue: 3.5.43(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- '@vue/composition-api' - '@vue/composition-api'
- typescript - typescript
@@ -5237,13 +5255,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.30(typescript@5.9.3): vue@3.5.43(typescript@5.9.3):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.30 '@vue/compiler-dom': 3.5.43
'@vue/compiler-sfc': 3.5.30 '@vue/compiler-sfc': 3.5.43
'@vue/runtime-dom': 3.5.30 '@vue/runtime-dom': 3.5.43
'@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) '@vue/server-renderer': 3.5.43
'@vue/shared': 3.5.30 '@vue/shared': 3.5.43
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
+6
View File
@@ -8,9 +8,15 @@ allowBuilds:
unrs-resolver: true unrs-resolver: true
vue-demi: true vue-demi: true
overrides: overrides:
'@vue/server-renderer@<3.5.42': ^3.5.42
axios@>=1.13.0 <1.20.0: ^1.20.0 axios@>=1.13.0 <1.20.0: ^1.20.0
axios@>=1.15.0 <1.20.0: ^1.20.0 axios@>=1.15.0 <1.20.0: ^1.20.0
axios@>=1.15.1 <1.20.0: ^1.20.0 axios@>=1.15.1 <1.20.0: ^1.20.0
axios@>=1.15.2 <1.20.0: ^1.20.0 axios@>=1.15.2 <1.20.0: ^1.20.0
axios@>=1.16.1 <1.20.0: ^1.20.0 axios@>=1.16.1 <1.20.0: ^1.20.0
axios@>=1.17.0 <1.20.0: ^1.20.0 axios@>=1.17.0 <1.20.0: ^1.20.0
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
audit:
ignore:
- GHSA-vfj7-8cjw-p6xm
@@ -3,7 +3,6 @@ import { Link, useForm } from '@inertiajs/vue3';
import { Layers3, LockKeyhole } from 'lucide-vue-next'; import { Layers3, LockKeyhole } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -12,7 +11,13 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas'; import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import { index, update } from '@/routes/project-templates'; import {
index,
update,
store as storeStandalone,
} from '@/routes/project-templates';
import TemplateConfigurationEditor from '@/components/projects/TemplateConfigurationEditor.vue';
import { createTemplateConfiguration } from '@/lib/project-template-configuration';
import { show } from '@/routes/projects'; import { show } from '@/routes/projects';
import { store } from '@/routes/projects/templates'; import { store } from '@/routes/projects/templates';
import type { ProjectDuplicateCopyAreaOption } from '@/types'; import type { ProjectDuplicateCopyAreaOption } from '@/types';
@@ -22,11 +27,18 @@ import type { ProjectTemplateModel } from '@/types/models/project-template-model
const props = defineProps<{ const props = defineProps<{
copyAreaOptions: ProjectDuplicateCopyAreaOption[]; copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project?: ProjectModel; project?: ProjectModel | null;
initialConfiguration?: Record<string, unknown>;
template?: ProjectTemplateModel; template?: ProjectTemplateModel;
}>(); }>();
const { t } = useLocalization(); const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas(); const { areaText } = useProjectTemplateAreas();
const publishingBase = computed(() =>
props.project?.project_template &&
!props.project.project_template.is_published
? props.project.project_template
: null,
);
const source = computed(() => props.template?.source_project ?? props.project); const source = computed(() => props.template?.source_project ?? props.project);
const linkedCount = computed(() => props.template?.linked_project_count ?? 0); const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
const form = useForm( const form = useForm(
@@ -34,14 +46,23 @@ const form = useForm(
{ {
name: name:
props.template?.name ?? props.template?.name ??
t('templates.defaultName', { project: props.project?.name ?? '' }), (props.project
? t('templates.defaultName', { project: props.project.name })
: ''),
slug: slug:
props.template?.slug ?? props.template?.slug ??
`${props.project?.key.toLowerCase()}-template`, publishingBase.value?.slug ??
(props.project
? `${props.project.key.toLowerCase()}-template`
: ''),
copy_areas: props.template copy_areas: props.template
? [...props.template.copy_areas] ? [...props.template.copy_areas]
: props.copyAreaOptions.map((area) => area.value), : props.copyAreaOptions.map((area) => area.value),
confirm_linked_projects_update: false, confirm_linked_projects_update: false,
configuration_snapshot: createTemplateConfiguration(
props.template?.configuration_snapshot ??
props.initialConfiguration,
),
}, },
); );
const protection = useFormProtection(form); const protection = useFormProtection(form);
@@ -53,13 +74,10 @@ const copyAreasError = computed(
)?.[1], )?.[1],
); );
const cancelTarget = computed(() => const cancelTarget = computed(() =>
props.template ? index() : show(props.project!.id), props.project ? show(props.project.id) : index(),
); );
function submit(): void { function submit(): void {
if ( if (linkedCount.value > 0 && !form.confirm_linked_projects_update) {
!source.value ||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
) {
return; return;
} }
form.transform((data) => form.transform((data) =>
@@ -67,6 +85,7 @@ function submit(): void {
? { ? {
name: data.name, name: data.name,
copy_areas: data.copy_areas, copy_areas: data.copy_areas,
configuration_snapshot: data.configuration_snapshot,
...(linkedCount.value > 0 ...(linkedCount.value > 0
? { ? {
confirm_linked_projects_update: confirm_linked_projects_update:
@@ -74,10 +93,21 @@ function submit(): void {
} }
: {}), : {}),
} }
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas }, : {
name: data.name,
slug: data.slug,
copy_areas: data.copy_areas,
...(!props.project
? { configuration_snapshot: data.configuration_snapshot }
: {}),
},
); );
protection.submit( protection.submit(
props.template ? update(props.template.id) : store(props.project!.id), props.template
? update(props.template.id)
: props.project
? store(props.project.id)
: storeStandalone(),
{ preserveScroll: true }, { preserveScroll: true },
); );
} }
@@ -90,12 +120,6 @@ function submit(): void {
@submit.prevent="submit" @submit.prevent="submit"
> >
<FormFeedback :state="protection.state.value" @retry="submit" /> <FormFeedback :state="protection.state.value" @retry="submit" />
<FeedbackState
v-if="!source"
state="error"
:title="t('templates.missingSource')"
:description="t('templates.missingSourceHelp')"
/>
<div <div
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]" class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
> >
@@ -115,7 +139,7 @@ function submit(): void {
name="name" name="name"
required required
maxlength="255" maxlength="255"
:disabled="form.processing || !source" :disabled="form.processing"
:placeholder="t('templates.namePlaceholder')" :placeholder="t('templates.namePlaceholder')"
:invalid="!!form.errors.name" :invalid="!!form.errors.name"
aria-describedby="name-error" aria-describedby="name-error"
@@ -139,7 +163,11 @@ function submit(): void {
name="slug" name="slug"
required required
maxlength="255" maxlength="255"
:disabled="!!template || form.processing || !source" :disabled="
!!template ||
!!publishingBase ||
form.processing
"
:placeholder="t('templates.slugPlaceholder')" :placeholder="t('templates.slugPlaceholder')"
:invalid="!!form.errors.slug" :invalid="!!form.errors.slug"
aria-describedby="slug-help slug-error" aria-describedby="slug-help slug-error"
@@ -152,7 +180,7 @@ function submit(): void {
> >
{{ {{
t( t(
template template || publishingBase
? 'templates.slugLocked' ? 'templates.slugLocked'
: 'templates.slugHelp', : 'templates.slugHelp',
) )
@@ -166,7 +194,7 @@ function submit(): void {
</section> </section>
<fieldset <fieldset
class="bg-card min-w-0 rounded-md border p-5" class="bg-card min-w-0 rounded-md border p-5"
:disabled="form.processing || !source" :disabled="form.processing"
aria-describedby="areas-help copy-areas-error" aria-describedby="areas-help copy-areas-error"
> >
<legend class="text-title px-1"> <legend class="text-title px-1">
@@ -231,6 +259,14 @@ function submit(): void {
:message="copyAreasError" :message="copyAreasError"
/> />
</fieldset> </fieldset>
<TemplateConfigurationEditor
v-if="template || !project"
v-model="form.configuration_snapshot"
:areas="form.copy_areas"
:options="copyAreaOptions"
:errors="form.errors"
:disabled="form.processing"
/>
</div> </div>
<aside <aside
class="bg-card gap-column grid min-w-0 rounded-md border p-5" class="bg-card gap-column grid min-w-0 rounded-md border p-5"
@@ -310,7 +346,7 @@ function submit(): void {
id="confirm-linked" id="confirm-linked"
v-model="form.confirm_linked_projects_update" v-model="form.confirm_linked_projects_update"
type="checkbox" type="checkbox"
:disabled="form.processing || !source" :disabled="form.processing"
:aria-invalid="!!form.errors.confirm_linked_projects_update" :aria-invalid="!!form.errors.confirm_linked_projects_update"
aria-describedby="linked-help linked-error" aria-describedby="linked-help linked-error"
class="accent-primary mt-0.5 size-5 shrink-0" class="accent-primary mt-0.5 size-5 shrink-0"
@@ -335,8 +371,7 @@ function submit(): void {
:loading="form.processing" :loading="form.processing"
:loading-label="t('forms.saving')" :loading-label="t('forms.saving')"
:disabled=" :disabled="
!source || linkedCount > 0 && !form.confirm_linked_projects_update
(linkedCount > 0 && !form.confirm_linked_projects_update)
" "
>{{ >{{
t(template ? 'templates.saveChanges' : 'templates.saveNew') t(template ? 'templates.saveChanges' : 'templates.saveNew')
@@ -0,0 +1,662 @@
<script setup lang="ts">
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-vue-next';
import { computed } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import { notificationLabel } from '@/lib/notification-labels';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import type {
TemplateConfiguration,
TemplateConfigurationEditorProps,
TemplateConfigurationItem,
TemplateListArea,
TemplateTextField,
TemplateNotificationFilter,
} from '@/types/project-template-configuration';
const props = defineProps<TemplateConfigurationEditorProps>();
const configuration = defineModel<TemplateConfiguration>({ required: true });
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const listAreas = computed(
() =>
props.options
.map((option) => option.value)
.filter(
(area) =>
props.areas.includes(area) &&
area !== 'project_settings' &&
area !== 'status_transitions',
) as TemplateListArea[],
);
const selectClass =
'border-input bg-background min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm';
const filterAttributes = [
'status.from.id',
'status.to.id',
'status.from.label',
'status.to.label',
'project.id',
'project.key',
'project.name',
'ticket.id',
'ticket.key',
'ticket.title',
'ticket.url',
'actor.id',
'actor.name',
'assignee.from.id',
'assignee.from.name',
'assignee.to.id',
'assignee.to.name',
];
const filterOperators = ['equals', 'not_equals', 'exists', 'not_exists', 'in'];
function fields(area: TemplateListArea): TemplateTextField[] {
if (area === 'ticket_fields') {
return [{ key: 'label', label: t('templateEditor.label') }];
}
if (area === 'ticket_link_types') {
return [
{ key: 'label', label: t('templateEditor.label') },
{
key: 'counterpart_label',
label: t('templateEditor.counterpart'),
},
];
}
if (area === 'repository_connections') {
return [
{ key: 'base_url', label: t('templateEditor.server') },
{
key: 'provider_repository_id',
label: t('templateEditor.repositoryId'),
},
{ key: 'owner_name', label: t('templateEditor.owner') },
{
key: 'repository_name',
label: t('templateEditor.repositoryName'),
},
{
key: 'repository_full_name',
label: t('templateEditor.fullName'),
},
{ key: 'repository_url', label: t('templateEditor.repositoryUrl') },
];
}
return [{ key: 'name', label: t('templateEditor.name') }];
}
function reindex(area: TemplateListArea): void {
const counters = new Map<string, number>();
configuration.value[area].forEach((item, index) => {
if (
area === 'repository_connections' ||
area === 'notification_actions'
) {
return;
}
const group = item.board_column_key ?? '';
counters.set(group, (counters.get(group) ?? 0) + 1);
item.position =
area === 'ticket_statuses' ? counters.get(group) : index + 1;
});
}
function add(area: TemplateListArea): void {
const item: TemplateConfigurationItem = {
configuration_key: crypto.randomUUID(),
};
if (area === 'ticket_fields') {
Object.assign(item, { label: '', field_type: 'number' });
} else if (area === 'ticket_link_types') {
Object.assign(item, { label: '', counterpart_label: '' });
} else if (area === 'repository_connections') {
Object.assign(item, {
provider: 'gitea',
base_url: '',
provider_repository_id: '',
owner_name: '',
repository_name: '',
repository_full_name: '',
repository_url: '',
});
} else {
Object.assign(item, { name: '' });
}
if (area === 'ticket_statuses') {
item.board_column_key = null;
configuration.value.status_transitions.push({
status_key: item.configuration_key,
target_keys: [],
});
}
if (area === 'ticket_types') {
item.is_parent = false;
}
if (area === 'notification_actions') {
Object.assign(item, {
enabled: true,
event_type: 'ticket_status_changed',
channel: 'webhook',
delivery_url: '',
message_template: '',
channel_configuration: { allowed_mentions: [] },
filters: [],
});
}
configuration.value[area].push(item);
reindex(area);
}
function remove(area: TemplateListArea, index: number): void {
const item = configuration.value[area][index];
if (area === 'board_columns') {
configuration.value.ticket_statuses.forEach((status) => {
if (status.board_column_key === item.configuration_key) {
status.board_column_key = null;
}
});
reindex('ticket_statuses');
}
if (area === 'ticket_statuses') {
configuration.value.status_transitions =
configuration.value.status_transitions.filter(
(transition) =>
transition.status_key !== item.configuration_key,
);
configuration.value.status_transitions.forEach((transition) => {
transition.target_keys = transition.target_keys.filter(
(key) => key !== item.configuration_key,
);
});
}
configuration.value[area].splice(index, 1);
reindex(area);
}
function move(area: TemplateListArea, index: number, offset: number): void {
const entries = configuration.value[area];
const next = index + offset;
if (next < 0 || next >= entries.length) {
return;
}
[entries[index], entries[next]] = [entries[next], entries[index]];
reindex(area);
}
function targets(statusKey: string): string[] {
return (
configuration.value.status_transitions.find(
(transition) => transition.status_key === statusKey,
)?.target_keys ?? []
);
}
function toggleTarget(from: string, to: string, checked: boolean): void {
let transition = configuration.value.status_transitions.find(
(entry) => entry.status_key === from,
);
if (!transition) {
transition = { status_key: from, target_keys: [] };
configuration.value.status_transitions.push(transition);
}
transition.target_keys = checked
? [...new Set([...transition.target_keys, to])]
: transition.target_keys.filter((key) => key !== to);
}
function filterChanged(filter: TemplateNotificationFilter): void {
if (['exists', 'not_exists'].includes(filter.operator)) {
filter.value = null;
} else if (filter.operator === 'in') {
filter.value = Array.isArray(filter.value)
? filter.value
: filter.value
? [filter.value]
: [];
} else if (Array.isArray(filter.value)) {
filter.value = filter.value[0] ?? '';
}
}
function mention(
item: TemplateConfigurationItem,
value: string,
checked: boolean,
): void {
const settings = (item.channel_configuration ??= { allowed_mentions: [] });
settings.allowed_mentions = checked
? [...new Set([...(settings.allowed_mentions ?? []), value])]
: (settings.allowed_mentions ?? []).filter((entry) => entry !== value);
}
function areaErrors(area: string): string[] {
return [
...new Set(
Object.entries(props.errors)
.filter(
([key]) =>
key === `configuration_snapshot.${area}` ||
key.startsWith(`configuration_snapshot.${area}.`),
)
.map(([, value]) => value)
.filter((value): value is string => !!value),
),
];
}
</script>
<template>
<fieldset class="gap-column grid min-w-0" :disabled="disabled">
<legend class="text-title mb-4">{{ t('templateEditor.title') }}</legend>
<p class="text-muted-foreground text-caption">
{{ t('templateEditor.help') }}
</p>
<details
v-if="areas.includes('project_settings')"
class="bg-card rounded-md border p-5"
open
>
<summary class="cursor-pointer font-medium">
{{ areaText('project_settings', 'label') }}
</summary>
<div class="mt-4 grid gap-4">
<Label class="flex items-center gap-3"
><Checkbox
v-model="configuration.project_settings.show_author"
/>{{ t('templateEditor.showAuthor') }}</Label
>
<Label class="flex items-center gap-3"
><Checkbox
v-model="
configuration.project_settings.show_story_points
"
/>{{ t('templateEditor.showPoints') }}</Label
>
<InputError
v-for="error in areaErrors('project_settings')"
:key="error"
:message="error"
/>
</div>
</details>
<details
v-for="area in listAreas"
:key="area"
class="bg-card min-w-0 rounded-md border p-5"
:open="
['board_columns', 'ticket_statuses', 'ticket_types'].includes(
area,
)
"
>
<summary class="cursor-pointer font-medium">
{{ areaText(area, 'label') }} ({{ configuration[area].length }})
</summary>
<div class="mt-4 grid min-w-0 gap-4">
<article
v-for="(item, index) in configuration[area]"
:key="item.configuration_key"
class="grid min-w-0 gap-4 rounded-md border p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-caption font-medium">{{
item.name ||
item.label ||
t('templateEditor.entry', { number: index + 1 })
}}</span>
<div class="flex gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:disabled="index === 0"
:aria-label="t('templateEditor.moveUp')"
@click="move(area, index, -1)"
><ArrowUp
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="
index === configuration[area].length - 1
"
:aria-label="t('templateEditor.moveDown')"
@click="move(area, index, 1)"
><ArrowDown
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="t('templateEditor.remove')"
@click="remove(area, index)"
><Trash2
/></Button>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div
v-for="field in fields(area)"
:key="field.key"
class="grid min-w-0 gap-2"
>
<Label
:for="`${item.configuration_key}-${field.key}`"
>{{ field.label }}</Label
>
<Input
:id="`${item.configuration_key}-${field.key}`"
v-model="item[field.key]"
required
/>
</div>
<div
v-if="area === 'ticket_statuses'"
class="grid gap-2"
>
<Label :for="`${item.configuration_key}-column`">{{
t('templateEditor.column')
}}</Label>
<select
:id="`${item.configuration_key}-column`"
v-model="item.board_column_key"
:class="selectClass"
@change="reindex('ticket_statuses')"
>
<option :value="null">
{{ t('templateEditor.backlog') }}
</option>
<option
v-for="column in configuration.board_columns"
:key="column.configuration_key"
:value="column.configuration_key"
>
{{ column.name }}
</option>
</select>
</div>
<Label
v-if="area === 'ticket_types'"
class="flex items-center gap-3"
><Checkbox v-model="item.is_parent" />{{
t('templateEditor.parentType')
}}</Label
>
<div v-if="area === 'ticket_fields'" class="grid gap-2">
<Label :for="`${item.configuration_key}-type`">{{
t('templateEditor.fieldType')
}}</Label>
<select
:id="`${item.configuration_key}-type`"
v-model="item.field_type"
:class="selectClass"
>
<option value="number">
{{ t('templateEditor.number') }}
</option>
<option value="user">
{{ t('templateEditor.user') }}
</option>
</select>
</div>
</div>
<template v-if="area === 'notification_actions'">
<Label class="flex items-center gap-3"
><Checkbox v-model="item.enabled" />{{
t('templateEditor.enabled')
}}</Label
>
<div class="grid gap-4 sm:grid-cols-2">
<Label class="grid gap-2"
>{{ t('templateEditor.event')
}}<select
v-model="item.event_type"
:class="selectClass"
>
<option
v-for="event in [
'ticket_status_changed',
'ticket_create',
'ticket_assignee_changed',
]"
:key="event"
:value="event"
>
{{
notificationLabel(
t,
'event',
event,
event,
)
}}
</option>
</select></Label
>
<Label class="grid gap-2"
>{{ t('templateEditor.channel')
}}<select
v-model="item.channel"
:class="selectClass"
>
<option value="webhook">
{{ t('notifications.channel.webhook') }}
</option>
<option value="discord">
{{ t('notifications.channel.discord') }}
</option>
</select></Label
>
<Label class="grid gap-2 sm:col-span-2"
>{{ t('templateEditor.deliveryUrl')
}}<Input
v-model="item.delivery_url"
required
type="url"
/></Label>
<Label class="grid gap-2 sm:col-span-2"
>{{ t('templateEditor.message')
}}<textarea
v-model="item.message_template"
:class="selectClass"
rows="4"
:required="item.channel === 'discord'"
maxlength="2000"
/>
</Label>
</div>
<fieldset
v-if="item.channel === 'discord'"
class="grid gap-3 rounded-md border p-3"
>
<legend>
{{ t('templateEditor.allowedMentions') }}
</legend>
<Label
v-for="value in ['everyone', 'roles', 'users']"
:key="value"
class="flex items-center gap-3"
>
<Checkbox
:model-value="
item.channel_configuration?.allowed_mentions?.includes(
value,
) ?? false
"
@update:model-value="
mention(item, value, $event === true)
"
/>{{
notificationLabel(
t,
'mention',
value,
value,
)
}}
</Label>
</fieldset>
<div class="grid gap-3">
<div
v-for="(filter, filterIndex) in item.filters"
:key="filterIndex"
class="grid gap-3 rounded-md border p-3 sm:grid-cols-2"
>
<Label class="grid gap-2"
>{{ t('notifications.attribute')
}}<select
v-model="filter.attribute"
@change="
filter.value =
filter.operator === 'in'
? []
: null
"
:class="selectClass"
>
<option
v-for="attribute in filterAttributes"
:key="attribute"
:value="attribute"
>
{{
notificationLabel(
t,
'attribute',
attribute,
attribute,
)
}}
</option>
</select></Label
>
<Label class="grid gap-2"
>{{ t('notifications.operator')
}}<select
v-model="filter.operator"
@change="filterChanged(filter)"
:class="selectClass"
>
<option
v-for="operator in filterOperators"
:key="operator"
:value="operator"
>
{{
notificationLabel(
t,
'operator',
operator,
operator,
)
}}
</option>
</select></Label
>
<Label
v-if="
!['exists', 'not_exists'].includes(
filter.operator,
)
"
class="grid gap-2"
>{{ t('notifications.value')
}}<Input
:model-value="
Array.isArray(filter.value)
? filter.value.join(', ')
: (filter.value ?? '')
"
@update:model-value="
filter.value = String($event)
"
/></Label>
<Button
type="button"
variant="outline"
@click="
item.filters?.splice(filterIndex, 1)
"
>{{
t('notifications.removeFilter')
}}</Button
>
</div>
<Button
type="button"
variant="outline"
@click="
(item.filters ??= []).push({
attribute: 'status.to.label',
operator: 'equals',
value: '',
})
"
><Plus />{{
t('notifications.addFilter')
}}</Button
>
</div>
</template>
</article>
<InputError
v-for="error in areaErrors(area)"
:key="error"
:message="error"
/>
<Button
type="button"
variant="outline"
class="justify-self-start"
@click="add(area)"
><Plus />{{ t('templateEditor.addEntry') }}</Button
>
</div>
</details>
<details
v-if="areas.includes('status_transitions')"
class="bg-card rounded-md border p-5"
open
>
<summary class="cursor-pointer font-medium">
{{ areaText('status_transitions', 'label') }}
</summary>
<div class="mt-4 grid gap-4">
<fieldset
v-for="status in configuration.ticket_statuses"
:key="status.configuration_key"
class="grid gap-3 rounded-md border p-4"
>
<legend class="px-1 font-medium">{{ status.name }}</legend>
<Label
v-for="target in configuration.ticket_statuses.filter(
(entry) =>
entry.configuration_key !==
status.configuration_key,
)"
:key="target.configuration_key"
class="flex items-center gap-3"
>
<Checkbox
:model-value="
targets(status.configuration_key).includes(
target.configuration_key,
)
"
@update:model-value="
toggleTarget(
status.configuration_key,
target.configuration_key,
$event === true,
)
"
/>{{ target.name }}
</Label>
</fieldset>
<InputError
v-for="error in areaErrors('status_transitions')"
:key="error"
:message="error"
/>
</div>
</details>
</fieldset>
</template>
@@ -19,6 +19,7 @@ import {
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import type { TemplateResetForm } from '@/types/project-template-configuration';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types'; import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
const props = defineProps<{ const props = defineProps<{
@@ -27,10 +28,7 @@ const props = defineProps<{
templateState?: ProjectTemplateState | null; templateState?: ProjectTemplateState | null;
}>(); }>();
const { t } = useLocalization(); const { t } = useLocalization();
const form = useForm<{ const form = useForm<TemplateResetForm>({
areas: string[];
status_replacements: Record<string, number | undefined>;
}>({
areas: [], areas: [],
status_replacements: {}, status_replacements: {},
}); });
@@ -245,6 +243,15 @@ function resetArea(): void {
v-if="statusRequirement" v-if="statusRequirement"
class="gap-column grid min-w-0" class="gap-column grid min-w-0"
> >
<p
v-if="
statusRequirement.replacementOptions
.length === 0
"
class="text-destructive text-sm"
>
{{ t('inheritance.noReplacementTargets') }}
</p>
<p class="text-caption text-muted-foreground"> <p class="text-caption text-muted-foreground">
{{ t('inheritance.statusHelp') }} {{ t('inheritance.statusHelp') }}
</p> </p>
@@ -0,0 +1,21 @@
import type { TemplateConfiguration } from '@/types/project-template-configuration';
export function createTemplateConfiguration(
snapshot?: Record<string, unknown>,
): TemplateConfiguration {
return JSON.parse(
JSON.stringify({
project_settings: { show_author: true, show_story_points: true },
board_columns: [],
ticket_statuses: [],
status_transitions: [],
ticket_types: [],
ticket_priorities: [],
ticket_fields: [],
ticket_link_types: [],
repository_connections: [],
notification_actions: [],
...snapshot,
}),
) as TemplateConfiguration;
}
+42 -6
View File
@@ -248,6 +248,38 @@ export const en = {
'Previous assignee name when the event updates assignment; blank otherwise.', 'Previous assignee name when the event updates assignment; blank otherwise.',
'notifications.variable.assignee_to': 'notifications.variable.assignee_to':
'New assignee name when the event updates assignment; blank when unassigned.', 'New assignee name when the event updates assignment; blank when unassigned.',
'templateEditor.title': 'Template configuration',
'templateEditor.help':
'Changes apply to linked projects that still inherit each group. Used entries remain protected as local overrides.',
'templateEditor.name': 'Name',
'templateEditor.label': 'Label',
'templateEditor.counterpart': 'Reverse label',
'templateEditor.server': 'Server URL',
'templateEditor.repositoryId': 'Repository ID',
'templateEditor.owner': 'Owner',
'templateEditor.repositoryName': 'Repository name',
'templateEditor.fullName': 'Owner / repository',
'templateEditor.repositoryUrl': 'Repository URL',
'templateEditor.entry': 'Entry {number}',
'templateEditor.moveUp': 'Move up',
'templateEditor.moveDown': 'Move down',
'templateEditor.remove': 'Remove entry',
'templateEditor.addEntry': 'Add entry',
'templateEditor.showAuthor': 'Show ticket author',
'templateEditor.showPoints': 'Show story points',
'templateEditor.column': 'Board column',
'templateEditor.backlog': 'Backlog (no board column)',
'templateEditor.parentType': 'Allows child tickets',
'templateEditor.fieldType': 'Field type',
'templateEditor.number': 'Number',
'templateEditor.user': 'User',
'templateEditor.allowedMentions': 'Allowed Discord mentions',
'templateEditor.enabled': 'Enabled',
'templateEditor.event': 'Event',
'templateEditor.channel': 'Channel',
'templateEditor.deliveryUrl': 'Delivery URL',
'templateEditor.message': 'Message template',
'templates.createEmpty': 'New empty template',
'templates.description': 'templates.description':
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.', 'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
'templates.updated': 'Project templates updated.', 'templates.updated': 'Project templates updated.',
@@ -264,7 +296,7 @@ export const en = {
other: '{count} linked projects', other: '{count} linked projects',
}, },
'templates.savedFrom': 'Saved from {project} ({key}).', 'templates.savedFrom': 'Saved from {project} ({key}).',
'templates.sourceUnavailable': 'Source project unavailable', 'templates.sourceUnavailable': 'Independent template',
'templates.edit': 'Edit', 'templates.edit': 'Edit',
'templates.use': 'Use template', 'templates.use': 'Use template',
'templates.createTitle': 'Save {project} as a template', 'templates.createTitle': 'Save {project} as a template',
@@ -272,7 +304,7 @@ export const en = {
'Choose the configuration to reuse in future projects.', 'Choose the configuration to reuse in future projects.',
'templates.editTitle': 'Edit {name}', 'templates.editTitle': 'Edit {name}',
'templates.editDescription': 'templates.editDescription':
'Refresh the template from its source project and review the impact on linked projects.', 'Edit the template configuration and review the impact on linked projects.',
'templates.defaultName': '{project} template', 'templates.defaultName': '{project} template',
'templates.details': 'Template details', 'templates.details': 'Template details',
'templates.name': 'Template name', 'templates.name': 'Template name',
@@ -285,13 +317,15 @@ export const en = {
'The slug is locked so existing template references remain stable.', 'The slug is locked so existing template references remain stable.',
'templates.groups': 'Configuration groups', 'templates.groups': 'Configuration groups',
'templates.createGroupsHelp': 'templates.createGroupsHelp':
'Only selected groups are saved. Related groups required by the workflow are included automatically.', 'Only selected groups are saved. Statuses, columns and transitions inherit independently.',
'templates.editGroupsHelp': 'templates.editGroupsHelp':
'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.', 'Edit the selected groups directly. Linked projects keep their existing local overrides.',
'templates.impact': 'Template impact', 'templates.impact': 'Template impact',
'templates.inheritance': 'Inheritance', 'templates.inheritance': 'Inheritance',
'templates.emptyStart':
'Templates contain configuration only. New projects start with a clean ticket history.',
'templates.cleanStart': 'templates.cleanStart':
'Tickets and other operational ticket data are never copied. The source project stays unchanged.', 'Tickets are never copied. Saving a project as a template links that project to the new template.',
'templates.refreshHelp': 'templates.refreshHelp':
'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.', 'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.',
'templates.linkedWarning': 'Linked projects will be affected', 'templates.linkedWarning': 'Linked projects will be affected',
@@ -1331,8 +1365,10 @@ export const en = {
'inheritance.resetWarning': 'inheritance.resetWarning':
'Local configuration in this area may be replaced or removed.', 'Local configuration in this area may be replaced or removed.',
'inheritance.statusHelp': 'inheritance.statusHelp':
'Choose where tickets in local statuses should move before those statuses are removed.', 'Choose a replacement for each used local status. Its tickets and local status transitions move to that replacement.',
'inheritance.statusLabel': '{status} ({count} tickets)', 'inheritance.statusLabel': '{status} ({count} tickets)',
'inheritance.noReplacementTargets':
'Add a status to the template before resetting used local statuses.',
'inheritance.selectStatus': 'Select replacement status', 'inheritance.selectStatus': 'Select replacement status',
'inheritance.area.projectSettings': 'Project settings', 'inheritance.area.projectSettings': 'Project settings',
'inheritance.area.boardColumns': 'Board columns', 'inheritance.area.boardColumns': 'Board columns',
@@ -6,31 +6,58 @@ import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects'; import { index, show } from '@/routes/projects';
import { create } from '@/routes/projects/templates'; import { create } from '@/routes/projects/templates';
import {
create as createEmpty,
index as templatesIndex,
} from '@/routes/project-templates';
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
const props = defineProps<ProjectTemplatesCreatePageProps>(); const props = defineProps<ProjectTemplatesCreatePageProps>();
const { t } = useLocalization(); const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [ const breadcrumbs = computed<BreadcrumbItem[]>(() =>
{ title: t('navigation.projects'), href: index() }, props.project
{ title: props.project.name, href: show(props.project.id) }, ? [
{ title: t('projects.saveTemplate'), href: create(props.project.id) }, { title: t('navigation.projects'), href: index() },
]); { title: props.project.name, href: show(props.project.id) },
{
title: t('projects.saveTemplate'),
href: create(props.project.id),
},
]
: [
{ title: t('navigation.templates'), href: templatesIndex() },
{ title: t('templates.createEmpty'), href: createEmpty() },
],
);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('templates.createTitle', { project: project.name })" /> <Head
:title="
project
? t('templates.createTitle', { project: project.name })
: t('templates.createEmpty')
"
/>
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl"> <div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
<header class="gap-stack grid"> <header class="gap-stack grid">
<h1 class="text-headline-mobile md:text-headline break-words"> <h1 class="text-headline-mobile md:text-headline break-words">
{{ t('templates.createTitle', { project: project.name }) }} {{
project
? t('templates.createTitle', {
project: project.name,
})
: t('templates.createEmpty')
}}
</h1> </h1>
<p class="text-muted-foreground"> <p class="text-muted-foreground">
{{ t('templates.createDescription') }} {{ t('templates.createDescription') }}
</p> </p>
</header> </header>
<ProjectTemplateForm <ProjectTemplateForm
:key="project.id" :key="project?.id ?? 'empty'"
:project="project" :project="project"
:initial-configuration="initialConfiguration"
:copy-area-options="copyAreaOptions" :copy-area-options="copyAreaOptions"
/> />
</div> </div>
@@ -18,7 +18,7 @@ import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas'; import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { destroy, edit, index } from '@/routes/project-templates'; import { destroy, edit, index, create } from '@/routes/project-templates';
import { import {
create as createProject, create as createProject,
index as projectsIndex, index as projectsIndex,
@@ -80,9 +80,9 @@ function removeTemplate(): void {
</p> </p>
</div> </div>
<Button as-child class="shrink-0" <Button as-child class="shrink-0"
><Link :href="createProject()" ><Link :href="create()"
><Plus aria-hidden="true" />{{ ><Plus aria-hidden="true" />{{
t('projects.create') t('templates.createEmpty')
}}</Link }}</Link
></Button ></Button
> >
+4 -1
View File
@@ -88,7 +88,10 @@ const settingsItems = computed(() =>
</p> </p>
</div> </div>
<Button <Button
v-if="$page.props.auth.permissions.projectCreate" v-if="
$page.props.auth.permissions.projectCreate &&
canManageAccess
"
as-child as-child
variant="outline" variant="outline"
class="self-start" class="self-start"
+5 -2
View File
@@ -67,7 +67,8 @@ export type ProjectTemplatesIndexPageProps = {
export type ProjectTemplatesCreatePageProps = { export type ProjectTemplatesCreatePageProps = {
copyAreaOptions: ProjectDuplicateCopyAreaOption[]; copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project: ProjectModel; project: ProjectModel | null;
initialConfiguration?: Record<string, unknown>;
}; };
export type ProjectTemplatesEditPageProps = { export type ProjectTemplatesEditPageProps = {
@@ -82,6 +83,8 @@ export type ProjectTemplateStateArea = {
export type ProjectTemplateState = { export type ProjectTemplateState = {
effectiveConfiguration: Record<string, unknown>; effectiveConfiguration: Record<string, unknown>;
localConfiguration?: Record<string, unknown>;
localAreas?: ProjectTemplateStateArea[];
inheritedAreas: ProjectTemplateStateArea[]; inheritedAreas: ProjectTemplateStateArea[];
inheritedConfiguration?: Record<string, unknown>; inheritedConfiguration?: Record<string, unknown>;
inheritsFromTemplate: boolean; inheritsFromTemplate: boolean;
@@ -95,7 +98,7 @@ export type ProjectTemplateState = {
name: string; name: string;
ticketCount: number; ticketCount: number;
}>; }>;
replacementOptions: Array<{ id: number; name: string }>; replacementOptions: Array<{ id: number | string; name: string }>;
} }
>; >;
templateConfiguration?: Record<string, unknown>; templateConfiguration?: Record<string, unknown>;
@@ -0,0 +1,77 @@
import type { ProjectDuplicateCopyAreaOption } from '@/types/page-props';
export type TemplateNotificationFilter = {
attribute: string;
operator: string;
value: string | string[] | null;
};
export type TemplateConfigurationItem = {
configuration_key: string;
name?: string;
label?: string;
counterpart_label?: string;
position?: number;
is_parent?: boolean;
field_type?: string;
board_column_key?: string | null;
provider?: string;
base_url?: string;
provider_repository_id?: string;
owner_name?: string;
repository_name?: string;
repository_full_name?: string;
repository_url?: string;
enabled?: boolean;
event_type?: string;
channel?: string;
channel_configuration?: { allowed_mentions?: string[] };
message_template?: string | null;
delivery_url?: string;
filters?: TemplateNotificationFilter[];
};
export type TemplateStatusTransition = {
status_key: string;
target_keys: string[];
};
export type TemplateConfiguration = {
project_settings: { show_author: boolean; show_story_points: boolean };
board_columns: TemplateConfigurationItem[];
ticket_statuses: TemplateConfigurationItem[];
status_transitions: TemplateStatusTransition[];
ticket_types: TemplateConfigurationItem[];
ticket_priorities: TemplateConfigurationItem[];
ticket_fields: TemplateConfigurationItem[];
ticket_link_types: TemplateConfigurationItem[];
repository_connections: TemplateConfigurationItem[];
notification_actions: TemplateConfigurationItem[];
};
export type TemplateListArea = Exclude<
keyof TemplateConfiguration,
'project_settings' | 'status_transitions'
>;
export type TemplateTextField = {
key:
| 'name'
| 'label'
| 'counterpart_label'
| 'base_url'
| 'provider_repository_id'
| 'owner_name'
| 'repository_name'
| 'repository_full_name'
| 'repository_url'
| 'delivery_url';
label: string;
};
export type TemplateConfigurationEditorProps = {
areas: string[];
options: ProjectDuplicateCopyAreaOption[];
errors: Record<string, string | undefined>;
disabled?: boolean;
};
export type TemplateResetForm = {
areas: string[];
status_replacements: Record<string, number | string | undefined>;
};
+2
View File
@@ -7,6 +7,8 @@ Route::middleware('can:create-project')
->prefix('project-templates') ->prefix('project-templates')
->name('project-templates.') ->name('project-templates.')
->group(function (): void { ->group(function (): void {
Route::get('create', [ProjectTemplateController::class, 'createEmpty'])->name('create');
Route::post('/', [ProjectTemplateController::class, 'storeEmpty'])->name('store');
Route::get('/', [ProjectTemplateController::class, 'index'])->name('index'); Route::get('/', [ProjectTemplateController::class, 'index'])->name('index');
Route::get('{projectTemplate}/edit', [ProjectTemplateController::class, 'edit'])->name('edit'); Route::get('{projectTemplate}/edit', [ProjectTemplateController::class, 'edit'])->name('edit');
Route::patch('{projectTemplate}', [ProjectTemplateController::class, 'update'])->name('update'); Route::patch('{projectTemplate}', [ProjectTemplateController::class, 'update'])->name('update');
+1 -1
View File
@@ -20,7 +20,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
Route::get('board', [ProjectBoardController::class, 'index'])->name('board.index'); Route::get('board', [ProjectBoardController::class, 'index'])->name('board.index');
Route::get('backlog', [ProjectController::class, 'backlog'])->name('backlog.index'); Route::get('backlog', [ProjectController::class, 'backlog'])->name('backlog.index');
Route::middleware('can:create-project')->group(function (): void { Route::middleware(['can:create-project', 'can:admin-project,project'])->group(function (): void {
Route::get('templates/create', [ProjectTemplateController::class, 'create'])->name('templates.create'); Route::get('templates/create', [ProjectTemplateController::class, 'create'])->name('templates.create');
Route::post('templates', [ProjectTemplateController::class, 'store'])->name('templates.store'); Route::post('templates', [ProjectTemplateController::class, 'store'])->name('templates.store');
}); });
@@ -31,6 +31,7 @@ class DiscordLoggerTest extends TestCase
'logging.channels.discord_errors.url' => 'https://discord.example.test/webhook', 'logging.channels.discord_errors.url' => 'https://discord.example.test/webhook',
'logging.channels.discord_errors.mention' => '@here', 'logging.channels.discord_errors.mention' => '@here',
'logging.channels.discord_errors.title' => '## KanbanGit error', 'logging.channels.discord_errors.title' => '## KanbanGit error',
'logging.channels.discord_errors.max_trace_lines' => 2,
]); ]);
Log::forgetChannel('discord_errors'); Log::forgetChannel('discord_errors');
@@ -38,7 +38,7 @@ class ProjectTemplateControllerTest extends TestCase
]); ]);
$this->syncGlobalPermissions($user, ['project:create']); $this->syncGlobalPermissions($user, ['project:create']);
$this->syncProjectPermissions($project, $user, ['view']); $this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user) $this->actingAs($user)
->get('/project-templates') ->get('/project-templates')
@@ -79,7 +79,7 @@ class ProjectTemplateControllerTest extends TestCase
]); ]);
$this->syncGlobalPermissions($user, ['project:create']); $this->syncGlobalPermissions($user, ['project:create']);
$this->syncProjectPermissions($project, $user, ['view']); $this->syncProjectPermissions($project, $user, ['admin']);
$project->ticketTypes()->delete(); $project->ticketTypes()->delete();
$project->ticketStatuses()->delete(); $project->ticketStatuses()->delete();
@@ -119,19 +119,8 @@ class ProjectTemplateControllerTest extends TestCase
$this->assertTrue($template->is_published); $this->assertTrue($template->is_published);
$this->assertNull($template->project_id); $this->assertNull($template->project_id);
$this->assertSame([ $this->assertSame(app(DuplicateProjectConfiguration::class)->export($project, $template->copy_areas), $template->configuration_snapshot);
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ $this->assertSame($template->id, $project->fresh()->project_template_id);
'show_author' => false,
'show_story_points' => false,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
],
],
], $template->configuration_snapshot);
$this->actingAs($user) $this->actingAs($user)
->delete(route('project-templates.destroy', $template)) ->delete(route('project-templates.destroy', $template))
@@ -271,6 +260,11 @@ class ProjectTemplateControllerTest extends TestCase
], ],
])->saveQuietly(); ])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($inheritingProject, $template->configuration_snapshot, adoptExisting: true);
$nextConfiguration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($template->configuration_snapshot);
$nextConfiguration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
$nextConfiguration['ticket_types'][0]['name'] = 'Feature';
$sourceProject->forceFill([ $sourceProject->forceFill([
'show_author' => true, 'show_author' => true,
'show_story_points' => true, 'show_story_points' => true,
@@ -281,6 +275,7 @@ class ProjectTemplateControllerTest extends TestCase
$this->actingAs($user) $this->actingAs($user)
->patch(route('project-templates.update', $template), [ ->patch(route('project-templates.update', $template), [
'name' => 'Platform Template v2', 'name' => 'Platform Template v2',
'configuration_snapshot' => $nextConfiguration,
'slug' => 'should-not-change', 'slug' => 'should-not-change',
'copy_areas' => [ 'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
@@ -296,19 +291,7 @@ class ProjectTemplateControllerTest extends TestCase
$this->assertSame('Platform Template v2', $template->name); $this->assertSame('Platform Template v2', $template->name);
$this->assertSame('platform-template', $template->slug); $this->assertSame('platform-template', $template->slug);
$this->assertSame([ $this->assertSame($nextConfiguration, $template->configuration_snapshot);
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Feature',
'position' => 1,
'is_parent' => false,
],
],
], $template->configuration_snapshot);
$this->assertTrue($inheritingProject->show_author); $this->assertTrue($inheritingProject->show_author);
$this->assertTrue($inheritingProject->show_story_points); $this->assertTrue($inheritingProject->show_story_points);
@@ -0,0 +1,628 @@
<?php
namespace Tests\Feature\Projects;
use App\Actions\Notifications\MatchNotificationFilters;
use App\Actions\Notifications\TicketStatusChangedPayload;
use App\Actions\ProjectBoard\DeleteTicketStatus;
use App\Actions\ProjectBoard\StoreTicketStatus;
use App\Actions\ProjectBoard\UpdateBoardColumn;
use App\Actions\Projects\CreateProject;
use App\Actions\Projects\CreateProjectTemplate;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Actions\Projects\ResolveProjectTemplateState;
use App\Actions\Projects\UpdateProjectTemplate;
use App\Actions\Projects\UpdateProjectTemplateLink;
use App\Models\BoardColumn;
use App\Models\NotificationAction;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\RepositoryConnection;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
use App\Models\TicketLink;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Tests\TestCase;
class ProjectTemplateIdentityTest extends TestCase
{
use RefreshDatabase;
public function test_deleting_an_earlier_template_status_preserves_ticket_meaning(): void
{
$source = Project::factory()->create();
$todo = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1]);
TicketStatus::factory()->for($source)->create(['name' => 'Doing', 'position' => 2]);
TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 3]);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$ticket = $this->ticket($project, $project->ticketStatuses()->where('name', 'Doing')->firstOrFail());
app(DeleteTicketStatus::class)->handle($source, $todo);
$this->refreshTemplate($template);
$this->assertSame('Doing', $ticket->fresh()->ticketStatus->name, 'Deleting Todo made a Doing ticket Done.');
}
public function test_inherited_transitions_map_to_correct_statuses_after_local_insertion(): void
{
$source = Project::factory()->create();
$first = BoardColumn::factory()->for($source)->create(['name' => 'First', 'position' => 1]);
$second = BoardColumn::factory()->for($source)->create(['name' => 'Second', 'position' => 2]);
$todo = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1, 'board_column_id' => $first->id]);
$done = TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 1, 'board_column_id' => $second->id]);
$template = $this->template($source, ['board_columns', 'ticket_statuses', 'status_transitions']);
$project = $this->linkedProject($template);
app(StoreTicketStatus::class)->handle($project, ['name' => 'Local', 'board_column_id' => $project->boardColumns()->where('name', 'First')->firstOrFail()->id]);
$todo->allowedTargetStatuses()->sync([$done->id]);
$this->refreshTemplate($template);
$projectTodo = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail();
$this->assertSame(['Done'], $projectTodo->allowedTargetStatuses()->pluck('name')->all(), 'Template transition targeted Local instead of Done.');
}
public function test_effective_configuration_includes_local_areas_absent_from_template(): void
{
$source = Project::factory()->create();
$template = $this->template($source, ['project_settings']);
$project = $this->linkedProject($template);
TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]);
$state = app(ResolveProjectTemplateState::class)->handle($project->fresh());
$this->assertArrayHasKey('ticket_types', $state['effectiveConfiguration']);
}
public function test_private_baseline_tracks_column_reordering(): void
{
$project = app(CreateProject::class)->handle(['name' => 'Private', 'key' => 'PRIV'], User::factory()->create());
$first = BoardColumn::factory()->for($project)->create(['name' => 'First', 'position' => 1]);
$second = BoardColumn::factory()->for($project)->create(['name' => 'Second', 'position' => 2]);
app(UpdateBoardColumn::class)->handle($project, $second, ['name' => 'Second', 'position' => 1]);
$actual = app(DuplicateProjectConfiguration::class)->export($project->fresh(), ['board_columns']);
$snapshot = $project->fresh()->projectTemplate->configuration_snapshot;
$this->assertSame($actual['board_columns'], $snapshot['board_columns']);
}
public function test_used_local_status_in_earlier_column_requires_explicit_reset_replacement(): void
{
$source = Project::factory()->create();
$first = BoardColumn::factory()->for($source)->create(['name' => 'First', 'position' => 1]);
$second = BoardColumn::factory()->for($source)->create(['name' => 'Second', 'position' => 2]);
TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1, 'board_column_id' => $first->id]);
TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 1, 'board_column_id' => $second->id]);
$template = $this->template($source, ['board_columns', 'ticket_statuses']);
$project = $this->linkedProject($template);
$local = app(StoreTicketStatus::class)->handle($project, ['name' => 'Local', 'board_column_id' => $project->boardColumns()->where('name', 'First')->firstOrFail()->id]);
$this->ticket($project, $local);
$user = User::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)->post(route('projects.settings.general.reset-template', $project), [
'areas' => ['ticket_statuses'],
])->assertSessionHasErrors('status_replacements.'.$local->id);
}
public function test_template_remains_editable_after_source_project_deletion(): void
{
$source = Project::factory()->create();
$template = $this->template($source, ['project_settings']);
$source->delete();
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->actingAs($user)->patch(route('project-templates.update', $template), [
'name' => 'Renamed',
'copy_areas' => ['project_settings'],
])->assertRedirect(route('project-templates.index'));
}
public function test_status_rename_propagates_stable_slug_behavior(): void
{
$source = Project::factory()->create();
$status = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1]);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$status->update(['name' => 'Ready']);
$this->refreshTemplate($template);
$this->assertSame($status->fresh()->slug, $project->ticketStatuses()->firstOrFail()->slug);
}
public function test_local_column_reorder_does_not_remap_inherited_status_column(): void
{
$source = Project::factory()->create();
$first = BoardColumn::factory()->for($source)->create(['name' => 'First', 'position' => 1]);
BoardColumn::factory()->for($source)->create(['name' => 'Second', 'position' => 2]);
$status = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1, 'board_column_id' => $first->id]);
$template = $this->template($source, ['board_columns', 'ticket_statuses']);
$project = $this->linkedProject($template);
$second = $project->boardColumns()->where('name', 'Second')->firstOrFail();
app(UpdateBoardColumn::class)->handle($project, $second, ['name' => 'Second', 'position' => 1]);
$status->update(['name' => 'Ready']);
$this->refreshTemplate($template);
$this->assertSame('First', $project->ticketStatuses()->firstOrFail()->boardColumn->name);
}
public function test_empty_template_and_project_creation_work_without_a_source_project(): void
{
$this->withoutVite();
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->actingAs($user)->get(route('project-templates.create'))->assertOk();
$this->post(route('project-templates.store'), [
'name' => 'Empty template', 'slug' => 'empty-template',
'copy_areas' => DuplicateProjectConfiguration::copyableAreas(),
])->assertSessionHasNoErrors()->assertRedirect();
$template = ProjectTemplate::query()->where('slug', 'empty-template')->firstOrFail();
$this->assertNull($template->source_project_id);
$project = app(CreateProject::class)->handle([
'name' => 'From empty template', 'key' => 'EMPTY',
'creation_mode' => 'template', 'source_template_id' => $template->id,
], $user);
$this->assertSame(['Task', 'Release'], $project->ticketTypes()->pluck('name')->all());
$this->assertSame(['Medium'], $project->ticketPriorities()->pluck('name')->all());
$this->assertSame(['Backlog'], $project->ticketStatuses()->pluck('name')->all());
$this->assertSame([], $project->template_overrides);
$this->assertSame(0, $project->tickets()->count());
}
public function test_publishing_private_template_keeps_its_identity_and_links_the_source(): void
{
$project = app(CreateProject::class)->handle(['name' => 'Original', 'key' => 'ORIG'], User::factory()->create());
$base = $project->projectTemplate;
$status = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $status);
$template = app(CreateProjectTemplate::class)->handle($project, [
'name' => 'Published', 'slug' => $base->slug, 'copy_areas' => DuplicateProjectConfiguration::copyableAreas(),
]);
$this->assertSame($base->id, $template->id);
$this->assertSame($base->slug, $template->slug);
$this->assertTrue($template->is_published);
$this->assertNull($template->project_id);
$this->assertSame($template->id, $project->fresh()->project_template_id);
$configuration = $template->configuration_snapshot;
$configuration['ticket_statuses'][0]['name'] = 'Ready';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
$this->assertSame('Ready', $ticket->fresh()->ticketStatus->name);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
}
public function test_read_only_project_users_cannot_publish_and_relink_the_project(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)->post(route('projects.templates.store', $project), [
'name' => 'Forbidden', 'slug' => 'forbidden', 'copy_areas' => ['project_settings'],
])->assertForbidden();
$this->assertNull($project->fresh()->project_template_id);
}
public function test_name_only_update_keeps_the_snapshot_even_when_the_source_changed(): void
{
$source = Project::factory()->create();
$status = TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
$snapshot = $template->configuration_snapshot;
$status->update(['name' => 'Source change']);
app(UpdateProjectTemplate::class)->handle($template, ['name' => 'Only renamed']);
$this->assertSame($snapshot, $template->fresh()->configuration_snapshot);
$this->assertSame('Todo', $ticket->fresh()->ticketStatus->name);
}
public function test_referenced_template_status_removal_keeps_the_project_area_until_explicit_reset(): void
{
$this->withoutVite();
$user = User::factory()->create();
$configuration = app(ProjectConfigurationIdentity::class)->defaults();
$configuration['ticket_statuses'][] = ['configuration_key' => (string) Str::uuid(), 'name' => 'Done', 'position' => 2, 'board_column_key' => null];
$template = app(CreateProjectTemplate::class)->empty(['name' => 'Protected', 'slug' => 'protected', 'copy_areas' => array_keys($configuration), 'configuration_snapshot' => $configuration]);
$project = $this->linkedProject($template);
$todo = $project->ticketStatuses()->where('name', 'Backlog')->firstOrFail();
$ticket = Ticket::factory()->create(['project_id' => $project->id, 'ticket_status_id' => $todo->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id]);
$next = $configuration;
$next['ticket_statuses'] = [$configuration['ticket_statuses'][1]];
$next['ticket_statuses'][0]['position'] = 1;
$next['status_transitions'] = [];
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('Backlog', $ticket->fresh()->ticketStatus->name);
$this->assertContains('ticket_statuses', $project->fresh()->template_overrides);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)->post(route('projects.settings.general.reset-template', $project), ['areas' => ['ticket_statuses']])->assertSessionHasErrors('status_replacements.'.$todo->id);
$this->post(route('projects.settings.general.reset-template', $project), ['areas' => ['ticket_statuses'], 'status_replacements' => [$todo->id => $next['ticket_statuses'][0]['configuration_key']]])->assertSessionHasNoErrors();
$this->assertSame('Done', $ticket->fresh()->ticketStatus->name);
$this->assertNotContains('ticket_statuses', $project->fresh()->template_overrides);
}
public function test_reset_can_create_a_new_template_status_and_use_it_as_replacement(): void
{
$this->withoutVite();
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$status = $project->ticketStatuses()->firstOrFail();
$status->update(['name' => 'Local']);
$ticket = $this->ticket($project, $status);
$newKey = (string) Str::uuid();
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => ['ticket_statuses' => [['configuration_key' => $newKey, 'name' => 'New template status', 'board_column_key' => null, 'position' => 1]]]]);
$user = User::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)->post(route('projects.settings.general.reset-template', $project), ['areas' => ['ticket_statuses'], 'status_replacements' => [$status->id => $newKey]])->assertSessionHasNoErrors();
$this->assertSame('New template status', $ticket->fresh()->ticketStatus->name);
$this->assertSame($newKey, $ticket->fresh()->ticketStatus->configuration_key);
}
public function test_field_reordering_preserves_values_and_type_changes_with_values_stay_local(): void
{
$source = Project::factory()->create();
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Estimate', 'field_type' => 'number', 'position' => 1]);
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Cost', 'field_type' => 'number', 'position' => 2]);
$template = $this->template($source, ['ticket_fields']);
$project = $this->linkedProject($template);
$field = $project->ticketFieldDefinitions()->where('label', 'Estimate')->firstOrFail();
$status = TicketStatus::factory()->for($project)->create();
$ticket = $this->ticket($project, $status);
$value = TicketFieldValue::factory()->create(['ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8]);
$next = $template->configuration_snapshot;
$next['ticket_fields'] = array_reverse($next['ticket_fields']);
$next['ticket_fields'][0]['position'] = 1;
$next['ticket_fields'][1]['position'] = 2;
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('Estimate', $value->fresh()->ticketFieldDefinition->label);
$this->assertSame($field->id, $value->fresh()->ticket_field_definition_id);
$next['ticket_fields'][1]['field_type'] = 'user';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('number', $field->fresh()->field_type);
$this->assertEquals(8, $value->fresh()->value_number);
$this->assertContains('ticket_fields', $project->fresh()->template_overrides);
}
public function test_template_payload_rejects_duplicate_keys_foreign_references_and_unknown_fields(): void
{
$this->withoutVite();
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$configuration = app(ProjectConfigurationIdentity::class)->defaults();
$configuration['ticket_types'][1]['configuration_key'] = $configuration['ticket_types'][0]['configuration_key'];
$configuration['ticket_statuses'][0]['board_column_key'] = (string) Str::uuid();
$configuration['ticket_priorities'][0]['project_id'] = 999;
$this->actingAs($user)->post(route('project-templates.store'), ['name' => 'Invalid', 'slug' => 'invalid', 'copy_areas' => array_keys($configuration), 'configuration_snapshot' => $configuration])->assertSessionHasErrors([
'configuration_snapshot.ticket_types.0.configuration_key', 'configuration_snapshot.ticket_priorities.0',
]);
unset($configuration['ticket_priorities'][0]['project_id']);
$configuration['ticket_types'][1]['configuration_key'] = (string) Str::uuid();
$this->post(route('project-templates.store'), ['name' => 'Invalid', 'slug' => 'invalid', 'copy_areas' => array_keys($configuration), 'configuration_snapshot' => $configuration])->assertSessionHasErrors('configuration_snapshot.ticket_statuses.0.board_column_key');
$this->assertSame(0, ProjectTemplate::query()->where('slug', 'invalid')->count());
}
public function test_identity_migration_keeps_existing_tickets_values_links_and_overrides(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Estimate', 'field_type' => 'number']);
$template = $this->template($source, ['ticket_statuses', 'ticket_fields']);
$project = $this->linkedProject($template);
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
$field = $project->ticketFieldDefinitions()->firstOrFail();
$value = TicketFieldValue::factory()->create(['ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8]);
$statusId = $ticket->ticket_status_id;
$overrides = $project->fresh()->template_overrides;
$migration = require database_path('migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php');
$migration->down();
$legacy = $template->fresh()->configuration_snapshot;
$this->assertArrayNotHasKey('configuration_key', $legacy['ticket_fields'][0]);
$migration->up();
$this->assertSame($template->id, $project->fresh()->project_template_id);
$this->assertSame($statusId, $ticket->fresh()->ticket_status_id);
$this->assertSame($field->id, $value->fresh()->ticket_field_definition_id);
$this->assertEquals(8, $value->fresh()->value_number);
$this->assertSame($overrides, $project->fresh()->template_overrides);
$snapshot = $template->fresh()->configuration_snapshot;
$this->assertSame($snapshot['ticket_statuses'][0]['configuration_key'], $ticket->fresh()->ticketStatus->configuration_key);
$this->assertSame($snapshot['ticket_fields'][0]['configuration_key'], $field->fresh()->configuration_key);
}
public function test_status_reset_transfers_local_transitions_and_preserves_their_override(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1]);
TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 2]);
$template = $this->template($source, ['ticket_statuses', 'status_transitions']);
$project = $this->linkedProject($template);
$todo = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail();
$done = $project->ticketStatuses()->where('name', 'Done')->firstOrFail();
$local = TicketStatus::factory()->for($project)->create(['name' => 'Local', 'position' => 3]);
$todo->allowedTargetStatuses()->sync([$local->id]);
$local->allowedTargetStatuses()->sync([$done->id]);
$project->fresh()->forceFill(['template_overrides' => ['ticket_statuses', 'status_transitions']])->saveQuietly();
$ticket = $this->ticket($project, $local);
$expectedOverrides = array_values(array_diff($project->fresh()->template_overrides, ['ticket_statuses']));
app(UpdateProjectTemplateLink::class)->reset($project->fresh(), ['ticket_statuses'], [$local->id => $todo->configuration_key]);
$this->assertSame($todo->id, $ticket->fresh()->ticket_status_id);
$this->assertSame([$done->id], $todo->allowedTargetStatuses()->pluck('ticket_statuses.id')->all());
$this->assertSame($expectedOverrides, $project->fresh()->template_overrides);
}
public function test_deleting_a_template_creates_private_baselines_without_recreating_configuration(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$template = app(CreateProjectTemplate::class)->empty(['name' => 'Shared', 'slug' => 'shared', 'copy_areas' => DuplicateProjectConfiguration::copyableAreas()]);
$project = $this->linkedProject($template);
$otherProject = $this->linkedProject($template);
$status = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $status);
$snapshot = app(DuplicateProjectConfiguration::class)->export($project, DuplicateProjectConfiguration::copyableAreas());
$this->actingAs($user)->delete(route('project-templates.destroy', $template))->assertRedirect();
$base = $project->fresh()->projectTemplate;
$this->assertNotNull($base);
$this->assertFalse($base->is_published);
$this->assertSame($project->id, $base->project_id);
$this->assertSame($snapshot, $base->configuration_snapshot);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
$this->assertFalse($otherProject->fresh()->projectTemplate->is_published);
$this->assertNotSame($base->id, $otherProject->fresh()->project_template_id);
}
public function test_private_base_slug_does_not_collide_with_published_template_slugs(): void
{
ProjectTemplate::factory()->create(['slug' => 'orig-base-template']);
$project = app(CreateProject::class)->handle(['name' => 'Original', 'key' => 'ORIG'], User::factory()->create());
$this->assertSame('orig-base-template-2', $project->projectTemplate->slug);
}
public function test_reordering_types_priorities_and_links_preserves_ticket_references(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
foreach (['Task', 'Bug'] as $index => $name) {
TicketType::factory()->for($source)->create(['name' => $name, 'position' => $index + 1]);
}
foreach (['High', 'Low'] as $index => $name) {
TicketPriority::factory()->for($source)->create(['name' => $name, 'position' => $index + 1]);
}
foreach (['Blocks', 'Relates'] as $index => $label) {
TicketLinkType::factory()->for($source)->create(['label' => $label, 'position' => $index + 1]);
}
$template = $this->template($source, ['ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_link_types']);
$project = $this->linkedProject($template);
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
$link = TicketLink::factory()->create(['source_ticket_id' => $ticket->id, 'ticket_link_type_id' => $project->ticketLinkTypes()->firstOrFail()->id]);
$next = $template->configuration_snapshot;
foreach (['ticket_types', 'ticket_priorities', 'ticket_link_types'] as $area) {
$next[$area] = array_reverse($next[$area]);
foreach ($next[$area] as $index => &$item) {
$item['position'] = $index + 1;
}
unset($item);
}
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('Task', $ticket->fresh()->ticketType->name);
$this->assertSame('High', $ticket->fresh()->ticketPriority->name);
$this->assertSame('Blocks', $link->fresh()->ticketLinkType->label);
foreach (['ticket_types', 'ticket_priorities', 'ticket_link_types'] as $area) {
array_pop($next[$area]);
}
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('Task', $ticket->fresh()->ticketType->name);
$this->assertSame('High', $ticket->fresh()->ticketPriority->name);
$this->assertSame('Blocks', $link->fresh()->ticketLinkType->label);
foreach (['ticket_types', 'ticket_priorities', 'ticket_link_types'] as $area) {
$this->assertContains($area, $project->fresh()->template_overrides);
}
}
public function test_notification_status_filters_use_the_linked_project_status_ids(): void
{
$source = Project::factory()->create();
$status = TicketStatus::factory()->for($source)->create(['name' => 'Ready']);
NotificationAction::factory()->for($source)->create(['filters' => [['attribute' => 'status.to.id', 'operator' => 'equals', 'value' => (string) $status->id]]]);
$template = $this->template($source, ['ticket_statuses', 'notification_actions']);
$this->assertSame($status->configuration_key, $template->configuration_snapshot['notification_actions'][0]['filters'][0]['value']);
$project = $this->linkedProject($template);
$target = $project->ticketStatuses()->firstOrFail();
$notification = $project->notificationActions()->firstOrFail();
$this->assertSame((string) $target->id, $notification->filters[0]['value']);
$payload = new TicketStatusChangedPayload($project->id, $project->key, $project->name, 1, $project->key.'-1', 'Example', 'https://example.test/ticket', null, null, $target->id, $target->name, $target->id, $target->name, now());
$this->assertTrue(app(MatchNotificationFilters::class)->handle($notification->filters, $payload));
$exported = app(DuplicateProjectConfiguration::class)->export($project, ['notification_actions']);
$this->assertSame($status->configuration_key, $exported['notification_actions'][0]['filters'][0]['value']);
}
public function test_column_replacement_moves_inherited_statuses_without_losing_tickets(): void
{
$source = Project::factory()->create();
$column = BoardColumn::factory()->for($source)->create(['name' => 'Old column', 'position' => 1]);
TicketStatus::factory()->for($source)->create(['name' => 'Ready', 'position' => 1, 'board_column_id' => $column->id]);
$template = $this->template($source, ['board_columns', 'ticket_statuses']);
$project = $this->linkedProject($template);
$status = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $status);
$next = $template->configuration_snapshot;
$key = (string) Str::uuid();
$next['board_columns'] = [['configuration_key' => $key, 'name' => 'New column', 'position' => 1]];
$next['ticket_statuses'][0]['board_column_key'] = $key;
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('New column', $ticket->fresh()->ticketStatus->boardColumn->name);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
$this->assertNotContains('board_columns', $project->fresh()->template_overrides);
$this->assertSame(['New column'], $project->boardColumns()->pluck('name')->all());
}
public function test_publishing_private_template_via_http_preserves_its_slug(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$project = app(CreateProject::class)->handle(['name' => 'Original', 'key' => 'ORIG'], $user);
$base = $project->projectTemplate;
$url = route('projects.templates.store', $project);
$this->actingAs($user)->post($url, ['name' => 'Published', 'slug' => 'different', 'copy_areas' => ['project_settings']])->assertSessionHasErrors('slug');
$this->post($url, ['name' => 'Published', 'slug' => $base->slug, 'copy_areas' => ['project_settings']])->assertSessionHasNoErrors()->assertRedirect();
$this->assertSame($base->id, $project->fresh()->project_template_id);
$this->assertTrue($base->fresh()->is_published);
$this->assertSame($base->slug, $base->fresh()->slug);
}
public function test_identity_migration_keeps_duplicate_field_labels_distinct(): void
{
$source = Project::factory()->create();
foreach ([1, 2] as $position) {
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Estimate', 'field_type' => 'number', 'position' => $position]);
}
$template = $this->template($source, ['ticket_fields']);
$project = $this->linkedProject($template);
$field = $project->ticketFieldDefinitions()->firstOrFail();
$ticket = $this->ticket($project, TicketStatus::factory()->for($project)->create());
$value = TicketFieldValue::factory()->create(['ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8]);
$migration = require database_path('migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php');
$migration->down();
$legacy = $template->fresh()->configuration_snapshot;
$this->assertArrayNotHasKey('configuration_key', $legacy['ticket_fields'][0]);
$migration->up();
$configuration = $template->fresh()->configuration_snapshot;
$this->assertNotSame($configuration['ticket_fields'][0]['configuration_key'], $configuration['ticket_fields'][1]['configuration_key']);
$this->assertSame($configuration['ticket_fields'][0]['configuration_key'], $field->fresh()->configuration_key);
$configuration['ticket_fields'][0]['label'] = 'First estimate';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
$this->assertSame($field->id, $value->fresh()->ticket_field_definition_id);
$this->assertSame('First estimate', $value->fresh()->ticketFieldDefinition->label);
$this->assertEquals(8, $value->fresh()->value_number);
}
public function test_stale_template_ownership_does_not_restore_the_old_link(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$other = app(CreateProjectTemplate::class)->empty(['name' => 'Other', 'slug' => 'other', 'copy_areas' => ['project_settings']]);
app(UpdateProjectTemplateLink::class)->switch($project, $other);
$status = TicketStatus::factory()->for($project)->create(['project_template_id' => $template->id, 'position' => 2]);
$this->assertSame($other->id, $status->project_template_id);
$this->assertSame($project->id, $status->project_id);
}
public function test_template_update_skips_a_project_that_changed_templates_before_its_lock(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$other = app(CreateProjectTemplate::class)->empty(['name' => 'Other', 'slug' => 'other', 'copy_areas' => ['project_settings']]);
$changed = false;
Project::retrieved(function (Project $selected) use ($project, $other, &$changed): void {
if (! $changed && $selected->id === $project->id) {
$changed = true;
DB::table('projects')->where('id', $project->id)->update(['project_template_id' => $other->id]);
}
});
try {
$configuration = $template->configuration_snapshot;
$configuration['ticket_statuses'][0]['name'] = 'Changed on old template';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
} finally {
Event::forget('eloquent.retrieved: '.Project::class);
}
$this->assertTrue($changed);
$this->assertSame($other->id, $project->fresh()->project_template_id);
$this->assertSame(['Todo'], $project->ticketStatuses()->pluck('name')->all());
}
public function test_repository_replacement_and_swapping_preserve_valid_connections(): void
{
$source = Project::factory()->create();
RepositoryConnection::factory()->for($source)->create(['provider_repository_id' => '1', 'base_url' => 'https://git.example.test']);
RepositoryConnection::factory()->for($source)->create(['provider_repository_id' => '2', 'base_url' => 'https://git.example.test']);
$template = $this->template($source, ['repository_connections']);
$project = $this->linkedProject($template);
$original = $project->repositoryConnections()->get()->keyBy('configuration_key');
$configuration = $template->configuration_snapshot;
$configuration['repository_connections'][0]['provider_repository_id'] = '2';
$configuration['repository_connections'][1]['provider_repository_id'] = '1';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
foreach ($configuration['repository_connections'] as $entry) {
$connection = $project->repositoryConnections()->where('configuration_key', $entry['configuration_key'])->firstOrFail();
$this->assertSame($original[$entry['configuration_key']]->id, $connection->id);
$this->assertSame($entry['provider_repository_id'], $connection->provider_repository_id);
}
$configuration['repository_connections'][0]['configuration_key'] = (string) Str::uuid();
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
$this->assertSame(2, $project->repositoryConnections()->count());
$this->assertEqualsCanonicalizing(['1', '2'], $project->repositoryConnections()->pluck('provider_repository_id')->all());
}
public function test_template_configuration_rejects_duplicate_repository_connections(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$source = Project::factory()->create();
RepositoryConnection::factory()->for($source)->create();
$configuration = app(DuplicateProjectConfiguration::class)->export($source, ['repository_connections']);
$configuration['repository_connections'][] = [
...$configuration['repository_connections'][0], 'configuration_key' => (string) Str::uuid(),
];
$this->actingAs($user)->post(route('project-templates.store'), [
'name' => 'Duplicate repositories', 'slug' => 'duplicate-repositories',
'copy_areas' => ['repository_connections'], 'configuration_snapshot' => $configuration,
])->assertSessionHasErrors('configuration_snapshot.repository_connections.1.provider_repository_id');
$this->assertDatabaseMissing('project_templates', ['slug' => 'duplicate-repositories']);
}
private function template(Project $source, array $areas): ProjectTemplate
{
return ProjectTemplate::factory()->create([
'source_project_id' => $source->id,
'is_published' => true,
'copy_areas' => $areas,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($source, $areas),
]);
}
private function linkedProject(ProjectTemplate $template): Project
{
$project = Project::factory()->create(['project_template_id' => $template->id]);
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot);
return $project->fresh();
}
private function refreshTemplate(ProjectTemplate $template): void
{
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'copy_areas' => $template->copy_areas, 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, $template->copy_areas)]);
}
private function ticket(Project $project, TicketStatus $status): Ticket
{
return Ticket::factory()->create([
'project_id' => $project->id,
'ticket_type_id' => ($project->ticketTypes()->first() ?? TicketType::factory()->for($project)->create())->id,
'ticket_priority_id' => ($project->ticketPriorities()->first() ?? TicketPriority::factory()->for($project)->create())->id,
'ticket_status_id' => $status->id,
'ticket_key' => $project->key.'-1',
]);
}
}
@@ -31,11 +31,13 @@ class ProjectTemplateOverrideTest extends TestCase
$project = Project::factory()->create(); $project = Project::factory()->create();
$status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]); $status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly(); $project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
$ticket = $this->createTicket($project, $status); $ticket = $this->createTicket($project, $status);
$sourceStatus->update(['name' => 'Ready']); $sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [ app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name, 'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]); ]);
@@ -62,6 +64,7 @@ class ProjectTemplateOverrideTest extends TestCase
$sourceStatus->update(['name' => 'Ready']); $sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [ app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name, 'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]); ]);
@@ -76,6 +79,7 @@ class ProjectTemplateOverrideTest extends TestCase
$project = Project::factory()->create(); $project = Project::factory()->create();
$status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]); $status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly(); $project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
$status->update(['name' => 'Project only']); $status->update(['name' => 'Project only']);
$status->update(['name' => 'Todo']); $status->update(['name' => 'Todo']);
@@ -86,6 +90,7 @@ class ProjectTemplateOverrideTest extends TestCase
$sourceStatus->update(['name' => 'Ready']); $sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [ app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name, 'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]); ]);
@@ -106,7 +111,7 @@ class ProjectTemplateOverrideTest extends TestCase
'project_template_id' => $template->id, 'project_template_id' => $template->id,
'template_overrides' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], 'template_overrides' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]); ]);
$replacementStatus = TicketStatus::factory()->for($project)->create(['name' => 'Old template status', 'position' => 1]); $replacementStatus = TicketStatus::factory()->for($project)->create(['name' => 'Old template status', 'position' => 1, 'configuration_key' => $template->configuration_snapshot['ticket_statuses'][0]['configuration_key']]);
$localStatus = TicketStatus::factory()->for($project)->create(['name' => 'Local status', 'position' => 2]); $localStatus = TicketStatus::factory()->for($project)->create(['name' => 'Local status', 'position' => 2]);
$ticket = $this->createTicket($project, $localStatus); $ticket = $this->createTicket($project, $localStatus);
$this->syncProjectPermissions($project, $user, ['admin']); $this->syncProjectPermissions($project, $user, ['admin']);
@@ -171,9 +176,11 @@ class ProjectTemplateOverrideTest extends TestCase
$project = Project::factory()->create(); $project = Project::factory()->create();
TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]); TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly(); $project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
app(UpdateProjectTemplate::class)->handle($template, [ app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name, 'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [ 'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES, DuplicateProjectConfiguration::COPY_TICKET_TYPES,
@@ -199,6 +206,7 @@ class ProjectTemplateOverrideTest extends TestCase
'field_type' => 'number', 'label' => 'Estimate', 'position' => 1, 'field_type' => 'number', 'label' => 'Estimate', 'position' => 1,
]); ]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly(); $project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
$status = TicketStatus::factory()->for($project)->create(); $status = TicketStatus::factory()->for($project)->create();
$ticket = $this->createTicket($project, $status); $ticket = $this->createTicket($project, $status);
$fieldValue = TicketFieldValue::factory()->create([ $fieldValue = TicketFieldValue::factory()->create([
@@ -210,6 +218,7 @@ class ProjectTemplateOverrideTest extends TestCase
$sourceField->update(['label' => 'Story points']); $sourceField->update(['label' => 'Story points']);
app(UpdateProjectTemplate::class)->handle($template, [ app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name, 'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_FIELDS], 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_FIELDS],
]); ]);
+4
View File
@@ -222,6 +222,10 @@ if (serve) {
assert.match(admin, /Board columns · inherited/); assert.match(admin, /Board columns · inherited/);
assert.match(admin, /Ticket types · overridden/); assert.match(admin, /Ticket types · overridden/);
assert.match( assert.match(
await render('/projects/7', { create: true, manage: true }),
/href="\/projects\/7\/templates\/create"/,
);
assert.doesNotMatch(
await render('/projects/7', { create: true }), await render('/projects/7', { create: true }),
/href="\/projects\/7\/templates\/create"/, /href="\/projects\/7\/templates\/create"/,
); );
+90 -3
View File
@@ -46,6 +46,7 @@ try {
'/project-templates?reset', '/project-templates?reset',
'/projects/1/templates/create?reset', '/projects/1/templates/create?reset',
'/project-templates/2/edit?reset', '/project-templates/2/edit?reset',
'/project-templates/create?reset',
]) { ]) {
await visit(path); await visit(path);
await noOverflow(); await noOverflow();
@@ -73,6 +74,7 @@ try {
}); });
} }
} }
await visit('/project-templates/2/edit?reset');
assert.equal(await page.locator('#slug').isDisabled(), true); assert.equal(await page.locator('#slug').isDisabled(), true);
assert.equal( assert.equal(
await button('Save template changes').isDisabled(), await button('Save template changes').isDisabled(),
@@ -193,6 +195,14 @@ try {
name: 'Updated foundation', name: 'Updated foundation',
copy_areas: ['project_settings', 'ticket_types'], copy_areas: ['project_settings', 'ticket_types'],
confirm_linked_projects_update: true, confirm_linked_projects_update: true,
configuration_snapshot: await page.evaluate(
() =>
JSON.parse(
document
.querySelector('[data-page]')
.getAttribute('data-page'),
).props.template.configuration_snapshot,
),
}); });
await page await page
.getByRole('heading', { name: 'Project templates', exact: true }) .getByRole('heading', { name: 'Project templates', exact: true })
@@ -207,15 +217,92 @@ try {
assert.deepEqual((await unlinked).postDataJSON(), { assert.deepEqual((await unlinked).postDataJSON(), {
name: 'Foundation', name: 'Foundation',
copy_areas: ['project_settings', 'ticket_types'], copy_areas: ['project_settings', 'ticket_types'],
configuration_snapshot: await page.evaluate(
() =>
JSON.parse(
document
.querySelector('[data-page]')
.getAttribute('data-page'),
).props.template.configuration_snapshot,
),
}); });
await page await page
.getByRole('heading', { name: 'Project templates', exact: true }) .getByRole('heading', { name: 'Project templates', exact: true })
.waitFor(); .waitFor();
await visit('/project-templates/2/edit?reset&missing'); await visit('/project-templates/2/edit?reset&missing&unlinked');
assert.equal(
await page
.getByText('This template cannot be refreshed', { exact: true })
.count(),
0,
);
assert.equal(await button('Save template changes').isEnabled(), true);
await button('Save template changes').click();
await page await page
.getByText('This template cannot be refreshed', { exact: true }) .getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/create?reset');
await page.locator('#name').fill('New independent template');
await page.locator('#slug').fill('independent-template');
const columns = page
.locator('details')
.filter({ has: page.locator('summary', { hasText: 'Board columns' }) });
await columns
.getByRole('button', { name: 'Add entry', exact: true })
.click();
await columns.getByLabel('Name', { exact: true }).fill('Todo');
const statuses = page.locator('details').filter({
has: page.locator('summary', { hasText: 'Ticket statuses' }),
});
await statuses
.getByRole('button', { name: 'Add entry', exact: true })
.click();
await statuses.getByLabel('Name', { exact: true }).last().fill('Review');
await statuses
.getByLabel('Board column', { exact: true })
.last()
.selectOption({ label: 'Todo' });
const types = page
.locator('details')
.filter({ has: page.locator('summary', { hasText: 'Ticket types' }) });
await types
.getByRole('button', { name: 'Move down', exact: true })
.first()
.click();
const standaloneCreated = page.waitForRequest(
(request) => request.method() === 'POST',
);
await button('Save new template').click();
const body = (await standaloneCreated).postDataJSON();
assert.equal(
new URL((await standaloneCreated).url()).pathname,
'/project-templates',
);
assert.equal(body.configuration_snapshot.ticket_statuses[1].name, 'Review');
assert.equal(
body.configuration_snapshot.ticket_statuses[1].board_column_key,
body.configuration_snapshot.board_columns[0].configuration_key,
);
assert.deepEqual(
body.configuration_snapshot.ticket_types.map((item) => item.name),
['Release', 'Task'],
);
assert.deepEqual(
body.configuration_snapshot.ticket_types.map((item) => item.position),
[1, 2],
);
assert.equal(
new Set(
body.configuration_snapshot.ticket_types.map(
(item) => item.configuration_key,
),
).size,
2,
);
await page
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor(); .waitFor();
assert.equal(await button('Save template changes').isDisabled(), true);
await visit('/projects/1/templates/create?reset'); await visit('/projects/1/templates/create?reset');
await page.locator('#name').fill('Unsaved work'); await page.locator('#name').fill('Unsaved work');
+72 -6
View File
@@ -43,10 +43,54 @@ const template = {
is_published: true, is_published: true,
source_project: project, source_project: project,
copy_areas: ['project_settings', 'ticket_types'], copy_areas: ['project_settings', 'ticket_types'],
configuration_snapshot: {
project_settings: { show_author: true, show_story_points: true },
board_columns: [],
ticket_statuses: [
{
configuration_key: '10000000-0000-4000-8000-000000000001',
name: 'Backlog',
position: 1,
board_column_key: null,
},
],
status_transitions: [
{
status_key: '10000000-0000-4000-8000-000000000001',
target_keys: [],
},
],
ticket_types: [
{
configuration_key: '20000000-0000-4000-8000-000000000001',
name: 'Task',
position: 1,
is_parent: false,
},
{
configuration_key: '20000000-0000-4000-8000-000000000002',
name: 'Release',
position: 2,
is_parent: true,
},
],
ticket_priorities: [
{
configuration_key: '30000000-0000-4000-8000-000000000001',
name: 'Medium',
position: 1,
},
],
ticket_fields: [],
ticket_link_types: [],
repository_connections: [],
notification_actions: [],
},
linked_project_count: 2, linked_project_count: 2,
}; };
const initial = () => ({ const initial = () => ({
project, project,
initialConfiguration: structuredClone(template.configuration_snapshot),
copyAreaOptions: areas.map((value) => ({ copyAreaOptions: areas.map((value) => ({
value, value,
label: value, label: value,
@@ -100,7 +144,8 @@ if (serve) {
const url = new URL(request.url, 'http://localhost'); const url = new URL(request.url, 'http://localhost');
let component = url.pathname.endsWith('/edit') let component = url.pathname.endsWith('/edit')
? 'Edit' ? 'Edit'
: url.pathname.includes('/templates/create') : url.pathname.includes('/templates/create') ||
url.pathname === '/project-templates/create'
? 'Create' ? 'Create'
: url.pathname === '/project-templates' : url.pathname === '/project-templates'
? 'Index' ? 'Index'
@@ -117,6 +162,9 @@ if (serve) {
if (url.searchParams.has('empty')) { if (url.searchParams.has('empty')) {
data.templates = []; data.templates = [];
} }
if (url.pathname === '/project-templates/create') {
data.project = null;
}
let errors = {}; let errors = {};
if (request.method !== 'GET') { if (request.method !== 'GET') {
let body = ''; let body = '';
@@ -259,12 +307,30 @@ if (serve) {
unlinked.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '', unlinked.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/, / disabled(?:[ =>])/,
); );
assert.match( const independent = await render('Edit', {
await render('Edit', { template: {
template: { ...template, source_project: null }, ...template,
}), source_project: null,
/This template cannot be refreshed/, linked_project_count: 0,
},
});
assert.match(independent, /Template configuration/);
assert.doesNotMatch(independent, /This template cannot be refreshed/);
assert.doesNotMatch(
independent.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
); );
const empty = await render('Create', {
project: null,
template: undefined,
});
assert.match(empty, /New empty template/);
assert.match(
empty.match(/<input[^>]*id="name"[^>]*>/)?.[0] ?? '',
/\bvalue(?:="")?(?:\s|>)/,
);
assert.match(empty, /Template configuration/);
assert.match(list, /href="\/project-templates\/create"/);
assert.match( assert.match(
await render( await render(
'Index', 'Index',