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