From 1291a0fcadf8ab27adc1deef925bda0079a51db1 Mon Sep 17 00:00:00 2001
From: Twyco
Date: Thu, 15 Oct 2026 11:38:17 +0200
Subject: [PATCH] fix(KAN-14): preserve template inheritance and project data
---
.../ManagesTicketStatusPositions.php | 2 +
.../ProjectBoard/ReordersBoardColumns.php | 5 +
.../UpdateTicketStatusTransitions.php | 35 +-
.../UpdateProjectTicketMetadataSettings.php | 30 +-
app/Actions/Projects/CreateProject.php | 2 +
.../Projects/CreateProjectBaseTemplate.php | 9 +-
.../Projects/CreateProjectTemplate.php | 53 +-
.../Projects/DeleteProjectTemplate.php | 31 +
.../DuplicateProjectConfiguration.php | 292 +++++---
.../Projects/ProjectConfigurationIdentity.php | 95 +++
.../ProjectTemplateOverrideManager.php | 29 +-
.../Projects/RefreshProjectBaseTemplate.php | 30 +-
.../Projects/ResolveProjectTemplateState.php | 26 +-
.../Projects/UpdateProjectTemplate.php | 47 +-
.../Projects/UpdateProjectTemplateLink.php | 26 +-
app/Http/Controllers/ProjectController.php | 6 +-
.../Controllers/ProjectTemplateController.php | 22 +-
.../ValidatesProjectTemplateConfiguration.php | 141 ++++
.../Requests/ResetProjectTemplateRequest.php | 56 +-
.../Requests/StoreProjectTemplateRequest.php | 11 +-
.../StoreStandaloneProjectTemplateRequest.php | 15 +
.../Requests/UpdateProjectTemplateRequest.php | 6 +-
app/Http/Resources/ProjectResource.php | 2 +
app/Models/BoardColumn.php | 2 +-
.../SyncsProjectTemplateOwnership.php | 33 +
app/Models/NotificationAction.php | 2 +-
app/Models/RepositoryConnection.php | 2 +-
app/Models/TicketFieldDefinition.php | 2 +-
app/Models/TicketLinkType.php | 2 +-
app/Models/TicketPriority.php | 2 +-
app/Models/TicketStatus.php | 2 +-
app/Models/TicketType.php | 2 +-
.../ProjectConfigurationObserver.php | 3 +
database/factories/BoardColumnFactory.php | 2 +
.../factories/NotificationActionFactory.php | 2 +
.../factories/RepositoryConnectionFactory.php | 2 +
.../TicketFieldDefinitionFactory.php | 2 +
database/factories/TicketLinkTypeFactory.php | 2 +
database/factories/TicketPriorityFactory.php | 2 +
database/factories/TicketStatusFactory.php | 2 +
database/factories/TicketTypeFactory.php | 2 +
...guration_keys_to_project_configuration.php | 165 +++++
.../projects/ProjectTemplateForm.vue | 85 ++-
.../projects/TemplateConfigurationEditor.vue | 662 ++++++++++++++++++
.../projects/TemplateInheritancePanel.vue | 15 +-
.../js/lib/project-template-configuration.ts | 21 +
resources/js/locales/en.ts | 48 +-
.../js/pages/project-templates/Create.vue | 43 +-
.../js/pages/project-templates/Index.vue | 6 +-
resources/js/pages/projects/Show.vue | 5 +-
resources/js/types/page-props.ts | 7 +-
.../types/project-template-configuration.ts | 77 ++
routes/web/project-templates.php | 2 +
routes/web/projects.php | 2 +-
tests/Feature/Logging/DiscordLoggerTest.php | 1 +
.../ProjectTemplateControllerTest.php | 39 +-
.../Projects/ProjectTemplateIdentityTest.php | 628 +++++++++++++++++
.../Projects/ProjectTemplateOverrideTest.php | 11 +-
tests/Fixtures/project-overview.mjs | 4 +
tests/Fixtures/project-templates-browser.mjs | 93 ++-
tests/Fixtures/project-templates.mjs | 78 ++-
61 files changed, 2696 insertions(+), 335 deletions(-)
create mode 100644 app/Actions/Projects/DeleteProjectTemplate.php
create mode 100644 app/Actions/Projects/ProjectConfigurationIdentity.php
create mode 100644 app/Http/Requests/Concerns/ValidatesProjectTemplateConfiguration.php
create mode 100644 app/Http/Requests/StoreStandaloneProjectTemplateRequest.php
create mode 100644 database/migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php
create mode 100644 resources/js/components/projects/TemplateConfigurationEditor.vue
create mode 100644 resources/js/lib/project-template-configuration.ts
create mode 100644 resources/js/types/project-template-configuration.ts
create mode 100644 tests/Feature/Projects/ProjectTemplateIdentityTest.php
diff --git a/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php b/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php
index 07493c9..6c7c3b5 100644
--- a/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php
+++ b/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php
@@ -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
diff --git a/app/Actions/ProjectBoard/ReordersBoardColumns.php b/app/Actions/ProjectBoard/ReordersBoardColumns.php
index 0657fe5..bd88a95 100644
--- a/app/Actions/ProjectBoard/ReordersBoardColumns.php
+++ b/app/Actions/ProjectBoard/ReordersBoardColumns.php
@@ -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');
+ }
}
}
diff --git a/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php
index 63416ff..5988e12 100644
--- a/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php
+++ b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php
@@ -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);
}
}
diff --git a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php
index 2a43375..d31ecfb 100644
--- a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php
+++ b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php
@@ -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);
}
}
diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php
index 8d86e7c..ef45f12 100644
--- a/app/Actions/Projects/CreateProject.php
+++ b/app/Actions/Projects/CreateProject.php
@@ -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,
);
}
diff --git a/app/Actions/Projects/CreateProjectBaseTemplate.php b/app/Actions/Projects/CreateProjectBaseTemplate.php
index 29623e3..186632b 100644
--- a/app/Actions/Projects/CreateProjectBaseTemplate.php
+++ b/app/Actions/Projects/CreateProjectBaseTemplate.php
@@ -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),
diff --git a/app/Actions/Projects/CreateProjectTemplate.php b/app/Actions/Projects/CreateProjectTemplate.php
index 463267e..fe0963e 100644
--- a/app/Actions/Projects/CreateProjectTemplate.php
+++ b/app/Actions/Projects/CreateProjectTemplate.php
@@ -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 $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} $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);
}
}
diff --git a/app/Actions/Projects/DeleteProjectTemplate.php b/app/Actions/Projects/DeleteProjectTemplate.php
new file mode 100644
index 0000000..ad843bc
--- /dev/null
+++ b/app/Actions/Projects/DeleteProjectTemplate.php
@@ -0,0 +1,31 @@
+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);
+ }
+}
diff --git a/app/Actions/Projects/DuplicateProjectConfiguration.php b/app/Actions/Projects/DuplicateProjectConfiguration.php
index 2513340..95a5900 100644
--- a/app/Actions/Projects/DuplicateProjectConfiguration.php
+++ b/app/Actions/Projects/DuplicateProjectConfiguration.php
@@ -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 */
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
unset($configuration[self::COPY_BOARD_WORKFLOW]);
}
- return $configuration;
+ return $this->projectConfigurationIdentity->normalize($configuration);
}
/** @param list $copyAreas @return array */
@@ -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 $configuration
* @param array $statusReplacements
- * @return list areas which retained referenced local values
+ * @return list 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 $configuration @param array $statusReplacements @return list */
+ 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 $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> $filters @param array $mapping @return list> */
+ 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 $replacements @param list $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 $models
- * @param array> $items
+ * @param list> $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;
}
}
diff --git a/app/Actions/Projects/ProjectConfigurationIdentity.php b/app/Actions/Projects/ProjectConfigurationIdentity.php
new file mode 100644
index 0000000..086a62e
--- /dev/null
+++ b/app/Actions/Projects/ProjectConfigurationIdentity.php
@@ -0,0 +1,95 @@
+ */
+ 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 $configuration @return array */
+ 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 $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 */
+ 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;
+ }
+}
diff --git a/app/Actions/Projects/ProjectTemplateOverrideManager.php b/app/Actions/Projects/ProjectTemplateOverrideManager.php
index f01339e..44783a3 100644
--- a/app/Actions/Projects/ProjectTemplateOverrideManager.php
+++ b/app/Actions/Projects/ProjectTemplateOverrideManager.php
@@ -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
diff --git a/app/Actions/Projects/RefreshProjectBaseTemplate.php b/app/Actions/Projects/RefreshProjectBaseTemplate.php
index 095ab11..f22cec2 100644
--- a/app/Actions/Projects/RefreshProjectBaseTemplate.php
+++ b/app/Actions/Projects/RefreshProjectBaseTemplate.php
@@ -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);
}
}
diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php
index 99d3519..e171d81 100644
--- a/app/Actions/Projects/ResolveProjectTemplateState.php
+++ b/app/Actions/Projects/ResolveProjectTemplateState.php
@@ -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 => [
diff --git a/app/Actions/Projects/UpdateProjectTemplate.php b/app/Actions/Projects/UpdateProjectTemplate.php
index c8231e4..8d48d71 100644
--- a/app/Actions/Projects/UpdateProjectTemplate.php
+++ b/app/Actions/Projects/UpdateProjectTemplate.php
@@ -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);
}
}
diff --git a/app/Actions/Projects/UpdateProjectTemplateLink.php b/app/Actions/Projects/UpdateProjectTemplateLink.php
index c046b03..494b547 100644
--- a/app/Actions/Projects/UpdateProjectTemplateLink.php
+++ b/app/Actions/Projects/UpdateProjectTemplateLink.php
@@ -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 $areas
* @param array $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);
}
}
diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php
index ad847c7..a5fd8c8 100644
--- a/app/Http/Controllers/ProjectController.php
+++ b/app/Http/Controllers/ProjectController.php
@@ -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);
}
diff --git a/app/Http/Controllers/ProjectTemplateController.php b/app/Http/Controllers/ProjectTemplateController.php
index e9c1b6a..7814e8d 100644
--- a/app/Http/Controllers/ProjectTemplateController.php
+++ b/app/Http/Controllers/ProjectTemplateController.php
@@ -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.");
diff --git a/app/Http/Requests/Concerns/ValidatesProjectTemplateConfiguration.php b/app/Http/Requests/Concerns/ValidatesProjectTemplateConfiguration.php
new file mode 100644
index 0000000..6780011
--- /dev/null
+++ b/app/Http/Requests/Concerns/ValidatesProjectTemplateConfiguration.php
@@ -0,0 +1,141 @@
+ */
+ 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 */
+ 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.');
+ }
+ }
+ }
+ }];
+ }
+}
diff --git a/app/Http/Requests/ResetProjectTemplateRequest.php b/app/Http/Requests/ResetProjectTemplateRequest.php
index 670193d..8bebc3d 100644
--- a/app/Http/Requests/ResetProjectTemplateRequest.php
+++ b/app/Http/Requests/ResetProjectTemplateRequest.php
@@ -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}.");
}
}
},
diff --git a/app/Http/Requests/StoreProjectTemplateRequest.php b/app/Http/Requests/StoreProjectTemplateRequest.php
index fe4f99e..9eddc4f 100644
--- a/app/Http/Requests/StoreProjectTemplateRequest.php
+++ b/app/Http/Requests/StoreProjectTemplateRequest.php
@@ -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())],
];
}
diff --git a/app/Http/Requests/StoreStandaloneProjectTemplateRequest.php b/app/Http/Requests/StoreStandaloneProjectTemplateRequest.php
new file mode 100644
index 0000000..4384b85
--- /dev/null
+++ b/app/Http/Requests/StoreStandaloneProjectTemplateRequest.php
@@ -0,0 +1,15 @@
+configurationRules()];
+ }
+}
diff --git a/app/Http/Requests/UpdateProjectTemplateRequest.php b/app/Http/Requests/UpdateProjectTemplateRequest.php
index 1e037ca..65508c4 100644
--- a/app/Http/Requests/UpdateProjectTemplateRequest.php
+++ b/app/Http/Requests/UpdateProjectTemplateRequest.php
@@ -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),
diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php
index 12022c8..ff07db5 100644
--- a/app/Http/Resources/ProjectResource.php
+++ b/app/Http/Resources/ProjectResource.php
@@ -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),
diff --git a/app/Models/BoardColumn.php b/app/Models/BoardColumn.php
index 3c8081f..adc37e7 100644
--- a/app/Models/BoardColumn.php
+++ b/app/Models/BoardColumn.php
@@ -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 */
diff --git a/app/Models/Concerns/SyncsProjectTemplateOwnership.php b/app/Models/Concerns/SyncsProjectTemplateOwnership.php
index cf02f1d..8adfe0b 100644
--- a/app/Models/Concerns/SyncsProjectTemplateOwnership.php
+++ b/app/Models/Concerns/SyncsProjectTemplateOwnership.php
@@ -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 $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');
diff --git a/app/Models/NotificationAction.php b/app/Models/NotificationAction.php
index 70bbd92..21d1367 100644
--- a/app/Models/NotificationAction.php
+++ b/app/Models/NotificationAction.php
@@ -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',
diff --git a/app/Models/RepositoryConnection.php b/app/Models/RepositoryConnection.php
index 81725c1..c5f6805 100644
--- a/app/Models/RepositoryConnection.php
+++ b/app/Models/RepositoryConnection.php
@@ -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',
diff --git a/app/Models/TicketFieldDefinition.php b/app/Models/TicketFieldDefinition.php
index 2f7afe4..b6aead5 100644
--- a/app/Models/TicketFieldDefinition.php
+++ b/app/Models/TicketFieldDefinition.php
@@ -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
{
/**
diff --git a/app/Models/TicketLinkType.php b/app/Models/TicketLinkType.php
index e42e97a..7c0081c 100644
--- a/app/Models/TicketLinkType.php
+++ b/app/Models/TicketLinkType.php
@@ -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 */
diff --git a/app/Models/TicketPriority.php b/app/Models/TicketPriority.php
index 94588f9..45d2fc7 100644
--- a/app/Models/TicketPriority.php
+++ b/app/Models/TicketPriority.php
@@ -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 */
diff --git a/app/Models/TicketStatus.php b/app/Models/TicketStatus.php
index 6aaa879..a658943 100644
--- a/app/Models/TicketStatus.php
+++ b/app/Models/TicketStatus.php
@@ -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 */
diff --git a/app/Models/TicketType.php b/app/Models/TicketType.php
index 9675876..734b3ae 100644
--- a/app/Models/TicketType.php
+++ b/app/Models/TicketType.php
@@ -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 */
diff --git a/app/Observers/ProjectConfigurationObserver.php b/app/Observers/ProjectConfigurationObserver.php
index 8e9bf21..558815a 100644
--- a/app/Observers/ProjectConfigurationObserver.php
+++ b/app/Observers/ProjectConfigurationObserver.php
@@ -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
diff --git a/database/factories/BoardColumnFactory.php b/database/factories/BoardColumnFactory.php
index 461c0e7..83d52aa 100644
--- a/database/factories/BoardColumnFactory.php
+++ b/database/factories/BoardColumnFactory.php
@@ -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
@@ -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(),
diff --git a/database/factories/NotificationActionFactory.php b/database/factories/NotificationActionFactory.php
index 70cc628..63841f9 100644
--- a/database/factories/NotificationActionFactory.php
+++ b/database/factories/NotificationActionFactory.php
@@ -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
@@ -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(),
diff --git a/database/factories/RepositoryConnectionFactory.php b/database/factories/RepositoryConnectionFactory.php
index 060c2ce..46fde65 100644
--- a/database/factories/RepositoryConnectionFactory.php
+++ b/database/factories/RepositoryConnectionFactory.php
@@ -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
@@ -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(),
diff --git a/database/factories/TicketFieldDefinitionFactory.php b/database/factories/TicketFieldDefinitionFactory.php
index 1b4c088..6de5ff5 100644
--- a/database/factories/TicketFieldDefinitionFactory.php
+++ b/database/factories/TicketFieldDefinitionFactory.php
@@ -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
@@ -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,
diff --git a/database/factories/TicketLinkTypeFactory.php b/database/factories/TicketLinkTypeFactory.php
index 08204b4..224a231 100644
--- a/database/factories/TicketLinkTypeFactory.php
+++ b/database/factories/TicketLinkTypeFactory.php
@@ -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
@@ -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,
diff --git a/database/factories/TicketPriorityFactory.php b/database/factories/TicketPriorityFactory.php
index c1baec5..a5c68c8 100644
--- a/database/factories/TicketPriorityFactory.php
+++ b/database/factories/TicketPriorityFactory.php
@@ -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
@@ -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(),
diff --git a/database/factories/TicketStatusFactory.php b/database/factories/TicketStatusFactory.php
index 68eadec..c37bb20 100644
--- a/database/factories/TicketStatusFactory.php
+++ b/database/factories/TicketStatusFactory.php
@@ -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
@@ -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,
diff --git a/database/factories/TicketTypeFactory.php b/database/factories/TicketTypeFactory.php
index fff3e0d..e7f8a28 100644
--- a/database/factories/TicketTypeFactory.php
+++ b/database/factories/TicketTypeFactory.php
@@ -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
@@ -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,
diff --git a/database/migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php b/database/migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php
new file mode 100644
index 0000000..456d95d
--- /dev/null
+++ b/database/migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php
@@ -0,0 +1,165 @@
+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');
+ });
+ }
+ }
+};
diff --git a/resources/js/components/projects/ProjectTemplateForm.vue b/resources/js/components/projects/ProjectTemplateForm.vue
index f0bb3f0..61b445d 100644
--- a/resources/js/components/projects/ProjectTemplateForm.vue
+++ b/resources/js/components/projects/ProjectTemplateForm.vue
@@ -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;
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"
>
-
@@ -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 {
+
diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue
index 96cbb28..415ddba 100644
--- a/resources/js/pages/projects/Show.vue
+++ b/resources/js/pages/projects/Show.vue
@@ -88,7 +88,10 @@ const settingsItems = computed(() =>