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