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

This commit is contained in:
2026-10-15 11:38:17 +02:00
parent 5769d84526
commit 1291a0fcad
61 changed files with 2696 additions and 335 deletions
@@ -2,6 +2,7 @@
namespace App\Actions\ProjectBoard;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions
'position' => $index + 1,
])->saveQuietly();
});
app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses');
}
private function statusContextLabel(TicketStatus $ticketStatus): string
@@ -2,6 +2,7 @@
namespace App\Actions\ProjectBoard;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\BoardColumn;
use App\Models\Project;
use Illuminate\Support\Collection;
@@ -50,5 +51,9 @@ trait ReordersBoardColumns
'position' => $index + 1,
])->saveQuietly();
});
$project = $columns->first()?->project;
if ($project !== null) {
app(RefreshProjectBaseTemplate::class)->handle($project, 'board_columns');
}
}
}
@@ -5,8 +5,10 @@ namespace App\Actions\ProjectBoard;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateTicketStatusTransitions
{
@@ -20,22 +22,25 @@ class UpdateTicketStatusTransitions
*/
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
{
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
->unique()
->values()
->all();
return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus {
Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail();
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
->unique()
->values()
->all();
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
$this->projectTemplateOverrideManager->mark(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
$this->refreshProjectBaseTemplate->handle(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
$this->projectTemplateOverrideManager->mark(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
$this->refreshProjectBaseTemplate->handle(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
return $ticketStatus->fresh();
return $ticketStatus->fresh();
}, 3);
}
}
@@ -7,6 +7,7 @@ use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateProjectTicketMetadataSettings
{
@@ -20,19 +21,22 @@ class UpdateProjectTicketMetadataSettings
*/
public function handle(Project $project, array $validated): Project
{
$project->update([
'show_author' => Arr::get($validated, 'show_author', false),
'show_story_points' => Arr::get($validated, 'show_story_points', false),
]);
$this->projectTemplateOverrideManager->mark(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
$this->refreshProjectBaseTemplate->handle(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
return DB::transaction(function () use ($project, $validated): Project {
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$project->update([
'show_author' => Arr::get($validated, 'show_author', false),
'show_story_points' => Arr::get($validated, 'show_story_points', false),
]);
$this->projectTemplateOverrideManager->mark(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
$this->refreshProjectBaseTemplate->handle(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
return $project->fresh();
return $project->fresh();
}, 3);
}
}
+2
View File
@@ -36,6 +36,7 @@ class CreateProject
if ($creationMode === 'template' && $sourceTemplateId !== null) {
$sourceTemplate = ProjectTemplate::query()
->published()
->lockForUpdate()
->findOrFail((int) $sourceTemplateId);
$project->forceFill([
@@ -72,6 +73,7 @@ class CreateProject
$this->duplicateProjectConfiguration->apply(
$project->fresh() ?? $project,
$sourceTemplate->configuration_snapshot ?? [],
adoptExisting: true,
);
}
@@ -15,11 +15,18 @@ class CreateProjectBaseTemplate
{
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
$slug = strtolower($project->key).'-base-template';
$suffix = 1;
$candidate = $slug;
while (ProjectTemplate::query()->where('slug', $candidate)->exists()) {
$candidate = $slug.'-'.++$suffix;
}
return ProjectTemplate::query()->create([
'project_id' => $project->id,
'source_project_id' => $project->id,
'name' => "{$project->name} base template",
'slug' => strtolower($project->key).'-base-template',
'slug' => $candidate,
'is_published' => false,
'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
+43 -10
View File
@@ -5,28 +5,61 @@ namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class CreateProjectTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
) {}
/** @param array<string, mixed> $attributes */
public function empty(array $attributes): ProjectTemplate
{
$areas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
return ProjectTemplate::query()->create([
'name' => Arr::get($attributes, 'name'),
'slug' => Arr::get($attributes, 'slug'),
'is_published' => true,
'copy_areas' => $areas,
'configuration_snapshot' => collect($this->duplicateProjectConfiguration->normalizeConfiguration(
Arr::get($attributes, 'configuration_snapshot', $this->projectConfigurationIdentity->defaults()),
))->only($areas)->all(),
]);
}
/**
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
*/
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
{
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
return DB::transaction(function () use ($sourceProject, $attributes): ProjectTemplate {
$sourceProject = Project::query()->whereKey($sourceProject->id)->lockForUpdate()->firstOrFail();
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
$template = $sourceProject->projectTemplate;
if ($template === null || $template->is_published || $template->project_id !== $sourceProject->id) {
$template = new ProjectTemplate;
$template->slug = Arr::get($attributes, 'slug');
}
$template->forceFill([
'project_id' => null,
'source_project_id' => $sourceProject->id,
'name' => Arr::get($attributes, 'name'),
'is_published' => true,
'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
])->save();
$sourceProject->forceFill([
'project_template_id' => $template->id,
'template_overrides' => array_values(array_diff(DuplicateProjectConfiguration::copyableAreas(), $copyAreas)),
])->saveQuietly();
foreach ($sourceProject->configurationRelations() as $relation) {
$sourceProject->{$relation}()->update(['project_template_id' => $template->id]);
}
return ProjectTemplate::query()->create([
'project_id' => null,
'source_project_id' => $sourceProject->id,
'name' => $attributes['name'],
'slug' => $attributes['slug'],
'is_published' => true,
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
]);
return $template;
}, 3);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Actions\Projects;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class DeleteProjectTemplate
{
public function __construct(
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function handle(ProjectTemplate $template): void
{
DB::transaction(function () use ($template): void {
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$projects = $template->linkedProjects()->orderBy('id')->lockForUpdate()->get();
$template->delete();
foreach ($projects as $project) {
$base = $project->baseTemplate()->first() ?? $this->createProjectBaseTemplate->handle($project);
$base->forceFill(['configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, DuplicateProjectConfiguration::copyableAreas())])->saveQuietly();
$project->forceFill(['project_template_id' => $base->id, 'template_overrides' => []])->saveQuietly();
foreach ($project->configurationRelations() as $relation) {
$project->{$relation}()->update(['project_template_id' => $base->id]);
}
}
}, 3);
}
}
@@ -2,9 +2,13 @@
namespace App\Actions\Projects;
use App\Actions\Notifications\NormalizeNotificationFilters;
use App\Models\Project;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class DuplicateProjectConfiguration
{
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
) {}
/** @return list<string> */
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
unset($configuration[self::COPY_BOARD_WORKFLOW]);
}
return $configuration;
return $this->projectConfigurationIdentity->normalize($configuration);
}
/** @param list<string> $copyAreas @return array<string, mixed> */
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
];
}
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column, int $index): array => [$column->id => $index],
$columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column): array => [$column->id => $column->configuration_key],
);
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status, int $index): array => [$status->id => $index],
$statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status): array => [$status->id => $status->configuration_key],
);
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
static fn ($column): array => ['configuration_key' => $column->configuration_key, 'name' => $column->name, 'position' => $column->position],
)->all();
}
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => [
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
'configuration_key' => $status->configuration_key,
'board_column_key' => $status->board_column_id === null ? null : $columnKeys->get($status->board_column_id),
'name' => $status->name,
'position' => $status->position,
],
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?int => $statusIndexes->get($id),
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
static fn ($status): array => [
'status_key' => $status->configuration_key,
'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?string => $statusKeys->get($id),
)->filter()->sort()->values()->all(),
],
)->all();
}
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
'filters' => $item->filters ?? [],
'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
],
];
$relations = [
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
foreach ($simpleAreas as $area => $mapper) {
if (in_array($area, $selectedAreas, true)) {
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map(
static fn ($item): array => ['configuration_key' => $item->configuration_key, ...$mapper($item)],
)->all();
}
}
@@ -200,95 +211,124 @@ class DuplicateProjectConfiguration
/**
* @param array<string, mixed> $configuration
* @param array<int|string, int|string> $statusReplacements
* @return list<string> areas which retained referenced local values
* @return list<string> Areas which retain referenced project configuration.
*/
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
public function apply(Project $targetProject, array $configuration, array $statusReplacements = [], bool $adoptExisting = false): array
{
return DB::transaction(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
$targetProject = Project::query()->whereKey($targetProject->id)->lockForUpdate()->firstOrFail();
return $this->applyLocked($targetProject, $configuration, $statusReplacements, $adoptExisting);
}, 3);
}
/** @param array<string, mixed> $configuration @param array<int|string, int|string> $statusReplacements @return list<string> */
private function applyLocked(Project $targetProject, array $configuration, array $statusReplacements, bool $adoptExisting): array
{
$configuration = $this->normalizeConfiguration($configuration);
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
$blockedAreas = [];
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
}
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
$blocked = $this->syncModels(
$targetProject->boardColumns()->get(),
$configuration[self::COPY_BOARD_COLUMNS],
fn (array $item) => $targetProject->boardColumns()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
$currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
$nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
$columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
$statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
static fn ($status): bool => $nextStatuses->has($status->configuration_key)
|| $status->tickets_count === 0 || array_key_exists($status->id, $statusReplacements),
);
if ($blocked) {
$canDeleteColumn = static fn (Model $column): bool => $currentStatuses->where('board_column_id', $column->id)->every(
static function ($status) use ($column, $statusesCanChange, $nextStatuses, $columnKeys): bool {
if (! $statusesCanChange) {
return false;
}
$next = $nextStatuses->get($status->configuration_key);
$nextColumn = $next['board_column_key'] ?? null;
return $next === null || ($nextColumn !== $column->configuration_key
&& ($nextColumn === null || in_array($nextColumn, $columnKeys, true)));
},
);
if ($this->syncModels($targetProject, self::COPY_BOARD_COLUMNS, $configuration[self::COPY_BOARD_COLUMNS],
$canDeleteColumn, $adoptExisting)) {
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
}
}
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
$columns = $targetProject->boardColumns()->get()->values();
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
$columnIndex = $item['board_column_index'] ?? null;
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
unset($item['board_column_index']);
$columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
$existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
$columnKey = $item['board_column_key'] ?? null;
$item['board_column_id'] = $columnKey === null
? null
: ($columns->get($columnKey)?->id ?? $existingStatuses->get($item['configuration_key'])?->board_column_id);
$item['slug'] = Str::slug($item['name']);
unset($item['board_column_key']);
return $item;
})->all();
$blocked = $this->syncModels(
$targetProject->ticketStatuses()->get(),
$items,
fn (array $item) => $targetProject->ticketStatuses()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
function (Model $status) use ($statusReplacements): bool {
if (! $status->tickets()->exists()) {
return true;
}
$replacementId = $statusReplacements[$status->id] ?? null;
if ($replacementId === null) {
return false;
}
$status->tickets()->update(['ticket_status_id' => $replacementId]);
return true;
},
);
if ($blocked) {
$templateKeys = array_column($items, 'configuration_key');
$canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
|| array_key_exists($status->id, $statusReplacements);
$beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
$replacement = $statusReplacements[$status->id] ?? null;
if ($replacement === null) {
return;
}
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
->where('configuration_key', (string) $replacement)->first();
if ($replacementStatus === null && is_numeric($replacement)) {
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
}
if ($replacementStatus === null) {
throw ValidationException::withMessages([
'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
]);
}
$status->tickets()->update(['ticket_status_id' => $replacementStatus->id]);
$this->transferStatusTransitions($targetProject, $status, $replacementStatus, $statusReplacements, $templateKeys);
};
if ($this->syncModels($targetProject, self::COPY_TICKET_STATUSES, $items, $canDelete, $adoptExisting, $beforeDelete)) {
$blockedAreas[] = self::COPY_TICKET_STATUSES;
}
}
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
$statuses = $targetProject->ticketStatuses()->get()->values();
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
$status = $statuses->get($index);
$statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
$status = $statuses->get($transition['status_key']);
if ($status !== null) {
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
$status->allowedTargetStatuses()->sync(collect($transition['target_keys'])->map(
static fn (string $key): ?int => $statuses->get($key)?->id,
)->filter()->all());
}
}
}
$relations = [
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
$guards = [
self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
self::COPY_REPOSITORY_CONNECTIONS => null,
self::COPY_NOTIFICATION_ACTIONS => null,
];
foreach ($relations as $area => [$relation, $canDelete]) {
foreach ($guards as $area => $canDelete) {
if (! isset($configuration[$area])) {
continue;
}
$blocked = $this->syncModels(
$targetProject->{$relation}()->get(),
$configuration[$area],
fn (array $item) => $targetProject->{$relation}()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
$canDelete,
);
if ($blocked) {
$items = $configuration[$area];
if ($area === self::COPY_NOTIFICATION_ACTIONS) {
$statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
$items = array_map(fn (array $item): array => [
...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
], $items);
}
if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
$blockedAreas[] = $area;
}
}
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
}
return array_values(array_unique($blockedAreas));
return $blockedAreas;
});
foreach ($blockedAreas as $area) {
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
}
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
/** @param list<string> $copyAreas */
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
{
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
$this->apply($targetProject, $this->export($sourceProject, $copyAreas), adoptExisting: true);
}
/** @param list<array<string, mixed>> $filters @param array<int|string, int|string> $mapping @return list<array<string, mixed>> */
public function mapNotificationFilters(array $filters, array $mapping): array
{
return array_map(static function (array $filter) use ($mapping): array {
if (! in_array($filter['attribute'], ['status.from.id', 'status.to.id'], true)) {
return $filter;
}
$map = static fn (string $value): string => (string) ($mapping[$value] ?? $value);
$filter['value'] = is_array($filter['value'])
? array_map($map, $filter['value'])
: ($filter['value'] === null ? null : $map($filter['value']));
return $filter;
}, app(NormalizeNotificationFilters::class)->handle($filters));
}
/** @param array<int|string, int|string> $replacements @param list<string> $templateKeys */
private function transferStatusTransitions(Project $project, TicketStatus $removed, TicketStatus $replacement, array $replacements, array $templateKeys): void
{
$statuses = $project->ticketStatuses()->get();
$targets = $statuses->whereIn('configuration_key', $templateKeys);
$mapping = [];
foreach ($statuses as $status) {
if (in_array($status->configuration_key, $templateKeys, true)) {
$mapping[$status->id] = $status->id;
continue;
}
$target = $replacements[$status->id] ?? null;
if (is_scalar($target)) {
$mapping[$status->id] = $targets->firstWhere('configuration_key', (string) $target)?->id
?? (is_numeric($target) ? $targets->firstWhere('id', (int) $target)?->id : null);
}
}
$mapping[$removed->id] = $replacement->id;
$edges = DB::table('ticket_status_transitions')->where('from_ticket_status_id', $removed->id)
->orWhere('to_ticket_status_id', $removed->id)->get();
foreach ($edges as $edge) {
$from = $mapping[$edge->from_ticket_status_id] ?? null;
$to = $mapping[$edge->to_ticket_status_id] ?? null;
if ($from !== null && $to !== null && $from !== $to) {
$statuses->firstWhere('id', $from)->allowedTargetStatuses()->syncWithoutDetaching([$to]);
}
}
}
/**
* @param EloquentCollection<int, Model> $models
* @param array<int, array<string, mixed>> $items
* @param list<array<string, mixed>> $items
*/
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
private function syncModels(Project $project, string $area, array $items, ?callable $canDelete, bool $adoptExisting, ?callable $beforeDelete = null): bool
{
foreach ($items as $index => $item) {
$model = $models->get($index);
$model === null ? $create($item) : $update($model, $item);
$relation = ProjectConfigurationIdentity::relations()[$area];
$models = $project->{$relation}()->get();
$byKey = $models->keyBy('configuration_key');
$matched = [];
$assignments = [];
foreach ($items as $item) {
$model = $byKey->get($item['configuration_key']);
if ($model === null && $adoptExisting) {
$candidates = $models->filter(fn (Model $candidate): bool => ! in_array($candidate->id, $matched, true)
&& $this->projectConfigurationIdentity->signature($area, $candidate->attributesToArray()) === $this->projectConfigurationIdentity->signature($area, $item));
$model = $candidates->count() === 1 ? $candidates->first() : null;
}
if ($model !== null) {
$matched[] = $model->id;
if ($area === self::COPY_TICKET_FIELDS && $model->field_type !== $item['field_type'] && $model->ticketFieldValues()->exists()) {
return true;
}
if ($area === self::COPY_TICKET_TYPES && $model->is_parent && ! ($item['is_parent'] ?? false)
&& $model->tickets()->whereHas('childTickets')->exists()) {
return true;
}
}
$assignments[] = [$model, $item];
}
$removed = $models->reject(static fn (Model $model): bool => in_array($model->id, $matched, true));
foreach ($removed as $model) {
if ($canDelete !== null && ! $canDelete($model)) {
return true;
}
}
$blocked = false;
foreach ($models->slice(count($items)) as $model) {
if ($canDelete !== null && ! $canDelete($model)) {
$blocked = true;
if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
foreach ($models as $model) {
$model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
}
}
continue;
if ($models->isNotEmpty() && $models->first()->getAttribute('position') !== null) {
$offset = max((int) $models->max('position'), (int) collect($items)->max('position')) + $models->count() + count($items) + 1;
foreach ($models->values() as $index => $model) {
$model->forceFill(['position' => $offset + $index])->saveQuietly();
}
}
foreach ($assignments as [$model, $item]) {
$model === null ? $project->{$relation}()->create($item) : $model->forceFill($item)->saveQuietly();
}
foreach ($removed as $model) {
if ($beforeDelete !== null) {
$beforeDelete($model);
}
$model->deleteQuietly();
}
return $blocked;
return false;
}
}
@@ -0,0 +1,95 @@
<?php
namespace App\Actions\Projects;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
class ProjectConfigurationIdentity
{
/** @return array<string, string> */
public static function relations(): array
{
return [
'board_columns' => 'boardColumns', 'ticket_statuses' => 'ticketStatuses',
'ticket_types' => 'ticketTypes', 'ticket_priorities' => 'ticketPriorities',
'ticket_fields' => 'ticketFieldDefinitions', 'ticket_link_types' => 'ticketLinkTypes',
'repository_connections' => 'repositoryConnections', 'notification_actions' => 'notificationActions',
];
}
/** @param array<string, mixed> $configuration @return array<string, mixed> */
public function normalize(array $configuration): array
{
foreach (self::relations() as $area => $relation) {
if (! array_key_exists($area, $configuration)) {
continue;
}
foreach ($configuration[$area] as $index => &$item) {
$item['configuration_key'] ??= (string) Uuid::uuid5(
Uuid::NAMESPACE_URL, 'kanbangit:'.$area.':'.$index.':'.$this->signature($area, $item),
);
}
unset($item);
}
$columns = array_column($configuration['board_columns'] ?? [], 'configuration_key');
foreach ($configuration['ticket_statuses'] ?? [] as $index => $status) {
if (! array_key_exists('board_column_key', $status)) {
$columnIndex = $status['board_column_index'] ?? null;
$status['board_column_key'] = $columnIndex === null ? null : ($columns[$columnIndex] ?? null);
}
unset($status['board_column_index']);
$configuration['ticket_statuses'][$index] = $status;
}
$statuses = array_column($configuration['ticket_statuses'] ?? [], 'configuration_key');
foreach ($configuration['status_transitions'] ?? [] as $index => $transition) {
if (array_key_exists('status_key', $transition)) {
continue;
}
if (! isset($statuses[$index])) {
continue;
}
$configuration['status_transitions'][$index] = [
'status_key' => $statuses[$index],
'target_keys' => array_values(array_filter(array_map(
static fn (int $target): ?string => $statuses[$target] ?? null, $transition,
))),
];
}
return $configuration;
}
/** @param array<string, mixed> $item */
public function signature(string $area, array $item): string
{
return $area === 'repository_connections'
? ($item['provider'] ?? '').':'.($item['base_url'] ?? '').':'.($item['repository_full_name'] ?? '')
: (string) ($item['name'] ?? $item['label'] ?? '');
}
/** @return array<string, mixed> */
public function defaults(): array
{
$configuration = array_fill_keys(DuplicateProjectConfiguration::copyableAreas(), []);
$configuration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
$configuration['ticket_statuses'] = [[
'configuration_key' => (string) Str::uuid(), 'name' => 'Backlog',
'position' => 1, 'board_column_key' => null,
]];
$configuration['ticket_types'] = [
['configuration_key' => (string) Str::uuid(), 'name' => 'Task', 'position' => 1, 'is_parent' => false],
['configuration_key' => (string) Str::uuid(), 'name' => 'Release', 'position' => 2, 'is_parent' => true],
];
$configuration['ticket_priorities'] = [[
'configuration_key' => (string) Str::uuid(), 'name' => 'Medium', 'position' => 1,
]];
$configuration['status_transitions'] = [[
'status_key' => $configuration['ticket_statuses'][0]['configuration_key'], 'target_keys' => [],
]];
return $configuration;
}
}
@@ -3,6 +3,7 @@
namespace App\Actions\Projects;
use App\Models\Project;
use Illuminate\Support\Facades\DB;
class ProjectTemplateOverrideManager
{
@@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager
return;
}
$project->loadMissing('projectTemplate');
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
return;
}
$overrides = array_values(array_unique([
...($project->template_overrides ?? []),
$area,
]));
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
DB::transaction(function () use ($project, $area): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$project->load('projectTemplate');
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
return;
}
$project->forceFill(['template_overrides' => array_values(array_unique([
...($project->template_overrides ?? []), $area,
]))])->saveQuietly();
}, 3);
}
/**
@@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager
*/
public function clear(Project $project, array $areas): void
{
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
DB::transaction(function () use ($project, $areas): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$project->forceFill(['template_overrides' => array_values(array_diff($project->template_overrides ?? [], $areas))])->saveQuietly();
}, 3);
}
public function withoutRecording(callable $callback): mixed
@@ -3,6 +3,8 @@
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class RefreshProjectBaseTemplate
{
@@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate
public function handle(Project $project, string $area): void
{
$project->loadMissing('projectTemplate');
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
return;
}
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
$project->projectTemplate->configuration_snapshot ?? [],
);
$project->projectTemplate->forceFill([
'configuration_snapshot' => array_replace(
$snapshot,
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
),
])->saveQuietly();
DB::transaction(function () use ($project, $area): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$template = ProjectTemplate::query()->find($project->project_template_id);
if ($template === null || $template->is_published) {
return;
}
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
$template->forceFill(['configuration_snapshot' => array_replace(
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
)])->saveQuietly();
}, 3);
}
}
@@ -71,7 +71,9 @@ class ResolveProjectTemplateState
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
return [
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
'effectiveConfiguration' => $currentConfiguration,
'localConfiguration' => array_diff_key($currentConfiguration, $templateConfiguration),
'localAreas' => collect(array_diff(array_keys($currentConfiguration), array_keys($templateConfiguration)))->map(fn (string $area): array => ['label' => $this->labelForArea($area), 'value' => $area])->values()->all(),
'inheritedAreas' => $inheritedAreas,
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
'inheritsFromTemplate' => $projectTemplate->is_published,
@@ -96,24 +98,20 @@ class ResolveProjectTemplateState
return [];
}
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null;
if (! is_array($templateStatuses)) {
return [];
}
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$replacementOptions = $statuses->take(count($templateStatuses))->map(
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
$templateKeys = array_column($templateStatuses, 'configuration_key');
$replacementOptions = collect($templateStatuses)->map(
static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']],
)->all();
$removedStatuses = $statuses->slice(count($templateStatuses))
->filter(static fn ($status): bool => $status->tickets_count > 0)
->map(static fn ($status): array => [
'id' => $status->id,
'name' => $status->name,
'ticketCount' => $status->tickets_count,
])
->values()
->all();
$removedStatuses = $statuses
->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0)
->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count])
->values()->all();
return $removedStatuses === [] ? [] : [
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
+22 -25
View File
@@ -19,37 +19,34 @@ class UpdateProjectTemplate
*/
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
{
$projectTemplate->loadMissing('sourceProject');
$sourceProject = $projectTemplate->sourceProject;
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$projectTemplate->configuration_snapshot ?? [],
);
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
->values()
->all();
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate {
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []);
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? []));
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
Arr::get($attributes, 'configuration_snapshot', $previousConfiguration),
))->only($copyAreas)->all();
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
->values()->all();
$projectTemplate->forceFill([
'name' => $attributes['name'],
'copy_areas' => $normalizedCopyAreas,
'name' => Arr::get($attributes, 'name'),
'copy_areas' => $copyAreas,
'configuration_snapshot' => $nextConfiguration,
])->save();
Project::query()
->where('project_template_id', $projectTemplate->id)
->orderBy('id')
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
->chunkById(100, function ($linkedProjects) use ($projectTemplate, $affectedAreas, $nextConfiguration, $previousConfiguration): void {
foreach ($linkedProjects as $linkedProject) {
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
$linkedProject = Project::query()
->whereKey($linkedProject->id)
->where('project_template_id', $projectTemplate->id)
->lockForUpdate()->first();
if ($linkedProject === null) {
continue;
}
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
$overrides = $linkedProject->template_overrides ?? [];
$replacementConfiguration = [];
@@ -81,8 +78,8 @@ class UpdateProjectTemplate
}
}
});
});
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
}, 3);
}
}
@@ -5,6 +5,7 @@ namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class UpdateProjectTemplateLink
{
@@ -16,6 +17,7 @@ class UpdateProjectTemplateLink
public function switch(Project $project, ProjectTemplate $template): void
{
DB::transaction(function () use ($project, $template): void {
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
@@ -42,19 +44,27 @@ class UpdateProjectTemplateLink
* @param list<string> $areas
* @param array<int|string, int|string> $statusReplacements
*/
public function reset(Project $project, array $areas, array $statusReplacements = []): void
public function reset(Project $project, array $areas, array $statusReplacements = []): array
{
$template = $project->projectTemplate;
if ($template === null) {
return;
return [];
}
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
return DB::transaction(function () use ($project, $template, $areas, $statusReplacements): array {
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
))->only($areas)->all();
if ($project->project_template_id !== $template->id) {
throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']);
}
$baseline = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
$configuration = collect($baseline)->only($areas)->all();
if (in_array('ticket_statuses', $areas, true)
&& ! in_array('status_transitions', $project->template_overrides ?? [], true)
&& isset($baseline['status_transitions'])) {
$configuration['status_transitions'] = $baseline['status_transitions'];
}
$blockedAreas = $this->duplicateProjectConfiguration->apply(
$project,
$configuration,
@@ -64,6 +74,8 @@ class UpdateProjectTemplateLink
$project->fresh(),
array_values(array_diff(array_keys($configuration), $blockedAreas)),
);
});
return $blockedAreas;
}, 3);
}
}
+5 -1
View File
@@ -161,7 +161,7 @@ class ProjectController extends Controller
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
$statusReplacements = Arr::get($validated, 'status_replacements', []);
$this->updateProjectTemplateLink->reset(
$blockedAreas = $this->updateProjectTemplateLink->reset(
$project->load('projectTemplate'),
$areas,
$statusReplacements,
@@ -171,6 +171,10 @@ class ProjectController extends Controller
? "Selected settings were reset to the {$template->name} template."
: "{$project->name} was reset to the {$template->name} template baseline.";
if ($blockedAreas !== []) {
$status = 'Some settings remain overridden because tickets or other configuration still use them.';
}
return back()->with('status', $status);
}
@@ -3,9 +3,12 @@
namespace App\Http\Controllers;
use App\Actions\Projects\CreateProjectTemplate;
use App\Actions\Projects\DeleteProjectTemplate;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Actions\Projects\UpdateProjectTemplate;
use App\Http\Requests\StoreProjectTemplateRequest;
use App\Http\Requests\StoreStandaloneProjectTemplateRequest;
use App\Http\Requests\UpdateProjectTemplateRequest;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource;
@@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller
{
public function __construct(
private readonly CreateProjectTemplate $createProjectTemplate,
private readonly DeleteProjectTemplate $deleteProjectTemplate,
private readonly UpdateProjectTemplate $updateProjectTemplate,
) {}
@@ -38,6 +42,22 @@ class ProjectTemplateController extends Controller
]);
}
public function createEmpty(ProjectConfigurationIdentity $identity): Response
{
return Inertia::render('project-templates/Create', [
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
'project' => null,
'initialConfiguration' => $identity->defaults(),
]);
}
public function storeEmpty(StoreStandaloneProjectTemplateRequest $request): RedirectResponse
{
$template = $this->createProjectTemplate->empty($request->validated());
return to_route('project-templates.edit', $template)->with('status', "Template {$template->name} was created.");
}
public function edit(ProjectTemplate $projectTemplate): Response
{
abort_if(! $projectTemplate->is_published, 404);
@@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller
abort_if(! $projectTemplate->is_published, 404);
$templateName = $projectTemplate->name;
$projectTemplate->delete();
$this->deleteProjectTemplate->handle($projectTemplate);
return to_route('project-templates.index')
->with('status', "Template {$templateName} was deleted.");
@@ -0,0 +1,141 @@
<?php
namespace App\Http\Requests\Concerns;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType;
use App\Enums\NotificationFilterAttribute;
use App\Enums\NotificationFilterOperator;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
trait ValidatesProjectTemplateConfiguration
{
/** @return array<string, mixed> */
protected function configurationRules(): array
{
$rules = ['configuration_snapshot' => ['sometimes', 'array:'.implode(',', DuplicateProjectConfiguration::copyableAreas())]];
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
$path = 'configuration_snapshot.'.$area;
$rules[$path] = ['sometimes', 'array', 'max:200'];
$rules[$path.'.*.configuration_key'] = ['required', 'uuid', 'distinct:strict'];
}
$rules['configuration_snapshot.project_settings'] = ['sometimes', 'array:show_author,show_story_points'];
foreach (['show_author', 'show_story_points'] as $field) {
$rules['configuration_snapshot.project_settings.'.$field] = ['required_with:configuration_snapshot.project_settings', 'boolean'];
}
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities'] as $area) {
$rules["configuration_snapshot.{$area}.*.name"] = ['required', 'string', 'max:255'];
}
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_fields', 'ticket_link_types'] as $area) {
$rules["configuration_snapshot.{$area}.*.position"] = ['required', 'integer', 'min:1', 'max:10000'];
if ($area !== 'ticket_statuses') {
$rules["configuration_snapshot.{$area}.*.position"][] = 'distinct:strict';
}
}
$fields = [
'board_columns' => 'configuration_key,name,position',
'ticket_statuses' => 'configuration_key,name,position,board_column_key',
'ticket_types' => 'configuration_key,name,position,is_parent',
'ticket_priorities' => 'configuration_key,name,position',
'ticket_fields' => 'configuration_key,label,position,field_type',
'ticket_link_types' => 'configuration_key,label,counterpart_label,position',
'repository_connections' => 'configuration_key,provider,base_url,provider_repository_id,owner_name,repository_name,repository_full_name,repository_url',
'notification_actions' => 'configuration_key,name,enabled,event_type,channel,channel_configuration,message_template,delivery_url,filters',
];
foreach ($fields as $area => $allowed) {
$rules["configuration_snapshot.{$area}.*"] = ['array:'.$allowed];
}
$rules['configuration_snapshot.ticket_statuses.*.board_column_key'] = ['nullable', 'uuid'];
$rules['configuration_snapshot.ticket_types.*.is_parent'] = ['required', 'boolean'];
$rules['configuration_snapshot.ticket_fields.*.field_type'] = ['required', Rule::in(['number', 'user'])];
$rules['configuration_snapshot.ticket_fields.*.label'] = ['required', 'string', 'max:255'];
$rules['configuration_snapshot.ticket_link_types.*.label'] = ['required', 'string', 'max:255'];
$rules['configuration_snapshot.ticket_link_types.*.counterpart_label'] = ['required', 'string', 'max:255'];
$rules['configuration_snapshot.status_transitions'] = ['sometimes', 'array', 'max:200'];
$rules['configuration_snapshot.status_transitions.*'] = ['array:status_key,target_keys'];
$rules['configuration_snapshot.status_transitions.*.status_key'] = ['required', 'uuid', 'distinct:strict'];
$rules['configuration_snapshot.status_transitions.*.target_keys'] = ['present', 'array', 'max:200'];
$rules['configuration_snapshot.status_transitions.*.target_keys.*'] = ['uuid'];
foreach (['provider_repository_id', 'owner_name', 'repository_name', 'repository_full_name'] as $field) {
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'string', 'max:255'];
}
$rules['configuration_snapshot.repository_connections.*.provider'] = ['required', Rule::in(['gitea'])];
foreach (['base_url', 'repository_url'] as $field) {
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'url:http,https', 'max:255'];
}
$prefix = 'configuration_snapshot.notification_actions.*.';
$rules[$prefix.'name'] = ['required', 'string', 'max:255'];
$rules[$prefix.'enabled'] = ['required', 'boolean'];
$rules[$prefix.'event_type'] = ['required', Rule::in(NotificationEventType::values())];
$rules[$prefix.'channel'] = ['required', Rule::in(NotificationChannel::values())];
$rules[$prefix.'delivery_url'] = ['required', 'url:http,https', 'max:2048'];
$rules[$prefix.'message_template'] = ['nullable', 'string', 'max:2000'];
$rules[$prefix.'channel_configuration'] = ['sometimes', 'array:allowed_mentions'];
$rules[$prefix.'channel_configuration.allowed_mentions'] = ['sometimes', 'array'];
$rules[$prefix.'channel_configuration.allowed_mentions.*'] = [Rule::in(['everyone', 'roles', 'users'])];
$rules[$prefix.'filters'] = ['sometimes', 'array', 'max:25'];
$rules[$prefix.'filters.*'] = ['array:attribute,operator,value'];
$rules[$prefix.'filters.*.attribute'] = ['required', Rule::in(NotificationFilterAttribute::values())];
$rules[$prefix.'filters.*.operator'] = ['required', Rule::in(NotificationFilterOperator::values())];
$rules[$prefix.'filters.*.value'] = ['nullable'];
return $rules;
}
/** @return array<int, callable(Validator): void> */
public function after(): array
{
return [function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$validated = $validator->validated();
$configuration = Arr::get($validated, 'configuration_snapshot', []);
$areas = Arr::get($validated, 'copy_areas', []);
$columns = array_column(Arr::get($configuration, 'board_columns', []), 'configuration_key');
$statuses = array_column(Arr::get($configuration, 'ticket_statuses', []), 'configuration_key');
foreach (Arr::get($configuration, 'ticket_statuses', []) as $index => $status) {
$key = Arr::get($status, 'board_column_key');
if ($key !== null && in_array('board_columns', $areas, true) && ! in_array($key, $columns, true)) {
$validator->errors()->add("configuration_snapshot.ticket_statuses.{$index}.board_column_key", 'Choose a column from this template.');
}
}
foreach (Arr::get($configuration, 'status_transitions', []) as $index => $transition) {
if (! in_array(Arr::get($transition, 'status_key'), $statuses, true)
|| array_diff(Arr::get($transition, 'target_keys', []), $statuses) !== []) {
$validator->errors()->add("configuration_snapshot.status_transitions.{$index}", 'Choose statuses from this template.');
}
if (in_array('status_transitions', $areas, true) && ! in_array('ticket_statuses', $areas, true)
&& Arr::get($transition, 'target_keys', []) !== []) {
$validator->errors()->add('copy_areas', 'Include ticket statuses when defining template transitions.');
}
}
$repositoryIdentifiers = [];
foreach (Arr::get($configuration, 'repository_connections', []) as $index => $connection) {
$identifier = serialize([$connection['provider'], $connection['base_url'], $connection['provider_repository_id']]);
if (isset($repositoryIdentifiers[$identifier])) {
$validator->errors()->add("configuration_snapshot.repository_connections.{$index}.provider_repository_id", 'This repository is already included in the template.');
}
$repositoryIdentifiers[$identifier] = true;
}
foreach (Arr::get($configuration, 'notification_actions', []) as $index => $action) {
if (Arr::get($action, 'channel') === 'discord' && blank(Arr::get($action, 'message_template'))) {
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.message_template", 'A Discord message is required.');
}
foreach (Arr::get($action, 'filters', []) as $filterIndex => $filter) {
$operator = Arr::get($filter, 'operator');
$value = Arr::get($filter, 'value');
$valid = is_string($value) || $value === null || (is_array($value) && collect($value)->every(static fn ($item): bool => is_string($item)));
if (! $valid || (in_array($operator, ['equals', 'not_equals', 'in'], true) && blank($value))
|| (in_array($operator, ['exists', 'not_exists'], true) && filled($value))) {
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.filters.{$filterIndex}.value", 'Choose a value that matches the filter operator.');
}
}
}
}];
}
}
@@ -34,11 +34,7 @@ class ResetProjectTemplateRequest extends FormRequest
Rule::in([$project->key]),
],
'status_replacements' => ['sometimes', 'array'],
'status_replacements.*' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
],
'status_replacements.*' => ['required'],
];
}
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
/** @var Project $project */
$project = $this->route('project');
$validated = $validator->validated();
$areas = array_values(Arr::get(
$validated,
'areas',
DuplicateProjectConfiguration::copyableAreas(),
));
$project->loadMissing('projectTemplate');
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
foreach ($areas as $area) {
if (! array_key_exists($area, $configuration)) {
$validator->errors()->add('areas', 'This template does not define the selected settings area.');
}
}
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
return;
}
$project->loadMissing('projectTemplate');
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
$project->projectTemplate?->configuration_snapshot ?? [],
);
$templateStatuses = Arr::get(
$configuration,
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
[],
);
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
$allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
$replacements = Arr::get($validated, 'status_replacements', []);
foreach ($statuses->slice(count($templateStatuses)) as $status) {
if ($status->tickets_count === 0) {
foreach ($replacements as $id => $replacement) {
if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
|| ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
$validator->errors()->add("status_replacements.{$id}", 'Choose a replacement from this template for a status in this project.');
}
}
foreach ($statuses as $status) {
if (in_array($status->configuration_key, $templateKeys, true) || $status->tickets_count === 0) {
continue;
}
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
if (! $allowedReplacementIds->contains($replacementId)) {
$validator->errors()->add(
"status_replacements.{$status->id}",
"Choose a replacement status for {$status->name}.",
);
$replacement = Arr::get($replacements, (string) $status->id);
if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
$validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
}
}
},
@@ -12,7 +12,8 @@ class StoreProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create-project') ?? false;
return ($this->user()?->can('create-project') ?? false)
&& ($this->route('project') === null || $this->user()->can('admin-project', $this->route('project')));
}
protected function prepareForValidation(): void
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
*/
public function rules(): array
{
$project = $this->route('project');
$baseTemplate = $project?->projectTemplate;
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
'copy_areas' => ['required', 'array', 'min:1'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))],
'copy_areas' => ['present', 'array'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
];
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Requests;
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
class StoreStandaloneProjectTemplateRequest extends StoreProjectTemplateRequest
{
use ValidatesProjectTemplateConfiguration;
public function rules(): array
{
return [...parent::rules(), ...$this->configurationRules()];
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
use App\Models\ProjectTemplate;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
class UpdateProjectTemplateRequest extends FormRequest
{
use ValidatesProjectTemplateConfiguration;
public function authorize(): bool
{
return $this->user()?->can('create-project') ?? false;
@@ -25,8 +28,9 @@ class UpdateProjectTemplateRequest extends FormRequest
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
return [
...$this->configurationRules(),
'name' => ['required', 'string', 'max:255'],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas' => ['present', 'array'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [
Rule::excludeIf(! $requiresImpactConfirmation),
+2
View File
@@ -32,6 +32,8 @@ class ProjectResource extends JsonResource
? null
: [
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
'local_configuration' => Arr::get($templateState, 'localConfiguration', []),
'local_areas' => Arr::get($templateState, 'localAreas', []),
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
+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\HasManyThrough;
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
class BoardColumn extends Model
{
/** @use HasFactory<BoardColumnFactory> */
@@ -5,11 +5,44 @@ namespace App\Models\Concerns;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
trait SyncsProjectTemplateOwnership
{
/** @param array<string, mixed> $options */
public function save(array $options = []): bool
{
return DB::transaction(function () use ($options): bool {
$this->syncProjectTemplateOwnership();
if ($this->getAttribute('project_id') !== null) {
$project = Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
if ($project?->project_template_id !== null) {
$this->setAttribute('project_template_id', $project->project_template_id);
}
}
return parent::save($options);
}, 3);
}
public function delete(): ?bool
{
return DB::transaction(function (): ?bool {
if ($this->getAttribute('project_id') !== null) {
Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
}
return parent::delete();
}, 3);
}
protected function syncProjectTemplateOwnership(): void
{
if ($this->getAttribute('configuration_key') === null) {
$this->setAttribute('configuration_key', (string) Str::uuid());
}
$projectTemplateId = $this->getAttribute('project_template_id');
$projectId = $this->getAttribute('project_id');
+1 -1
View File
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
#[Fillable(['configuration_key',
'channel',
'channel_configuration',
'delivery_url',
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
#[Fillable(['configuration_key',
'base_url',
'owner_name',
'project_id',
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
#[Fillable(['configuration_key', 'field_type', 'label', 'position', 'project_id', 'project_template_id'])]
class TicketFieldDefinition extends Model
{
/**
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
#[Fillable(['configuration_key', 'label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
class TicketLinkType extends Model
{
/** @use HasFactory<TicketLinkTypeFactory> */
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
class TicketPriority extends Model
{
/** @use HasFactory<TicketPriorityFactory> */
+1 -1
View File
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
#[Fillable(['configuration_key', 'board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
class TicketStatus extends Model
{
/** @use HasFactory<TicketStatusFactory> */
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
class TicketType extends Model
{
/** @use HasFactory<TicketTypeFactory> */
@@ -30,6 +30,9 @@ class ProjectConfigurationObserver
public function deleted(Model $model): void
{
$this->markOverride($model);
if ($model instanceof TicketStatus && $model->project !== null) {
$this->refreshProjectBaseTemplate->handle($model->project, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS);
}
}
private function markOverride(Model $model): void
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\BoardColumn;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<BoardColumn>
@@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'name' => fake()->unique()->words(2, true),
'position' => 1,
'project_id' => Project::factory(),
@@ -7,6 +7,7 @@ use App\Enums\NotificationEventType;
use App\Models\NotificationAction;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<NotificationAction>
@@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'channel' => NotificationChannel::Webhook,
'channel_configuration' => [],
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
@@ -6,6 +6,7 @@ use App\Actions\Git\GitProviderKey;
use App\Models\Project;
use App\Models\RepositoryConnection;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<RepositoryConnection>
@@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'base_url' => 'https://git.'.fake()->domainName(),
'owner_name' => fake()->userName(),
'project_id' => Project::factory(),
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketFieldDefinition>
@@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
'label' => fake()->unique()->words(2, true),
'position' => 1,
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketLinkType;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketLinkType>
@@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory
$label = fake()->unique()->words(2, true);
return [
'configuration_key' => (string) Str::uuid(),
'label' => $label,
'counterpart_label' => $label,
'position' => 1,
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketPriority;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketPriority>
@@ -19,6 +20,7 @@ class TicketPriorityFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
'position' => 1,
'project_id' => Project::factory(),
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketStatus>
@@ -19,6 +20,7 @@ class TicketStatusFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'board_column_id' => null,
'name' => fake()->unique()->words(2, true),
'position' => 1,
+2
View File
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketType;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketType>
@@ -19,6 +20,7 @@ class TicketTypeFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'is_parent' => false,
'name' => fake()->unique()->jobTitle(),
'position' => 1,
@@ -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 { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -12,7 +11,13 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import { index, update } from '@/routes/project-templates';
import {
index,
update,
store as storeStandalone,
} from '@/routes/project-templates';
import TemplateConfigurationEditor from '@/components/projects/TemplateConfigurationEditor.vue';
import { createTemplateConfiguration } from '@/lib/project-template-configuration';
import { show } from '@/routes/projects';
import { store } from '@/routes/projects/templates';
import type { ProjectDuplicateCopyAreaOption } from '@/types';
@@ -22,11 +27,18 @@ import type { ProjectTemplateModel } from '@/types/models/project-template-model
const props = defineProps<{
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project?: ProjectModel;
project?: ProjectModel | null;
initialConfiguration?: Record<string, unknown>;
template?: ProjectTemplateModel;
}>();
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const publishingBase = computed(() =>
props.project?.project_template &&
!props.project.project_template.is_published
? props.project.project_template
: null,
);
const source = computed(() => props.template?.source_project ?? props.project);
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
const form = useForm(
@@ -34,14 +46,23 @@ const form = useForm(
{
name:
props.template?.name ??
t('templates.defaultName', { project: props.project?.name ?? '' }),
(props.project
? t('templates.defaultName', { project: props.project.name })
: ''),
slug:
props.template?.slug ??
`${props.project?.key.toLowerCase()}-template`,
publishingBase.value?.slug ??
(props.project
? `${props.project.key.toLowerCase()}-template`
: ''),
copy_areas: props.template
? [...props.template.copy_areas]
: props.copyAreaOptions.map((area) => area.value),
confirm_linked_projects_update: false,
configuration_snapshot: createTemplateConfiguration(
props.template?.configuration_snapshot ??
props.initialConfiguration,
),
},
);
const protection = useFormProtection(form);
@@ -53,13 +74,10 @@ const copyAreasError = computed(
)?.[1],
);
const cancelTarget = computed(() =>
props.template ? index() : show(props.project!.id),
props.project ? show(props.project.id) : index(),
);
function submit(): void {
if (
!source.value ||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
) {
if (linkedCount.value > 0 && !form.confirm_linked_projects_update) {
return;
}
form.transform((data) =>
@@ -67,6 +85,7 @@ function submit(): void {
? {
name: data.name,
copy_areas: data.copy_areas,
configuration_snapshot: data.configuration_snapshot,
...(linkedCount.value > 0
? {
confirm_linked_projects_update:
@@ -74,10 +93,21 @@ function submit(): void {
}
: {}),
}
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
: {
name: data.name,
slug: data.slug,
copy_areas: data.copy_areas,
...(!props.project
? { configuration_snapshot: data.configuration_snapshot }
: {}),
},
);
protection.submit(
props.template ? update(props.template.id) : store(props.project!.id),
props.template
? update(props.template.id)
: props.project
? store(props.project.id)
: storeStandalone(),
{ preserveScroll: true },
);
}
@@ -90,12 +120,6 @@ function submit(): void {
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<FeedbackState
v-if="!source"
state="error"
:title="t('templates.missingSource')"
:description="t('templates.missingSourceHelp')"
/>
<div
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
>
@@ -115,7 +139,7 @@ function submit(): void {
name="name"
required
maxlength="255"
:disabled="form.processing || !source"
:disabled="form.processing"
:placeholder="t('templates.namePlaceholder')"
:invalid="!!form.errors.name"
aria-describedby="name-error"
@@ -139,7 +163,11 @@ function submit(): void {
name="slug"
required
maxlength="255"
:disabled="!!template || form.processing || !source"
:disabled="
!!template ||
!!publishingBase ||
form.processing
"
:placeholder="t('templates.slugPlaceholder')"
:invalid="!!form.errors.slug"
aria-describedby="slug-help slug-error"
@@ -152,7 +180,7 @@ function submit(): void {
>
{{
t(
template
template || publishingBase
? 'templates.slugLocked'
: 'templates.slugHelp',
)
@@ -166,7 +194,7 @@ function submit(): void {
</section>
<fieldset
class="bg-card min-w-0 rounded-md border p-5"
:disabled="form.processing || !source"
:disabled="form.processing"
aria-describedby="areas-help copy-areas-error"
>
<legend class="text-title px-1">
@@ -231,6 +259,14 @@ function submit(): void {
:message="copyAreasError"
/>
</fieldset>
<TemplateConfigurationEditor
v-if="template || !project"
v-model="form.configuration_snapshot"
:areas="form.copy_areas"
:options="copyAreaOptions"
:errors="form.errors"
:disabled="form.processing"
/>
</div>
<aside
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
@@ -310,7 +346,7 @@ function submit(): void {
id="confirm-linked"
v-model="form.confirm_linked_projects_update"
type="checkbox"
:disabled="form.processing || !source"
:disabled="form.processing"
:aria-invalid="!!form.errors.confirm_linked_projects_update"
aria-describedby="linked-help linked-error"
class="accent-primary mt-0.5 size-5 shrink-0"
@@ -335,8 +371,7 @@ function submit(): void {
:loading="form.processing"
:loading-label="t('forms.saving')"
:disabled="
!source ||
(linkedCount > 0 && !form.confirm_linked_projects_update)
linkedCount > 0 && !form.confirm_linked_projects_update
"
>{{
t(template ? 'templates.saveChanges' : 'templates.saveNew')
@@ -0,0 +1,662 @@
<script setup lang="ts">
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-vue-next';
import { computed } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import { notificationLabel } from '@/lib/notification-labels';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import type {
TemplateConfiguration,
TemplateConfigurationEditorProps,
TemplateConfigurationItem,
TemplateListArea,
TemplateTextField,
TemplateNotificationFilter,
} from '@/types/project-template-configuration';
const props = defineProps<TemplateConfigurationEditorProps>();
const configuration = defineModel<TemplateConfiguration>({ required: true });
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const listAreas = computed(
() =>
props.options
.map((option) => option.value)
.filter(
(area) =>
props.areas.includes(area) &&
area !== 'project_settings' &&
area !== 'status_transitions',
) as TemplateListArea[],
);
const selectClass =
'border-input bg-background min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm';
const filterAttributes = [
'status.from.id',
'status.to.id',
'status.from.label',
'status.to.label',
'project.id',
'project.key',
'project.name',
'ticket.id',
'ticket.key',
'ticket.title',
'ticket.url',
'actor.id',
'actor.name',
'assignee.from.id',
'assignee.from.name',
'assignee.to.id',
'assignee.to.name',
];
const filterOperators = ['equals', 'not_equals', 'exists', 'not_exists', 'in'];
function fields(area: TemplateListArea): TemplateTextField[] {
if (area === 'ticket_fields') {
return [{ key: 'label', label: t('templateEditor.label') }];
}
if (area === 'ticket_link_types') {
return [
{ key: 'label', label: t('templateEditor.label') },
{
key: 'counterpart_label',
label: t('templateEditor.counterpart'),
},
];
}
if (area === 'repository_connections') {
return [
{ key: 'base_url', label: t('templateEditor.server') },
{
key: 'provider_repository_id',
label: t('templateEditor.repositoryId'),
},
{ key: 'owner_name', label: t('templateEditor.owner') },
{
key: 'repository_name',
label: t('templateEditor.repositoryName'),
},
{
key: 'repository_full_name',
label: t('templateEditor.fullName'),
},
{ key: 'repository_url', label: t('templateEditor.repositoryUrl') },
];
}
return [{ key: 'name', label: t('templateEditor.name') }];
}
function reindex(area: TemplateListArea): void {
const counters = new Map<string, number>();
configuration.value[area].forEach((item, index) => {
if (
area === 'repository_connections' ||
area === 'notification_actions'
) {
return;
}
const group = item.board_column_key ?? '';
counters.set(group, (counters.get(group) ?? 0) + 1);
item.position =
area === 'ticket_statuses' ? counters.get(group) : index + 1;
});
}
function add(area: TemplateListArea): void {
const item: TemplateConfigurationItem = {
configuration_key: crypto.randomUUID(),
};
if (area === 'ticket_fields') {
Object.assign(item, { label: '', field_type: 'number' });
} else if (area === 'ticket_link_types') {
Object.assign(item, { label: '', counterpart_label: '' });
} else if (area === 'repository_connections') {
Object.assign(item, {
provider: 'gitea',
base_url: '',
provider_repository_id: '',
owner_name: '',
repository_name: '',
repository_full_name: '',
repository_url: '',
});
} else {
Object.assign(item, { name: '' });
}
if (area === 'ticket_statuses') {
item.board_column_key = null;
configuration.value.status_transitions.push({
status_key: item.configuration_key,
target_keys: [],
});
}
if (area === 'ticket_types') {
item.is_parent = false;
}
if (area === 'notification_actions') {
Object.assign(item, {
enabled: true,
event_type: 'ticket_status_changed',
channel: 'webhook',
delivery_url: '',
message_template: '',
channel_configuration: { allowed_mentions: [] },
filters: [],
});
}
configuration.value[area].push(item);
reindex(area);
}
function remove(area: TemplateListArea, index: number): void {
const item = configuration.value[area][index];
if (area === 'board_columns') {
configuration.value.ticket_statuses.forEach((status) => {
if (status.board_column_key === item.configuration_key) {
status.board_column_key = null;
}
});
reindex('ticket_statuses');
}
if (area === 'ticket_statuses') {
configuration.value.status_transitions =
configuration.value.status_transitions.filter(
(transition) =>
transition.status_key !== item.configuration_key,
);
configuration.value.status_transitions.forEach((transition) => {
transition.target_keys = transition.target_keys.filter(
(key) => key !== item.configuration_key,
);
});
}
configuration.value[area].splice(index, 1);
reindex(area);
}
function move(area: TemplateListArea, index: number, offset: number): void {
const entries = configuration.value[area];
const next = index + offset;
if (next < 0 || next >= entries.length) {
return;
}
[entries[index], entries[next]] = [entries[next], entries[index]];
reindex(area);
}
function targets(statusKey: string): string[] {
return (
configuration.value.status_transitions.find(
(transition) => transition.status_key === statusKey,
)?.target_keys ?? []
);
}
function toggleTarget(from: string, to: string, checked: boolean): void {
let transition = configuration.value.status_transitions.find(
(entry) => entry.status_key === from,
);
if (!transition) {
transition = { status_key: from, target_keys: [] };
configuration.value.status_transitions.push(transition);
}
transition.target_keys = checked
? [...new Set([...transition.target_keys, to])]
: transition.target_keys.filter((key) => key !== to);
}
function filterChanged(filter: TemplateNotificationFilter): void {
if (['exists', 'not_exists'].includes(filter.operator)) {
filter.value = null;
} else if (filter.operator === 'in') {
filter.value = Array.isArray(filter.value)
? filter.value
: filter.value
? [filter.value]
: [];
} else if (Array.isArray(filter.value)) {
filter.value = filter.value[0] ?? '';
}
}
function mention(
item: TemplateConfigurationItem,
value: string,
checked: boolean,
): void {
const settings = (item.channel_configuration ??= { allowed_mentions: [] });
settings.allowed_mentions = checked
? [...new Set([...(settings.allowed_mentions ?? []), value])]
: (settings.allowed_mentions ?? []).filter((entry) => entry !== value);
}
function areaErrors(area: string): string[] {
return [
...new Set(
Object.entries(props.errors)
.filter(
([key]) =>
key === `configuration_snapshot.${area}` ||
key.startsWith(`configuration_snapshot.${area}.`),
)
.map(([, value]) => value)
.filter((value): value is string => !!value),
),
];
}
</script>
<template>
<fieldset class="gap-column grid min-w-0" :disabled="disabled">
<legend class="text-title mb-4">{{ t('templateEditor.title') }}</legend>
<p class="text-muted-foreground text-caption">
{{ t('templateEditor.help') }}
</p>
<details
v-if="areas.includes('project_settings')"
class="bg-card rounded-md border p-5"
open
>
<summary class="cursor-pointer font-medium">
{{ areaText('project_settings', 'label') }}
</summary>
<div class="mt-4 grid gap-4">
<Label class="flex items-center gap-3"
><Checkbox
v-model="configuration.project_settings.show_author"
/>{{ t('templateEditor.showAuthor') }}</Label
>
<Label class="flex items-center gap-3"
><Checkbox
v-model="
configuration.project_settings.show_story_points
"
/>{{ t('templateEditor.showPoints') }}</Label
>
<InputError
v-for="error in areaErrors('project_settings')"
:key="error"
:message="error"
/>
</div>
</details>
<details
v-for="area in listAreas"
:key="area"
class="bg-card min-w-0 rounded-md border p-5"
:open="
['board_columns', 'ticket_statuses', 'ticket_types'].includes(
area,
)
"
>
<summary class="cursor-pointer font-medium">
{{ areaText(area, 'label') }} ({{ configuration[area].length }})
</summary>
<div class="mt-4 grid min-w-0 gap-4">
<article
v-for="(item, index) in configuration[area]"
:key="item.configuration_key"
class="grid min-w-0 gap-4 rounded-md border p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-caption font-medium">{{
item.name ||
item.label ||
t('templateEditor.entry', { number: index + 1 })
}}</span>
<div class="flex gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:disabled="index === 0"
:aria-label="t('templateEditor.moveUp')"
@click="move(area, index, -1)"
><ArrowUp
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="
index === configuration[area].length - 1
"
:aria-label="t('templateEditor.moveDown')"
@click="move(area, index, 1)"
><ArrowDown
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="t('templateEditor.remove')"
@click="remove(area, index)"
><Trash2
/></Button>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div
v-for="field in fields(area)"
:key="field.key"
class="grid min-w-0 gap-2"
>
<Label
:for="`${item.configuration_key}-${field.key}`"
>{{ field.label }}</Label
>
<Input
:id="`${item.configuration_key}-${field.key}`"
v-model="item[field.key]"
required
/>
</div>
<div
v-if="area === 'ticket_statuses'"
class="grid gap-2"
>
<Label :for="`${item.configuration_key}-column`">{{
t('templateEditor.column')
}}</Label>
<select
:id="`${item.configuration_key}-column`"
v-model="item.board_column_key"
:class="selectClass"
@change="reindex('ticket_statuses')"
>
<option :value="null">
{{ t('templateEditor.backlog') }}
</option>
<option
v-for="column in configuration.board_columns"
:key="column.configuration_key"
:value="column.configuration_key"
>
{{ column.name }}
</option>
</select>
</div>
<Label
v-if="area === 'ticket_types'"
class="flex items-center gap-3"
><Checkbox v-model="item.is_parent" />{{
t('templateEditor.parentType')
}}</Label
>
<div v-if="area === 'ticket_fields'" class="grid gap-2">
<Label :for="`${item.configuration_key}-type`">{{
t('templateEditor.fieldType')
}}</Label>
<select
:id="`${item.configuration_key}-type`"
v-model="item.field_type"
:class="selectClass"
>
<option value="number">
{{ t('templateEditor.number') }}
</option>
<option value="user">
{{ t('templateEditor.user') }}
</option>
</select>
</div>
</div>
<template v-if="area === 'notification_actions'">
<Label class="flex items-center gap-3"
><Checkbox v-model="item.enabled" />{{
t('templateEditor.enabled')
}}</Label
>
<div class="grid gap-4 sm:grid-cols-2">
<Label class="grid gap-2"
>{{ t('templateEditor.event')
}}<select
v-model="item.event_type"
:class="selectClass"
>
<option
v-for="event in [
'ticket_status_changed',
'ticket_create',
'ticket_assignee_changed',
]"
:key="event"
:value="event"
>
{{
notificationLabel(
t,
'event',
event,
event,
)
}}
</option>
</select></Label
>
<Label class="grid gap-2"
>{{ t('templateEditor.channel')
}}<select
v-model="item.channel"
:class="selectClass"
>
<option value="webhook">
{{ t('notifications.channel.webhook') }}
</option>
<option value="discord">
{{ t('notifications.channel.discord') }}
</option>
</select></Label
>
<Label class="grid gap-2 sm:col-span-2"
>{{ t('templateEditor.deliveryUrl')
}}<Input
v-model="item.delivery_url"
required
type="url"
/></Label>
<Label class="grid gap-2 sm:col-span-2"
>{{ t('templateEditor.message')
}}<textarea
v-model="item.message_template"
:class="selectClass"
rows="4"
:required="item.channel === 'discord'"
maxlength="2000"
/>
</Label>
</div>
<fieldset
v-if="item.channel === 'discord'"
class="grid gap-3 rounded-md border p-3"
>
<legend>
{{ t('templateEditor.allowedMentions') }}
</legend>
<Label
v-for="value in ['everyone', 'roles', 'users']"
:key="value"
class="flex items-center gap-3"
>
<Checkbox
:model-value="
item.channel_configuration?.allowed_mentions?.includes(
value,
) ?? false
"
@update:model-value="
mention(item, value, $event === true)
"
/>{{
notificationLabel(
t,
'mention',
value,
value,
)
}}
</Label>
</fieldset>
<div class="grid gap-3">
<div
v-for="(filter, filterIndex) in item.filters"
:key="filterIndex"
class="grid gap-3 rounded-md border p-3 sm:grid-cols-2"
>
<Label class="grid gap-2"
>{{ t('notifications.attribute')
}}<select
v-model="filter.attribute"
@change="
filter.value =
filter.operator === 'in'
? []
: null
"
:class="selectClass"
>
<option
v-for="attribute in filterAttributes"
:key="attribute"
:value="attribute"
>
{{
notificationLabel(
t,
'attribute',
attribute,
attribute,
)
}}
</option>
</select></Label
>
<Label class="grid gap-2"
>{{ t('notifications.operator')
}}<select
v-model="filter.operator"
@change="filterChanged(filter)"
:class="selectClass"
>
<option
v-for="operator in filterOperators"
:key="operator"
:value="operator"
>
{{
notificationLabel(
t,
'operator',
operator,
operator,
)
}}
</option>
</select></Label
>
<Label
v-if="
!['exists', 'not_exists'].includes(
filter.operator,
)
"
class="grid gap-2"
>{{ t('notifications.value')
}}<Input
:model-value="
Array.isArray(filter.value)
? filter.value.join(', ')
: (filter.value ?? '')
"
@update:model-value="
filter.value = String($event)
"
/></Label>
<Button
type="button"
variant="outline"
@click="
item.filters?.splice(filterIndex, 1)
"
>{{
t('notifications.removeFilter')
}}</Button
>
</div>
<Button
type="button"
variant="outline"
@click="
(item.filters ??= []).push({
attribute: 'status.to.label',
operator: 'equals',
value: '',
})
"
><Plus />{{
t('notifications.addFilter')
}}</Button
>
</div>
</template>
</article>
<InputError
v-for="error in areaErrors(area)"
:key="error"
:message="error"
/>
<Button
type="button"
variant="outline"
class="justify-self-start"
@click="add(area)"
><Plus />{{ t('templateEditor.addEntry') }}</Button
>
</div>
</details>
<details
v-if="areas.includes('status_transitions')"
class="bg-card rounded-md border p-5"
open
>
<summary class="cursor-pointer font-medium">
{{ areaText('status_transitions', 'label') }}
</summary>
<div class="mt-4 grid gap-4">
<fieldset
v-for="status in configuration.ticket_statuses"
:key="status.configuration_key"
class="grid gap-3 rounded-md border p-4"
>
<legend class="px-1 font-medium">{{ status.name }}</legend>
<Label
v-for="target in configuration.ticket_statuses.filter(
(entry) =>
entry.configuration_key !==
status.configuration_key,
)"
:key="target.configuration_key"
class="flex items-center gap-3"
>
<Checkbox
:model-value="
targets(status.configuration_key).includes(
target.configuration_key,
)
"
@update:model-value="
toggleTarget(
status.configuration_key,
target.configuration_key,
$event === true,
)
"
/>{{ target.name }}
</Label>
</fieldset>
<InputError
v-for="error in areaErrors('status_transitions')"
:key="error"
:message="error"
/>
</div>
</details>
</fieldset>
</template>
@@ -19,6 +19,7 @@ import {
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TemplateResetForm } from '@/types/project-template-configuration';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
const props = defineProps<{
@@ -27,10 +28,7 @@ const props = defineProps<{
templateState?: ProjectTemplateState | null;
}>();
const { t } = useLocalization();
const form = useForm<{
areas: string[];
status_replacements: Record<string, number | undefined>;
}>({
const form = useForm<TemplateResetForm>({
areas: [],
status_replacements: {},
});
@@ -245,6 +243,15 @@ function resetArea(): void {
v-if="statusRequirement"
class="gap-column grid min-w-0"
>
<p
v-if="
statusRequirement.replacementOptions
.length === 0
"
class="text-destructive text-sm"
>
{{ t('inheritance.noReplacementTargets') }}
</p>
<p class="text-caption text-muted-foreground">
{{ t('inheritance.statusHelp') }}
</p>
@@ -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.',
'notifications.variable.assignee_to':
'New assignee name when the event updates assignment; blank when unassigned.',
'templateEditor.title': 'Template configuration',
'templateEditor.help':
'Changes apply to linked projects that still inherit each group. Used entries remain protected as local overrides.',
'templateEditor.name': 'Name',
'templateEditor.label': 'Label',
'templateEditor.counterpart': 'Reverse label',
'templateEditor.server': 'Server URL',
'templateEditor.repositoryId': 'Repository ID',
'templateEditor.owner': 'Owner',
'templateEditor.repositoryName': 'Repository name',
'templateEditor.fullName': 'Owner / repository',
'templateEditor.repositoryUrl': 'Repository URL',
'templateEditor.entry': 'Entry {number}',
'templateEditor.moveUp': 'Move up',
'templateEditor.moveDown': 'Move down',
'templateEditor.remove': 'Remove entry',
'templateEditor.addEntry': 'Add entry',
'templateEditor.showAuthor': 'Show ticket author',
'templateEditor.showPoints': 'Show story points',
'templateEditor.column': 'Board column',
'templateEditor.backlog': 'Backlog (no board column)',
'templateEditor.parentType': 'Allows child tickets',
'templateEditor.fieldType': 'Field type',
'templateEditor.number': 'Number',
'templateEditor.user': 'User',
'templateEditor.allowedMentions': 'Allowed Discord mentions',
'templateEditor.enabled': 'Enabled',
'templateEditor.event': 'Event',
'templateEditor.channel': 'Channel',
'templateEditor.deliveryUrl': 'Delivery URL',
'templateEditor.message': 'Message template',
'templates.createEmpty': 'New empty template',
'templates.description':
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
'templates.updated': 'Project templates updated.',
@@ -264,7 +296,7 @@ export const en = {
other: '{count} linked projects',
},
'templates.savedFrom': 'Saved from {project} ({key}).',
'templates.sourceUnavailable': 'Source project unavailable',
'templates.sourceUnavailable': 'Independent template',
'templates.edit': 'Edit',
'templates.use': 'Use template',
'templates.createTitle': 'Save {project} as a template',
@@ -272,7 +304,7 @@ export const en = {
'Choose the configuration to reuse in future projects.',
'templates.editTitle': 'Edit {name}',
'templates.editDescription':
'Refresh the template from its source project and review the impact on linked projects.',
'Edit the template configuration and review the impact on linked projects.',
'templates.defaultName': '{project} template',
'templates.details': 'Template details',
'templates.name': 'Template name',
@@ -285,13 +317,15 @@ export const en = {
'The slug is locked so existing template references remain stable.',
'templates.groups': 'Configuration groups',
'templates.createGroupsHelp':
'Only selected groups are saved. Related groups required by the workflow are included automatically.',
'Only selected groups are saved. Statuses, columns and transitions inherit independently.',
'templates.editGroupsHelp':
'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.',
'Edit the selected groups directly. Linked projects keep their existing local overrides.',
'templates.impact': 'Template impact',
'templates.inheritance': 'Inheritance',
'templates.emptyStart':
'Templates contain configuration only. New projects start with a clean ticket history.',
'templates.cleanStart':
'Tickets and other operational ticket data are never copied. The source project stays unchanged.',
'Tickets are never copied. Saving a project as a template links that project to the new template.',
'templates.refreshHelp':
'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.',
'templates.linkedWarning': 'Linked projects will be affected',
@@ -1331,8 +1365,10 @@ export const en = {
'inheritance.resetWarning':
'Local configuration in this area may be replaced or removed.',
'inheritance.statusHelp':
'Choose where tickets in local statuses should move before those statuses are removed.',
'Choose a replacement for each used local status. Its tickets and local status transitions move to that replacement.',
'inheritance.statusLabel': '{status} ({count} tickets)',
'inheritance.noReplacementTargets':
'Add a status to the template before resetting used local statuses.',
'inheritance.selectStatus': 'Select replacement status',
'inheritance.area.projectSettings': 'Project settings',
'inheritance.area.boardColumns': 'Board columns',
@@ -6,31 +6,58 @@ import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects';
import { create } from '@/routes/projects/templates';
import {
create as createEmpty,
index as templatesIndex,
} from '@/routes/project-templates';
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
const props = defineProps<ProjectTemplatesCreatePageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('projects.saveTemplate'), href: create(props.project.id) },
]);
const breadcrumbs = computed<BreadcrumbItem[]>(() =>
props.project
? [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{
title: t('projects.saveTemplate'),
href: create(props.project.id),
},
]
: [
{ title: t('navigation.templates'), href: templatesIndex() },
{ title: t('templates.createEmpty'), href: createEmpty() },
],
);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('templates.createTitle', { project: project.name })" />
<Head
:title="
project
? t('templates.createTitle', { project: project.name })
: t('templates.createEmpty')
"
/>
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
<header class="gap-stack grid">
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('templates.createTitle', { project: project.name }) }}
{{
project
? t('templates.createTitle', {
project: project.name,
})
: t('templates.createEmpty')
}}
</h1>
<p class="text-muted-foreground">
{{ t('templates.createDescription') }}
</p>
</header>
<ProjectTemplateForm
:key="project.id"
:key="project?.id ?? 'empty'"
:project="project"
:initial-configuration="initialConfiguration"
:copy-area-options="copyAreaOptions"
/>
</div>
@@ -18,7 +18,7 @@ import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy, edit, index } from '@/routes/project-templates';
import { destroy, edit, index, create } from '@/routes/project-templates';
import {
create as createProject,
index as projectsIndex,
@@ -80,9 +80,9 @@ function removeTemplate(): void {
</p>
</div>
<Button as-child class="shrink-0"
><Link :href="createProject()"
><Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.create')
t('templates.createEmpty')
}}</Link
></Button
>
+4 -1
View File
@@ -88,7 +88,10 @@ const settingsItems = computed(() =>
</p>
</div>
<Button
v-if="$page.props.auth.permissions.projectCreate"
v-if="
$page.props.auth.permissions.projectCreate &&
canManageAccess
"
as-child
variant="outline"
class="self-start"
+5 -2
View File
@@ -67,7 +67,8 @@ export type ProjectTemplatesIndexPageProps = {
export type ProjectTemplatesCreatePageProps = {
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project: ProjectModel;
project: ProjectModel | null;
initialConfiguration?: Record<string, unknown>;
};
export type ProjectTemplatesEditPageProps = {
@@ -82,6 +83,8 @@ export type ProjectTemplateStateArea = {
export type ProjectTemplateState = {
effectiveConfiguration: Record<string, unknown>;
localConfiguration?: Record<string, unknown>;
localAreas?: ProjectTemplateStateArea[];
inheritedAreas: ProjectTemplateStateArea[];
inheritedConfiguration?: Record<string, unknown>;
inheritsFromTemplate: boolean;
@@ -95,7 +98,7 @@ export type ProjectTemplateState = {
name: string;
ticketCount: number;
}>;
replacementOptions: Array<{ id: number; name: string }>;
replacementOptions: Array<{ id: number | string; name: string }>;
}
>;
templateConfiguration?: Record<string, unknown>;
@@ -0,0 +1,77 @@
import type { ProjectDuplicateCopyAreaOption } from '@/types/page-props';
export type TemplateNotificationFilter = {
attribute: string;
operator: string;
value: string | string[] | null;
};
export type TemplateConfigurationItem = {
configuration_key: string;
name?: string;
label?: string;
counterpart_label?: string;
position?: number;
is_parent?: boolean;
field_type?: string;
board_column_key?: string | null;
provider?: string;
base_url?: string;
provider_repository_id?: string;
owner_name?: string;
repository_name?: string;
repository_full_name?: string;
repository_url?: string;
enabled?: boolean;
event_type?: string;
channel?: string;
channel_configuration?: { allowed_mentions?: string[] };
message_template?: string | null;
delivery_url?: string;
filters?: TemplateNotificationFilter[];
};
export type TemplateStatusTransition = {
status_key: string;
target_keys: string[];
};
export type TemplateConfiguration = {
project_settings: { show_author: boolean; show_story_points: boolean };
board_columns: TemplateConfigurationItem[];
ticket_statuses: TemplateConfigurationItem[];
status_transitions: TemplateStatusTransition[];
ticket_types: TemplateConfigurationItem[];
ticket_priorities: TemplateConfigurationItem[];
ticket_fields: TemplateConfigurationItem[];
ticket_link_types: TemplateConfigurationItem[];
repository_connections: TemplateConfigurationItem[];
notification_actions: TemplateConfigurationItem[];
};
export type TemplateListArea = Exclude<
keyof TemplateConfiguration,
'project_settings' | 'status_transitions'
>;
export type TemplateTextField = {
key:
| 'name'
| 'label'
| 'counterpart_label'
| 'base_url'
| 'provider_repository_id'
| 'owner_name'
| 'repository_name'
| 'repository_full_name'
| 'repository_url'
| 'delivery_url';
label: string;
};
export type TemplateConfigurationEditorProps = {
areas: string[];
options: ProjectDuplicateCopyAreaOption[];
errors: Record<string, string | undefined>;
disabled?: boolean;
};
export type TemplateResetForm = {
areas: string[];
status_replacements: Record<string, number | string | undefined>;
};
+2
View File
@@ -7,6 +7,8 @@ Route::middleware('can:create-project')
->prefix('project-templates')
->name('project-templates.')
->group(function (): void {
Route::get('create', [ProjectTemplateController::class, 'createEmpty'])->name('create');
Route::post('/', [ProjectTemplateController::class, 'storeEmpty'])->name('store');
Route::get('/', [ProjectTemplateController::class, 'index'])->name('index');
Route::get('{projectTemplate}/edit', [ProjectTemplateController::class, 'edit'])->name('edit');
Route::patch('{projectTemplate}', [ProjectTemplateController::class, 'update'])->name('update');
+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('backlog', [ProjectController::class, 'backlog'])->name('backlog.index');
Route::middleware('can:create-project')->group(function (): void {
Route::middleware(['can:create-project', 'can:admin-project,project'])->group(function (): void {
Route::get('templates/create', [ProjectTemplateController::class, 'create'])->name('templates.create');
Route::post('templates', [ProjectTemplateController::class, 'store'])->name('templates.store');
});
@@ -31,6 +31,7 @@ class DiscordLoggerTest extends TestCase
'logging.channels.discord_errors.url' => 'https://discord.example.test/webhook',
'logging.channels.discord_errors.mention' => '@here',
'logging.channels.discord_errors.title' => '## KanbanGit error',
'logging.channels.discord_errors.max_trace_lines' => 2,
]);
Log::forgetChannel('discord_errors');
@@ -38,7 +38,7 @@ class ProjectTemplateControllerTest extends TestCase
]);
$this->syncGlobalPermissions($user, ['project:create']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->get('/project-templates')
@@ -79,7 +79,7 @@ class ProjectTemplateControllerTest extends TestCase
]);
$this->syncGlobalPermissions($user, ['project:create']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->syncProjectPermissions($project, $user, ['admin']);
$project->ticketTypes()->delete();
$project->ticketStatuses()->delete();
@@ -119,19 +119,8 @@ class ProjectTemplateControllerTest extends TestCase
$this->assertTrue($template->is_published);
$this->assertNull($template->project_id);
$this->assertSame([
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => false,
'show_story_points' => false,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
],
],
], $template->configuration_snapshot);
$this->assertSame(app(DuplicateProjectConfiguration::class)->export($project, $template->copy_areas), $template->configuration_snapshot);
$this->assertSame($template->id, $project->fresh()->project_template_id);
$this->actingAs($user)
->delete(route('project-templates.destroy', $template))
@@ -271,6 +260,11 @@ class ProjectTemplateControllerTest extends TestCase
],
])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($inheritingProject, $template->configuration_snapshot, adoptExisting: true);
$nextConfiguration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($template->configuration_snapshot);
$nextConfiguration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
$nextConfiguration['ticket_types'][0]['name'] = 'Feature';
$sourceProject->forceFill([
'show_author' => true,
'show_story_points' => true,
@@ -281,6 +275,7 @@ class ProjectTemplateControllerTest extends TestCase
$this->actingAs($user)
->patch(route('project-templates.update', $template), [
'name' => 'Platform Template v2',
'configuration_snapshot' => $nextConfiguration,
'slug' => 'should-not-change',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
@@ -296,19 +291,7 @@ class ProjectTemplateControllerTest extends TestCase
$this->assertSame('Platform Template v2', $template->name);
$this->assertSame('platform-template', $template->slug);
$this->assertSame([
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Feature',
'position' => 1,
'is_parent' => false,
],
],
], $template->configuration_snapshot);
$this->assertSame($nextConfiguration, $template->configuration_snapshot);
$this->assertTrue($inheritingProject->show_author);
$this->assertTrue($inheritingProject->show_story_points);
@@ -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();
$status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
$ticket = $this->createTicket($project, $status);
$sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
@@ -62,6 +64,7 @@ class ProjectTemplateOverrideTest extends TestCase
$sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
@@ -76,6 +79,7 @@ class ProjectTemplateOverrideTest extends TestCase
$project = Project::factory()->create();
$status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]);
$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' => 'Todo']);
@@ -86,6 +90,7 @@ class ProjectTemplateOverrideTest extends TestCase
$sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
@@ -106,7 +111,7 @@ class ProjectTemplateOverrideTest extends TestCase
'project_template_id' => $template->id,
'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]);
$ticket = $this->createTicket($project, $localStatus);
$this->syncProjectPermissions($project, $user, ['admin']);
@@ -171,9 +176,11 @@ class ProjectTemplateOverrideTest extends TestCase
$project = Project::factory()->create();
TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
@@ -199,6 +206,7 @@ class ProjectTemplateOverrideTest extends TestCase
'field_type' => 'number', 'label' => 'Estimate', 'position' => 1,
]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
$status = TicketStatus::factory()->for($project)->create();
$ticket = $this->createTicket($project, $status);
$fieldValue = TicketFieldValue::factory()->create([
@@ -210,6 +218,7 @@ class ProjectTemplateOverrideTest extends TestCase
$sourceField->update(['label' => 'Story points']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_FIELDS],
]);
+4
View File
@@ -222,6 +222,10 @@ if (serve) {
assert.match(admin, /Board columns · inherited/);
assert.match(admin, /Ticket types · overridden/);
assert.match(
await render('/projects/7', { create: true, manage: true }),
/href="\/projects\/7\/templates\/create"/,
);
assert.doesNotMatch(
await render('/projects/7', { create: true }),
/href="\/projects\/7\/templates\/create"/,
);
+90 -3
View File
@@ -46,6 +46,7 @@ try {
'/project-templates?reset',
'/projects/1/templates/create?reset',
'/project-templates/2/edit?reset',
'/project-templates/create?reset',
]) {
await visit(path);
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 button('Save template changes').isDisabled(),
@@ -193,6 +195,14 @@ try {
name: 'Updated foundation',
copy_areas: ['project_settings', 'ticket_types'],
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
.getByRole('heading', { name: 'Project templates', exact: true })
@@ -207,15 +217,92 @@ try {
assert.deepEqual((await unlinked).postDataJSON(), {
name: 'Foundation',
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
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/2/edit?reset&missing');
await visit('/project-templates/2/edit?reset&missing&unlinked');
assert.equal(
await page
.getByText('This template cannot be refreshed', { exact: true })
.count(),
0,
);
assert.equal(await button('Save template changes').isEnabled(), true);
await button('Save template changes').click();
await page
.getByText('This template cannot be refreshed', { exact: true })
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/create?reset');
await page.locator('#name').fill('New independent template');
await page.locator('#slug').fill('independent-template');
const columns = page
.locator('details')
.filter({ has: page.locator('summary', { hasText: 'Board columns' }) });
await columns
.getByRole('button', { name: 'Add entry', exact: true })
.click();
await columns.getByLabel('Name', { exact: true }).fill('Todo');
const statuses = page.locator('details').filter({
has: page.locator('summary', { hasText: 'Ticket statuses' }),
});
await statuses
.getByRole('button', { name: 'Add entry', exact: true })
.click();
await statuses.getByLabel('Name', { exact: true }).last().fill('Review');
await statuses
.getByLabel('Board column', { exact: true })
.last()
.selectOption({ label: 'Todo' });
const types = page
.locator('details')
.filter({ has: page.locator('summary', { hasText: 'Ticket types' }) });
await types
.getByRole('button', { name: 'Move down', exact: true })
.first()
.click();
const standaloneCreated = page.waitForRequest(
(request) => request.method() === 'POST',
);
await button('Save new template').click();
const body = (await standaloneCreated).postDataJSON();
assert.equal(
new URL((await standaloneCreated).url()).pathname,
'/project-templates',
);
assert.equal(body.configuration_snapshot.ticket_statuses[1].name, 'Review');
assert.equal(
body.configuration_snapshot.ticket_statuses[1].board_column_key,
body.configuration_snapshot.board_columns[0].configuration_key,
);
assert.deepEqual(
body.configuration_snapshot.ticket_types.map((item) => item.name),
['Release', 'Task'],
);
assert.deepEqual(
body.configuration_snapshot.ticket_types.map((item) => item.position),
[1, 2],
);
assert.equal(
new Set(
body.configuration_snapshot.ticket_types.map(
(item) => item.configuration_key,
),
).size,
2,
);
await page
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
assert.equal(await button('Save template changes').isDisabled(), true);
await visit('/projects/1/templates/create?reset');
await page.locator('#name').fill('Unsaved work');
+72 -6
View File
@@ -43,10 +43,54 @@ const template = {
is_published: true,
source_project: project,
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,
};
const initial = () => ({
project,
initialConfiguration: structuredClone(template.configuration_snapshot),
copyAreaOptions: areas.map((value) => ({
value,
label: value,
@@ -100,7 +144,8 @@ if (serve) {
const url = new URL(request.url, 'http://localhost');
let component = url.pathname.endsWith('/edit')
? 'Edit'
: url.pathname.includes('/templates/create')
: url.pathname.includes('/templates/create') ||
url.pathname === '/project-templates/create'
? 'Create'
: url.pathname === '/project-templates'
? 'Index'
@@ -117,6 +162,9 @@ if (serve) {
if (url.searchParams.has('empty')) {
data.templates = [];
}
if (url.pathname === '/project-templates/create') {
data.project = null;
}
let errors = {};
if (request.method !== 'GET') {
let body = '';
@@ -259,12 +307,30 @@ if (serve) {
unlinked.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
assert.match(
await render('Edit', {
template: { ...template, source_project: null },
}),
/This template cannot be refreshed/,
const independent = await render('Edit', {
template: {
...template,
source_project: null,
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(
await render(
'Index',