Compare commits

...
1 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
61 changed files with 2696 additions and 335 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,6 +22,8 @@ class UpdateTicketStatusTransitions
*/ */
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
{ {
return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus {
Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail();
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', [])) $allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId) ->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
->unique() ->unique()
@@ -37,5 +41,6 @@ class UpdateTicketStatusTransitions
); );
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,6 +21,8 @@ class UpdateProjectTicketMetadataSettings
*/ */
public function handle(Project $project, array $validated): Project public function handle(Project $project, array $validated): Project
{ {
return DB::transaction(function () use ($project, $validated): Project {
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$project->update([ $project->update([
'show_author' => Arr::get($validated, 'show_author', false), 'show_author' => Arr::get($validated, 'show_author', false),
'show_story_points' => Arr::get($validated, 'show_story_points', false), 'show_story_points' => Arr::get($validated, 'show_story_points', false),
@@ -34,5 +37,6 @@ class UpdateProjectTicketMetadataSettings
); );
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),
+40 -7
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();
return ProjectTemplate::query()->create([ $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, 'project_id' => null,
'source_project_id' => $sourceProject->id, 'source_project_id' => $sourceProject->id,
'name' => $attributes['name'], 'name' => Arr::get($attributes, 'name'),
'slug' => $attributes['slug'],
'is_published' => true, 'is_published' => true,
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas), 'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas), 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
]); ])->save();
$sourceProject->forceFill([
'project_template_id' => $template->id,
'template_overrides' => array_values(array_diff(DuplicateProjectConfiguration::copyableAreas(), $copyAreas)),
])->saveQuietly();
foreach ($sourceProject->configurationRelations() as $relation) {
$sourceProject->{$relation}()->update(['project_template_id' => $template->id]);
}
return $template;
}, 3);
} }
} }
@@ -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;
} }
$replacementId = $statusReplacements[$status->id] ?? null; $replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
if ($replacementId === null) { ->where('configuration_key', (string) $replacement)->first();
return false; if ($replacementStatus === null && is_numeric($replacement)) {
$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();
$project->load('projectTemplate');
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) { if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
return; return;
} }
$project->forceFill(['template_overrides' => array_values(array_unique([
$overrides = array_values(array_unique([ ...($project->template_overrides ?? []), $area,
...($project->template_overrides ?? []), ]))])->saveQuietly();
$area, }, 3);
]));
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
} }
/** /**
@@ -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);
if ($template === null || $template->is_published) {
return; return;
} }
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration( $snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
$project->projectTemplate->configuration_snapshot ?? [], $template->forceFill(['configuration_snapshot' => array_replace(
); $snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
)])->saveQuietly();
$project->projectTemplate->forceFill([ }, 3);
'configuration_snapshot' => array_replace(
$snapshot,
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
),
])->saveQuietly();
} }
} }
@@ -71,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 => [
+19 -22
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(
$projectTemplate->configuration_snapshot ?? [],
);
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)])) $affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null)) ->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
->values() ->values()->all();
->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');
});
}
}
};
@@ -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[]>(() =>
props.project
? [
{ title: t('navigation.projects'), href: index() }, { title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) }, { title: props.project.name, href: show(props.project.id) },
{ title: t('projects.saveTemplate'), href: create(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"/,
); );
+89 -2
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 await page
.getByText('This template cannot be refreshed', { exact: true }) .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
.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',