From 3122e00ed820631b5a89d2b6947f19e42f1b4547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20Schr=C3=B6der?= Date: Tue, 8 Sep 2026 12:03:07 +0200 Subject: [PATCH] fix: make project template inheritance granular and safe --- .../UpdateTicketStatusTransitions.php | 16 + .../UpdateProjectTicketMetadataSettings.php | 16 + app/Actions/Projects/CreateProject.php | 11 +- .../DuplicateProjectConfiguration.php | 549 ++++++++---------- .../ProjectTemplateOverrideManager.php | 55 ++ .../Projects/RefreshProjectBaseTemplate.php | 32 + .../Projects/ResolveProjectTemplateState.php | 52 +- .../Projects/UpdateProjectTemplate.php | 70 ++- .../Projects/UpdateProjectTemplateLink.php | 39 +- app/Actions/Tickets/CreateProjectTicket.php | 6 +- app/Http/Controllers/ProjectController.php | 25 +- .../Requests/Api/MoveProjectTicketRequest.php | 5 +- .../DestroyProjectTicketTypeRequest.php | 5 +- .../Requests/MoveProjectTicketRequest.php | 5 +- .../Requests/ResetProjectTemplateRequest.php | 66 ++- app/Http/Requests/StoreProjectRequest.php | 2 +- .../Requests/StoreProjectTemplateRequest.php | 2 +- .../StoreProjectTicketBranchRequest.php | 5 +- .../Requests/StoreProjectTicketRequest.php | 9 +- app/Http/Requests/StoreTicketLinkRequest.php | 5 +- .../Requests/StoreTicketStatusRequest.php | 5 +- .../Requests/UpdateProjectTemplateRequest.php | 2 +- .../Requests/UpdateProjectTicketRequest.php | 11 +- .../UpdateTicketStatusTransitionsRequest.php | 5 +- .../ValidateProjectTicketBranchRequest.php | 5 +- app/Http/Resources/ProjectResource.php | 1 + .../Resources/ProjectTemplateResource.php | 9 +- app/Models/Project.php | 18 +- app/Models/TicketPriority.php | 7 + .../ProjectConfigurationObserver.php | 66 +++ app/Providers/AppServiceProvider.php | 21 + database/factories/ProjectFactory.php | 1 + ..._project_metadata_to_project_templates.php | 8 +- ...te_override_tracking_to_projects_table.php | 63 ++ .../projects/TemplateInheritancePanel.vue | 177 ++++++ .../js/pages/projects/settings/Board.vue | 35 +- .../js/pages/projects/settings/General.vue | 153 +++-- .../js/pages/projects/settings/Metadata.vue | 45 +- .../pages/projects/settings/Notifications.vue | 116 ++-- .../pages/projects/settings/Repositories.vue | 47 +- resources/js/types/page-props.ts | 11 + tests/Feature/Api/ProjectApiTest.php | 3 + .../ProjectTemplateBaselineMigrationTest.php | 51 +- tests/Feature/Mcp/KanbanGitMcpTest.php | 5 +- .../Projects/ProjectBoardControllerTest.php | 4 + .../ProjectBoardSettingsControllerTest.php | 9 +- .../Projects/ProjectControllerTest.php | 2 + .../ProjectTemplateControllerTest.php | 7 + .../Projects/ProjectTemplateOverrideTest.php | 263 +++++++++ ...ctTicketMetadataSettingsControllerTest.php | 3 +- 50 files changed, 1504 insertions(+), 624 deletions(-) create mode 100644 app/Actions/Projects/ProjectTemplateOverrideManager.php create mode 100644 app/Actions/Projects/RefreshProjectBaseTemplate.php create mode 100644 app/Observers/ProjectConfigurationObserver.php create mode 100644 database/migrations/2026_09_08_091101_add_template_override_tracking_to_projects_table.php create mode 100644 resources/js/components/projects/TemplateInheritancePanel.vue create mode 100644 tests/Feature/Projects/ProjectTemplateOverrideTest.php diff --git a/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php index a17fef0..63416ff 100644 --- a/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php +++ b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php @@ -2,11 +2,19 @@ namespace App\Actions\ProjectBoard; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ProjectTemplateOverrideManager; +use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Models\TicketStatus; use Illuminate\Support\Arr; class UpdateTicketStatusTransitions { + public function __construct( + private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, + private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate, + ) {} + /** * @param array{allowed_ticket_status_ids?: array|null} $validated */ @@ -19,6 +27,14 @@ class UpdateTicketStatusTransitions ->all(); $ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds); + $this->projectTemplateOverrideManager->mark( + $ticketStatus->project, + DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, + ); + $this->refreshProjectBaseTemplate->handle( + $ticketStatus->project, + DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS, + ); return $ticketStatus->fresh(); } diff --git a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php index 5edc3ec..2a43375 100644 --- a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php +++ b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php @@ -2,11 +2,19 @@ namespace App\Actions\ProjectTicketMetadata; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ProjectTemplateOverrideManager; +use App\Actions\Projects\RefreshProjectBaseTemplate; use App\Models\Project; use Illuminate\Support\Arr; class UpdateProjectTicketMetadataSettings { + public function __construct( + private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, + private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate, + ) {} + /** * @param array{show_author?: bool|null, show_story_points?: bool|null} $validated */ @@ -16,6 +24,14 @@ class UpdateProjectTicketMetadataSettings 'show_author' => Arr::get($validated, 'show_author', false), 'show_story_points' => Arr::get($validated, 'show_story_points', false), ]); + $this->projectTemplateOverrideManager->mark( + $project, + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + ); + $this->refreshProjectBaseTemplate->handle( + $project, + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + ); return $project->fresh(); } diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php index 221e3d9..8d86e7c 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -18,6 +18,7 @@ class CreateProject private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly PermissionService $permissionService, + private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, ) {} /** @@ -39,18 +40,22 @@ class CreateProject $project->forceFill([ 'project_template_id' => $sourceTemplate->id, - ])->save(); + 'template_overrides' => [], + ])->saveQuietly(); } else { $baseTemplate = $this->createProjectBaseTemplate->handle($project); $project->forceFill([ 'project_template_id' => $baseTemplate->id, - ])->save(); + 'template_overrides' => [], + ])->saveQuietly(); } $project = $project->fresh() ?? $project; - $this->initializeProjectTicketMetadata->handle($project); + $this->projectTemplateOverrideManager->withoutRecording( + fn () => $this->initializeProjectTicketMetadata->handle($project), + ); $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) { diff --git a/app/Actions/Projects/DuplicateProjectConfiguration.php b/app/Actions/Projects/DuplicateProjectConfiguration.php index 01e2eed..2513340 100644 --- a/app/Actions/Projects/DuplicateProjectConfiguration.php +++ b/app/Actions/Projects/DuplicateProjectConfiguration.php @@ -3,12 +3,18 @@ namespace App\Actions\Projects; use App\Models\Project; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; class DuplicateProjectConfiguration { public const COPY_PROJECT_SETTINGS = 'project_settings'; - public const COPY_BOARD_WORKFLOW = 'board_workflow'; + public const COPY_BOARD_COLUMNS = 'board_columns'; + + public const COPY_TICKET_STATUSES = 'ticket_statuses'; + + public const COPY_STATUS_TRANSITIONS = 'status_transitions'; public const COPY_TICKET_TYPES = 'ticket_types'; @@ -22,14 +28,20 @@ class DuplicateProjectConfiguration public const COPY_NOTIFICATION_ACTIONS = 'notification_actions'; - /** - * @return list - */ + public const COPY_BOARD_WORKFLOW = 'board_workflow'; + + public function __construct( + private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, + ) {} + + /** @return list */ public static function copyableAreas(): array { return [ self::COPY_PROJECT_SETTINGS, - self::COPY_BOARD_WORKFLOW, + self::COPY_BOARD_COLUMNS, + self::COPY_TICKET_STATUSES, + self::COPY_STATUS_TRANSITIONS, self::COPY_TICKET_TYPES, self::COPY_TICKET_PRIORITIES, self::COPY_TICKET_FIELDS, @@ -39,68 +51,67 @@ class DuplicateProjectConfiguration ]; } - /** - * @return list - */ + /** @return list */ + public static function acceptedCopyAreas(): array + { + return [...self::copyableAreas(), self::COPY_BOARD_WORKFLOW]; + } + + /** @return list */ public static function copyAreaOptions(): array { return [ - [ - 'value' => self::COPY_PROJECT_SETTINGS, - 'label' => 'Project settings', - 'description' => 'Copy general project preferences like story points and author visibility.', - ], - [ - 'value' => self::COPY_BOARD_WORKFLOW, - 'label' => 'Board workflow', - 'description' => 'Copy board columns, ticket statuses, and allowed status transitions.', - ], - [ - 'value' => self::COPY_TICKET_TYPES, - 'label' => 'Ticket types', - 'description' => 'Copy available ticket types, including parent-ticket types.', - ], - [ - 'value' => self::COPY_TICKET_PRIORITIES, - 'label' => 'Ticket priorities', - 'description' => 'Copy the project-specific ticket priorities.', - ], - [ - 'value' => self::COPY_TICKET_FIELDS, - 'label' => 'Custom ticket fields', - 'description' => 'Copy custom ticket field definitions without any ticket values.', - ], - [ - 'value' => self::COPY_TICKET_LINK_TYPES, - 'label' => 'Ticket link types', - 'description' => 'Copy available relationship labels between tickets.', - ], - [ - 'value' => self::COPY_REPOSITORY_CONNECTIONS, - 'label' => 'Repository connections', - 'description' => 'Copy linked repositories without changing the new project identity.', - ], - [ - 'value' => self::COPY_NOTIFICATION_ACTIONS, - 'label' => 'Notification actions', - 'description' => 'Copy notification rules and delivery settings.', - ], + ['value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'General project preferences like story points and author visibility.'], + ['value' => self::COPY_BOARD_COLUMNS, 'label' => 'Board columns', 'description' => 'Columns and their order on the project board.'], + ['value' => self::COPY_TICKET_STATUSES, 'label' => 'Ticket statuses', 'description' => 'Statuses and their assignment to board columns.'], + ['value' => self::COPY_STATUS_TRANSITIONS, 'label' => 'Status transitions', 'description' => 'Allowed moves between ticket statuses.'], + ['value' => self::COPY_TICKET_TYPES, 'label' => 'Ticket types', 'description' => 'Available ticket types, including parent types.'], + ['value' => self::COPY_TICKET_PRIORITIES, 'label' => 'Ticket priorities', 'description' => 'Available ticket priorities.'], + ['value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Custom field definitions without ticket values.'], + ['value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Relationship labels between tickets.'], + ['value' => self::COPY_REPOSITORY_CONNECTIONS, 'label' => 'Repository connections', 'description' => 'Repositories linked to the project.'], + ['value' => self::COPY_NOTIFICATION_ACTIONS, 'label' => 'Notification actions', 'description' => 'Notification rules and delivery settings.'], ]; } - /** - * @param list $copyAreas - * @return list - */ + /** @param list $copyAreas @return list */ public function normalizeCopyAreas(array $copyAreas): array { + if (in_array(self::COPY_BOARD_WORKFLOW, $copyAreas, true)) { + $copyAreas = [ + ...array_diff($copyAreas, [self::COPY_BOARD_WORKFLOW]), + self::COPY_BOARD_COLUMNS, + self::COPY_TICKET_STATUSES, + self::COPY_STATUS_TRANSITIONS, + ]; + } + return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas))); } - /** - * @param list $copyAreas - * @return array - */ + /** @param array $configuration @return array */ + public function normalizeConfiguration(array $configuration): array + { + $workflow = $configuration[self::COPY_BOARD_WORKFLOW] ?? null; + + if (is_array($workflow)) { + $statuses = $workflow['ticket_statuses'] ?? []; + $configuration[self::COPY_BOARD_COLUMNS] ??= $workflow['board_columns'] ?? []; + $configuration[self::COPY_TICKET_STATUSES] ??= array_map( + static fn (array $status): array => array_diff_key($status, ['allowed_target_indexes' => true]), + $statuses, + ); + $configuration[self::COPY_STATUS_TRANSITIONS] ??= array_map( + static fn (array $status): array => $status['allowed_target_indexes'] ?? [], + $statuses, + ); + unset($configuration[self::COPY_BOARD_WORKFLOW]); + } + + return $configuration; + } + + /** @param list $copyAreas @return array */ public function export(Project $sourceProject, array $copyAreas): array { $selectedAreas = $this->normalizeCopyAreas($copyAreas); @@ -109,17 +120,10 @@ class DuplicateProjectConfiguration return []; } - $sourceProject->loadMissing([ - 'boardColumns', - 'notificationActions', - 'repositoryConnections', - 'ticketFieldDefinitions', - 'ticketLinkTypes', - 'ticketPriorities', - 'ticketStatuses.allowedTargetStatuses', - 'ticketTypes', + $sourceProject->load([ + 'boardColumns', 'notificationActions', 'repositoryConnections', 'ticketFieldDefinitions', + 'ticketLinkTypes', 'ticketPriorities', 'ticketStatuses.allowedTargetStatuses', 'ticketTypes', ]); - $configuration = []; if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) { @@ -129,120 +133,65 @@ class DuplicateProjectConfiguration ]; } - if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) { - $boardColumnIndexes = []; - $boardColumns = []; + $columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys( + static fn ($column, int $index): array => [$column->id => $index], + ); + $statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys( + static fn ($status, int $index): array => [$status->id => $index], + ); - foreach ($sourceProject->boardColumns as $index => $boardColumn) { - $boardColumnIndexes[$boardColumn->id] = $index; - $boardColumns[] = [ - 'name' => $boardColumn->name, - 'position' => $boardColumn->position, - ]; + 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], + )->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), + 'name' => $status->name, + 'position' => $status->position, + ], + )->all(); + } + + 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(), + )->all(); + } + + $simpleAreas = [ + self::COPY_TICKET_TYPES => fn ($item): array => ['name' => $item->name, 'position' => $item->position, 'is_parent' => $item->is_parent], + self::COPY_TICKET_PRIORITIES => fn ($item): array => ['name' => $item->name, 'position' => $item->position], + self::COPY_TICKET_FIELDS => fn ($item): array => ['field_type' => $item->field_type, 'label' => $item->label, 'position' => $item->position], + self::COPY_TICKET_LINK_TYPES => fn ($item): array => ['label' => $item->label, 'counterpart_label' => $item->counterpart_label, 'position' => $item->position], + self::COPY_REPOSITORY_CONNECTIONS => fn ($item): array => [ + 'provider' => $item->provider->value, 'base_url' => $item->base_url, + 'provider_repository_id' => $item->provider_repository_id, 'owner_name' => $item->owner_name, + 'repository_name' => $item->repository_name, 'repository_full_name' => $item->repository_full_name, + 'repository_url' => $item->repository_url, + ], + self::COPY_NOTIFICATION_ACTIONS => fn ($item): array => [ + '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 ?? [], + ], + ]; + $relations = [ + self::COPY_TICKET_TYPES => 'ticketTypes', self::COPY_TICKET_PRIORITIES => 'ticketPriorities', + self::COPY_TICKET_FIELDS => 'ticketFieldDefinitions', self::COPY_TICKET_LINK_TYPES => 'ticketLinkTypes', + self::COPY_REPOSITORY_CONNECTIONS => 'repositoryConnections', self::COPY_NOTIFICATION_ACTIONS => 'notificationActions', + ]; + + foreach ($simpleAreas as $area => $mapper) { + if (in_array($area, $selectedAreas, true)) { + $configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all(); } - - $ticketStatusIndexes = []; - $ticketStatuses = []; - - foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { - $ticketStatusIndexes[$ticketStatus->id] = $index; - $ticketStatuses[] = [ - 'board_column_index' => $ticketStatus->board_column_id === null - ? null - : ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null), - 'name' => $ticketStatus->name, - 'position' => $ticketStatus->position, - 'allowed_target_indexes' => [], - ]; - } - - foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { - $ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses - ->pluck('id') - ->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null) - ->filter(static fn (?int $statusIndex): bool => $statusIndex !== null) - ->values() - ->all(); - } - - $configuration[self::COPY_BOARD_WORKFLOW] = [ - 'board_columns' => $boardColumns, - 'ticket_statuses' => $ticketStatuses, - ]; - } - - if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) { - $configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes - ->map(fn ($ticketType): array => [ - 'name' => $ticketType->name, - 'position' => $ticketType->position, - 'is_parent' => $ticketType->is_parent, - ]) - ->values() - ->all(); - } - - if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) { - $configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities - ->map(fn ($ticketPriority): array => [ - 'name' => $ticketPriority->name, - 'position' => $ticketPriority->position, - ]) - ->values() - ->all(); - } - - if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) { - $configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions - ->map(fn ($ticketFieldDefinition): array => [ - 'field_type' => $ticketFieldDefinition->field_type, - 'label' => $ticketFieldDefinition->label, - 'position' => $ticketFieldDefinition->position, - ]) - ->values() - ->all(); - } - - if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) { - $configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes - ->map(fn ($ticketLinkType): array => [ - 'label' => $ticketLinkType->label, - 'counterpart_label' => $ticketLinkType->counterpart_label, - 'position' => $ticketLinkType->position, - ]) - ->values() - ->all(); - } - - if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) { - $configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections - ->map(fn ($repositoryConnection): array => [ - 'provider' => $repositoryConnection->provider->value, - 'base_url' => $repositoryConnection->base_url, - 'provider_repository_id' => $repositoryConnection->provider_repository_id, - 'owner_name' => $repositoryConnection->owner_name, - 'repository_name' => $repositoryConnection->repository_name, - 'repository_full_name' => $repositoryConnection->repository_full_name, - 'repository_url' => $repositoryConnection->repository_url, - ]) - ->values() - ->all(); - } - - if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) { - $configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions - ->map(fn ($notificationAction): array => [ - 'name' => $notificationAction->name, - 'enabled' => $notificationAction->enabled, - 'event_type' => $notificationAction->event_type->value, - 'channel' => $notificationAction->channel->value, - 'channel_configuration' => $notificationAction->channel_configuration ?? [], - 'message_template' => $notificationAction->message_template, - 'delivery_url' => $notificationAction->delivery_url, - 'filters' => $notificationAction->filters ?? [], - ]) - ->values() - ->all(); } return $configuration; @@ -250,151 +199,141 @@ class DuplicateProjectConfiguration /** * @param array $configuration + * @param array $statusReplacements + * @return list areas which retained referenced local values */ - public function apply(Project $targetProject, array $configuration): void + public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array { - if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) { - $projectSettings = $configuration[self::COPY_PROJECT_SETTINGS]; + $configuration = $this->normalizeConfiguration($configuration); - $targetProject->forceFill([ - 'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author), - 'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points), - ])->save(); - } + $blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array { + $blockedAreas = []; - if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) { - $targetProject->ticketStatuses()->delete(); - $targetProject->boardColumns()->delete(); - - $boardColumnIds = []; - - foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) { - $newBoardColumn = $targetProject->boardColumns()->create([ - 'name' => $boardColumn['name'], - 'position' => $boardColumn['position'], - ]); - - $boardColumnIds[] = $newBoardColumn->id; + if (isset($configuration[self::COPY_PROJECT_SETTINGS])) { + $targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly(); } - $ticketStatusIds = []; - - foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) { - $boardColumnIndex = $ticketStatus['board_column_index'] ?? null; - $newTicketStatus = $targetProject->ticketStatuses()->create([ - 'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null), - 'name' => $ticketStatus['name'], - 'position' => $ticketStatus['position'], - ]); - - $ticketStatusIds[] = $newTicketStatus->id; + 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(), + ); + if ($blocked) { + $blockedAreas[] = self::COPY_BOARD_COLUMNS; + } } - foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) { - $newTicketStatusId = $ticketStatusIds[$index] ?? null; + 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']); - if ($newTicketStatusId === null) { + return $item; + })->all(); + $blocked = $this->syncModels( + $targetProject->ticketStatuses()->get(), + $items, + fn (array $item) => $targetProject->ticketStatuses()->create($item), + static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(), + function (Model $status) use ($statusReplacements): bool { + if (! $status->tickets()->exists()) { + return true; + } + $replacementId = $statusReplacements[$status->id] ?? null; + if ($replacementId === null) { + return false; + } + $status->tickets()->update(['ticket_status_id' => $replacementId]); + + return true; + }, + ); + if ($blocked) { + $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); + if ($status !== null) { + $status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->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], + ]; + + foreach ($relations as $area => [$relation, $canDelete]) { + if (! isset($configuration[$area])) { continue; } - - $targetProject->ticketStatuses()->findOrFail($newTicketStatusId) - ->allowedTargetStatuses() - ->sync( - collect($ticketStatus['allowed_target_indexes'] ?? []) - ->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null) - ->filter(static fn (?int $statusId): bool => $statusId !== null) - ->values() - ->all(), - ); + $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) { + $blockedAreas[] = $area; + } } + + foreach ($targetProject->configurationRelations() as $relation) { + $targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]); + } + + return array_values(array_unique($blockedAreas)); + }); + + foreach ($blockedAreas as $area) { + $this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area); } - if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) { - $targetProject->ticketTypes()->delete(); - - foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) { - $targetProject->ticketTypes()->create([ - 'name' => $ticketType['name'], - 'position' => $ticketType['position'], - 'is_parent' => $ticketType['is_parent'], - ]); - } - } - - if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) { - $targetProject->ticketPriorities()->delete(); - - foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) { - $targetProject->ticketPriorities()->create([ - 'name' => $ticketPriority['name'], - 'position' => $ticketPriority['position'], - ]); - } - } - - if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) { - $targetProject->ticketFieldDefinitions()->delete(); - - foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) { - $targetProject->ticketFieldDefinitions()->create([ - 'field_type' => $ticketFieldDefinition['field_type'], - 'label' => $ticketFieldDefinition['label'], - 'position' => $ticketFieldDefinition['position'], - ]); - } - } - - if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) { - $targetProject->ticketLinkTypes()->delete(); - - foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) { - $targetProject->ticketLinkTypes()->create([ - 'label' => $ticketLinkType['label'], - 'counterpart_label' => $ticketLinkType['counterpart_label'], - 'position' => $ticketLinkType['position'], - ]); - } - } - - if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) { - $targetProject->repositoryConnections()->delete(); - - foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) { - $targetProject->repositoryConnections()->create([ - 'provider' => $repositoryConnection['provider'], - 'base_url' => $repositoryConnection['base_url'], - 'provider_repository_id' => $repositoryConnection['provider_repository_id'], - 'owner_name' => $repositoryConnection['owner_name'], - 'repository_name' => $repositoryConnection['repository_name'], - 'repository_full_name' => $repositoryConnection['repository_full_name'], - 'repository_url' => $repositoryConnection['repository_url'], - ]); - } - } - - if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) { - $targetProject->notificationActions()->delete(); - - foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) { - $targetProject->notificationActions()->create([ - 'name' => $notificationAction['name'], - 'enabled' => $notificationAction['enabled'], - 'event_type' => $notificationAction['event_type'], - 'channel' => $notificationAction['channel'], - 'channel_configuration' => $notificationAction['channel_configuration'] ?? [], - 'message_template' => $notificationAction['message_template'] ?? null, - 'delivery_url' => $notificationAction['delivery_url'], - 'filters' => $notificationAction['filters'] ?? [], - ]); - } - } + return $blockedAreas; } - /** - * @param list $copyAreas - */ + /** @param list $copyAreas */ public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void { $this->apply($targetProject, $this->export($sourceProject, $copyAreas)); } + + /** + * @param EloquentCollection $models + * @param array> $items + */ + private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool + { + foreach ($items as $index => $item) { + $model = $models->get($index); + $model === null ? $create($item) : $update($model, $item); + } + + $blocked = false; + foreach ($models->slice(count($items)) as $model) { + if ($canDelete !== null && ! $canDelete($model)) { + $blocked = true; + + continue; + } + $model->deleteQuietly(); + } + + return $blocked; + } } diff --git a/app/Actions/Projects/ProjectTemplateOverrideManager.php b/app/Actions/Projects/ProjectTemplateOverrideManager.php new file mode 100644 index 0000000..f01339e --- /dev/null +++ b/app/Actions/Projects/ProjectTemplateOverrideManager.php @@ -0,0 +1,55 @@ + 0) { + return; + } + + $project->loadMissing('projectTemplate'); + + if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) { + return; + } + + $overrides = array_values(array_unique([ + ...($project->template_overrides ?? []), + $area, + ])); + + $project->forceFill(['template_overrides' => $overrides])->saveQuietly(); + } + + /** + * @param list $areas + */ + public function clear(Project $project, array $areas): void + { + $remaining = array_values(array_diff($project->template_overrides ?? [], $areas)); + $project->forceFill(['template_overrides' => $remaining])->saveQuietly(); + } + + public function withoutRecording(callable $callback): mixed + { + self::$suppressionDepth++; + + try { + return $callback(); + } finally { + self::$suppressionDepth--; + } + } + + public function isRecording(): bool + { + return self::$suppressionDepth === 0; + } +} diff --git a/app/Actions/Projects/RefreshProjectBaseTemplate.php b/app/Actions/Projects/RefreshProjectBaseTemplate.php new file mode 100644 index 0000000..095ab11 --- /dev/null +++ b/app/Actions/Projects/RefreshProjectBaseTemplate.php @@ -0,0 +1,32 @@ +loadMissing('projectTemplate'); + + if ($project->projectTemplate === null || $project->projectTemplate->is_published) { + return; + } + + $snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration( + $project->projectTemplate->configuration_snapshot ?? [], + ); + + $project->projectTemplate->forceFill([ + 'configuration_snapshot' => array_replace( + $snapshot, + $this->duplicateProjectConfiguration->export($project->fresh(), [$area]), + ), + ])->saveQuietly(); + } +} diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php index 8b3c8a0..99d3519 100644 --- a/app/Actions/Projects/ResolveProjectTemplateState.php +++ b/app/Actions/Projects/ResolveProjectTemplateState.php @@ -30,10 +30,16 @@ class ResolveProjectTemplateState ? DuplicateProjectConfiguration::copyableAreas() : $this->duplicateProjectConfiguration->normalizeCopyAreas($areas); - $templateConfiguration = collect($projectTemplate->configuration_snapshot ?? []) + $normalizedTemplateConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration( + $projectTemplate->configuration_snapshot ?? [], + ); + $templateConfiguration = collect($normalizedTemplateConfiguration) ->only($selectedAreas) ->all(); $currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas); + $persistedOverrides = $projectTemplate->is_published + ? array_values(array_intersect($project->template_overrides ?? [], $selectedAreas)) + : []; $overriddenAreas = []; $inheritedAreas = []; @@ -41,13 +47,12 @@ class ResolveProjectTemplateState foreach ($selectedAreas as $area) { $label = $this->labelForArea($area); $hasTemplateValue = array_key_exists($area, $templateConfiguration); - $hasCurrentValue = array_key_exists($area, $currentConfiguration); - if (! $hasTemplateValue && ! $hasCurrentValue) { + if (! $hasTemplateValue) { continue; } - if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) { + if (! in_array($area, $persistedOverrides, true)) { $inheritedAreas[] = [ 'label' => $label, 'value' => $area, @@ -72,6 +77,7 @@ class ResolveProjectTemplateState 'inheritsFromTemplate' => $projectTemplate->is_published, 'overriddenAreas' => $overriddenAreas, 'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues), + 'resetRequirements' => $this->resetRequirements($project, $templateConfiguration, $persistedOverrides), 'templateConfiguration' => $templateConfiguration, 'templateId' => $projectTemplate->id, 'templateName' => $projectTemplate->name, @@ -79,6 +85,44 @@ class ResolveProjectTemplateState ]; } + /** + * @param array $templateConfiguration + * @param list $persistedOverrides + * @return array + */ + private function resetRequirements(Project $project, array $templateConfiguration, array $persistedOverrides): array + { + if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $persistedOverrides, true)) { + return []; + } + + $templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? []; + 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], + )->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(); + + return $removedStatuses === [] ? [] : [ + DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [ + 'removedStatuses' => $removedStatuses, + 'replacementOptions' => $replacementOptions, + ], + ]; + } + private function labelForArea(string $area): string { foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) { diff --git a/app/Actions/Projects/UpdateProjectTemplate.php b/app/Actions/Projects/UpdateProjectTemplate.php index f0d5528..c8231e4 100644 --- a/app/Actions/Projects/UpdateProjectTemplate.php +++ b/app/Actions/Projects/UpdateProjectTemplate.php @@ -2,6 +2,7 @@ namespace App\Actions\Projects; +use App\Models\Project; use App\Models\ProjectTemplate; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -10,6 +11,7 @@ class UpdateProjectTemplate { public function __construct( private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, + private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, ) {} /** @@ -17,48 +19,68 @@ class UpdateProjectTemplate */ public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate { - $projectTemplate->loadMissing('sourceProject', 'linkedProjects.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 = $projectTemplate->configuration_snapshot ?? []; + $previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration( + $projectTemplate->configuration_snapshot ?? [], + ); $nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas); - $affectedAreas = array_values(array_unique([ - ...array_keys($previousConfiguration), - ...array_keys($nextConfiguration), - ])); + $affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)])) + ->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null)) + ->values() + ->all(); DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void { + ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail(); + $projectTemplate->forceFill([ 'name' => $attributes['name'], 'copy_areas' => $normalizedCopyAreas, 'configuration_snapshot' => $nextConfiguration, ])->save(); - foreach ($projectTemplate->linkedProjects as $linkedProject) { - $currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject->fresh(), $affectedAreas); - $replacementConfiguration = []; + Project::query() + ->where('project_template_id', $projectTemplate->id) + ->orderBy('id') + ->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void { + foreach ($linkedProjects as $linkedProject) { + $linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail(); + $currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas); + $overrides = $linkedProject->template_overrides ?? []; + $replacementConfiguration = []; - foreach ($nextConfiguration as $area => $configuration) { - $isNewTemplateArea = ! array_key_exists($area, $previousConfiguration); - $matchesPreviousTemplate = array_key_exists($area, $previousConfiguration) - && array_key_exists($area, $currentConfiguration) - && $currentConfiguration[$area] === $previousConfiguration[$area]; + foreach ($affectedAreas as $area) { + if (in_array($area, $overrides, true)) { + continue; + } - if ($isNewTemplateArea || $matchesPreviousTemplate) { - $replacementConfiguration[$area] = $configuration; + if (! array_key_exists($area, $nextConfiguration)) { + $this->projectTemplateOverrideManager->mark($linkedProject, $area); + + continue; + } + + if (! array_key_exists($area, $previousConfiguration) + && array_key_exists($area, $currentConfiguration) + && $currentConfiguration[$area] !== []) { + $this->projectTemplateOverrideManager->mark($linkedProject, $area); + + continue; + } + + $replacementConfiguration[$area] = $nextConfiguration[$area]; + } + + if ($replacementConfiguration !== []) { + $this->duplicateProjectConfiguration->apply($linkedProject, $replacementConfiguration); + } } - } - - if ($replacementConfiguration === []) { - continue; - } - - $this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration); - } + }); }); return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects'); diff --git a/app/Actions/Projects/UpdateProjectTemplateLink.php b/app/Actions/Projects/UpdateProjectTemplateLink.php index a7bcc39..c046b03 100644 --- a/app/Actions/Projects/UpdateProjectTemplateLink.php +++ b/app/Actions/Projects/UpdateProjectTemplateLink.php @@ -10,23 +10,39 @@ class UpdateProjectTemplateLink { public function __construct( private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, + private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager, ) {} public function switch(Project $project, ProjectTemplate $template): void { DB::transaction(function () use ($project, $template): void { + $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $configuration = $this->duplicateProjectConfiguration->normalizeConfiguration( + $template->configuration_snapshot ?? [], + ); + $localAreas = array_values(array_diff( + DuplicateProjectConfiguration::copyableAreas(), + array_keys($configuration), + )); + $project->forceFill([ 'project_template_id' => $template->id, - ])->save(); + 'template_overrides' => $localAreas, + ])->saveQuietly(); - $this->duplicateProjectConfiguration->apply( + $blockedAreas = $this->duplicateProjectConfiguration->apply($project, $configuration); + $this->projectTemplateOverrideManager->clear( $project->fresh(), - $template->configuration_snapshot ?? [], + array_values(array_diff(array_keys($configuration), $blockedAreas)), ); }); } - public function reset(Project $project): void + /** + * @param list $areas + * @param array $statusReplacements + */ + public function reset(Project $project, array $areas, array $statusReplacements = []): void { $template = $project->projectTemplate; @@ -34,10 +50,19 @@ class UpdateProjectTemplateLink return; } - DB::transaction(function () use ($project, $template): void { - $this->duplicateProjectConfiguration->apply( - $project->fresh(), + DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void { + $project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration( $template->configuration_snapshot ?? [], + ))->only($areas)->all(); + $blockedAreas = $this->duplicateProjectConfiguration->apply( + $project, + $configuration, + $statusReplacements, + ); + $this->projectTemplateOverrideManager->clear( + $project->fresh(), + array_values(array_diff(array_keys($configuration), $blockedAreas)), ); }); } diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index 985a410..56d89d6 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -47,11 +47,7 @@ class CreateProjectTicket $nextSequenceNumber = (int) Ticket::query() ->where('project_id', $project->id) ->max('sequence_number') + 1; - $ticketStatusQuery = TicketStatus::query() - ->where( - $project->project_template_id === null ? 'project_id' : 'project_template_id', - $project->project_template_id ?? $project->id, - ); + $ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id); $ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id')); diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 898a9df..d895e0f 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -37,6 +37,7 @@ class ProjectController extends Controller private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload, private readonly CreateProject $createProject, private readonly DeleteProject $deleteProject, + private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, private readonly ListVisibleProjects $listVisibleProjects, private readonly PermissionService $permissionService, private readonly ResolveProjectTemplateState $resolveProjectTemplateState, @@ -135,9 +136,10 @@ class ProjectController extends Controller public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse { + $validated = $request->validated(); $template = ProjectTemplate::query() ->published() - ->findOrFail($request->integer('source_template_id')); + ->findOrFail((int) Arr::get($validated, 'source_template_id')); $this->updateProjectTemplateLink->switch($project, $template); @@ -147,16 +149,29 @@ class ProjectController extends Controller public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse { - $request->validated(); + $validated = $request->validated(); $template = $project->projectTemplate; abort_if($template === null, 422, 'This project has no linked template to reset.'); - $this->updateProjectTemplateLink->reset($project->load('projectTemplate')); + $configuration = $this->duplicateProjectConfiguration->normalizeConfiguration( + $template->configuration_snapshot ?? [], + ); + $areas = array_values(Arr::get($validated, 'areas', array_keys($configuration))); + $statusReplacements = Arr::get($validated, 'status_replacements', []); - return to_route('projects.settings.general', $project) - ->with('status', "{$project->name} was reset to the {$template->name} template baseline."); + $this->updateProjectTemplateLink->reset( + $project->load('projectTemplate'), + $areas, + $statusReplacements, + ); + + $status = Arr::has($validated, 'areas') + ? "Selected settings were reset to the {$template->name} template." + : "{$project->name} was reset to the {$template->name} template baseline."; + + return back()->with('status', $status); } public function access(Request $request, Project $project): Response diff --git a/app/Http/Requests/Api/MoveProjectTicketRequest.php b/app/Http/Requests/Api/MoveProjectTicketRequest.php index 1ddccfa..a5b96ff 100644 --- a/app/Http/Requests/Api/MoveProjectTicketRequest.php +++ b/app/Http/Requests/Api/MoveProjectTicketRequest.php @@ -24,16 +24,13 @@ class MoveProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'ticket_status_id' => [ 'required', 'integer', Rule::exists('ticket_statuses', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], ]; diff --git a/app/Http/Requests/DestroyProjectTicketTypeRequest.php b/app/Http/Requests/DestroyProjectTicketTypeRequest.php index 64a0617..8950083 100644 --- a/app/Http/Requests/DestroyProjectTicketTypeRequest.php +++ b/app/Http/Requests/DestroyProjectTicketTypeRequest.php @@ -29,16 +29,13 @@ class DestroyProjectTicketTypeRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'replacement_ticket_type_id' => [ 'nullable', 'integer', Rule::exists('ticket_types', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], ]; diff --git a/app/Http/Requests/MoveProjectTicketRequest.php b/app/Http/Requests/MoveProjectTicketRequest.php index 5ed7e1f..76cd975 100644 --- a/app/Http/Requests/MoveProjectTicketRequest.php +++ b/app/Http/Requests/MoveProjectTicketRequest.php @@ -29,16 +29,13 @@ class MoveProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'ticket_status_id' => [ 'required', 'integer', Rule::exists('ticket_statuses', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], 'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])], diff --git a/app/Http/Requests/ResetProjectTemplateRequest.php b/app/Http/Requests/ResetProjectTemplateRequest.php index 25b0dda..670193d 100644 --- a/app/Http/Requests/ResetProjectTemplateRequest.php +++ b/app/Http/Requests/ResetProjectTemplateRequest.php @@ -2,10 +2,13 @@ namespace App\Http\Requests; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Arr; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class ResetProjectTemplateRequest extends FormRequest { @@ -23,11 +26,71 @@ class ResetProjectTemplateRequest extends FormRequest $project = $this->route('project'); return [ + 'areas' => ['sometimes', 'array', 'min:1'], + 'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())], 'confirmation_key' => [ - 'required', + 'required_without:areas', 'string', Rule::in([$project->key]), ], + 'status_replacements' => ['sometimes', 'array'], + 'status_replacements.*' => [ + 'required', + 'integer', + Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + ], + ]; + } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + /** @var Project $project */ + $project = $this->route('project'); + $validated = $validator->validated(); + $areas = array_values(Arr::get( + $validated, + 'areas', + DuplicateProjectConfiguration::copyableAreas(), + )); + + 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'); + $replacements = Arr::get($validated, 'status_replacements', []); + + foreach ($statuses->slice(count($templateStatuses)) as $status) { + if ($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}.", + ); + } + } + }, ]; } @@ -38,6 +101,7 @@ class ResetProjectTemplateRequest extends FormRequest { return [ 'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.', + 'areas.*.in' => 'Choose a valid template area to reset.', ]; } } diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php index 2e28543..a9fff7f 100644 --- a/app/Http/Requests/StoreProjectRequest.php +++ b/app/Http/Requests/StoreProjectRequest.php @@ -90,7 +90,7 @@ class StoreProjectRequest extends FormRequest 'copy_areas' => ['nullable', 'array'], 'copy_areas.*' => [ 'string', - Rule::in(DuplicateProjectConfiguration::copyableAreas()), + Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()), ], ]; } diff --git a/app/Http/Requests/StoreProjectTemplateRequest.php b/app/Http/Requests/StoreProjectTemplateRequest.php index a210f53..fe4f99e 100644 --- a/app/Http/Requests/StoreProjectTemplateRequest.php +++ b/app/Http/Requests/StoreProjectTemplateRequest.php @@ -33,7 +33,7 @@ class StoreProjectTemplateRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')], 'copy_areas' => ['required', 'array', 'min:1'], - 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())], + 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], ]; } } diff --git a/app/Http/Requests/StoreProjectTicketBranchRequest.php b/app/Http/Requests/StoreProjectTicketBranchRequest.php index 9e1f88f..c49b4d9 100644 --- a/app/Http/Requests/StoreProjectTicketBranchRequest.php +++ b/app/Http/Requests/StoreProjectTicketBranchRequest.php @@ -26,9 +26,6 @@ class StoreProjectTicketBranchRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'branch_name' => ['required', 'string', 'max:255'], @@ -36,7 +33,7 @@ class StoreProjectTicketBranchRequest extends FormRequest 'required', 'integer', Rule::exists('repository_connections', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], ]; diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index 2c90e92..4cc8191 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -48,9 +48,6 @@ class StoreProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'acceptance_criteria' => ['nullable', 'array'], @@ -70,21 +67,21 @@ class StoreProjectTicketRequest extends FormRequest 'required', 'integer', Rule::exists('ticket_priorities', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], 'ticket_status_id' => [ 'required', 'integer', Rule::exists('ticket_statuses', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], 'ticket_type_id' => [ 'required', 'integer', Rule::exists('ticket_types', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], 'story_points' => ['nullable', 'integer', 'min:0'], diff --git a/app/Http/Requests/StoreTicketLinkRequest.php b/app/Http/Requests/StoreTicketLinkRequest.php index 65904c6..a4f2915 100644 --- a/app/Http/Requests/StoreTicketLinkRequest.php +++ b/app/Http/Requests/StoreTicketLinkRequest.php @@ -28,9 +28,6 @@ class StoreTicketLinkRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'linked_ticket_id' => [ @@ -42,7 +39,7 @@ class StoreTicketLinkRequest extends FormRequest 'required', 'integer', Rule::exists('ticket_link_types', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], ]; diff --git a/app/Http/Requests/StoreTicketStatusRequest.php b/app/Http/Requests/StoreTicketStatusRequest.php index 696c35f..a1270e7 100644 --- a/app/Http/Requests/StoreTicketStatusRequest.php +++ b/app/Http/Requests/StoreTicketStatusRequest.php @@ -26,16 +26,13 @@ class StoreTicketStatusRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'board_column_id' => [ 'nullable', 'integer', Rule::exists('board_columns', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], 'name' => ['required', 'string', 'max:255'], diff --git a/app/Http/Requests/UpdateProjectTemplateRequest.php b/app/Http/Requests/UpdateProjectTemplateRequest.php index 85bfdfe..1007bdd 100644 --- a/app/Http/Requests/UpdateProjectTemplateRequest.php +++ b/app/Http/Requests/UpdateProjectTemplateRequest.php @@ -27,7 +27,7 @@ class UpdateProjectTemplateRequest extends FormRequest return [ 'name' => ['required', 'string', 'max:255'], 'copy_areas' => ['required', 'array', 'min:1'], - 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())], + 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], 'confirm_linked_projects_update' => [ Rule::requiredIf($requiresImpactConfirmation), 'accepted', diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index 9b42395..a337941 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -51,9 +51,6 @@ class UpdateProjectTicketRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'acceptance_criteria' => ['nullable', 'array'], @@ -72,7 +69,7 @@ class UpdateProjectTicketRequest extends FormRequest 'required', 'integer', Rule::exists('ticket_field_definitions', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], 'field_values.*.value_number' => ['nullable', 'numeric'], @@ -87,14 +84,14 @@ class UpdateProjectTicketRequest extends FormRequest 'required', 'integer', Rule::exists('ticket_priorities', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], 'ticket_type_id' => [ 'required', 'integer', Rule::exists('ticket_types', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], 'title' => ['required', 'string', 'max:255'], @@ -150,7 +147,7 @@ class UpdateProjectTicketRequest extends FormRequest $requestedTicketTypeId = Arr::get($validated, 'ticket_type_id'); $requestedTicketType = TicketType::query() - ->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id) + ->where('project_id', $project->id) ->find($requestedTicketTypeId); if ( diff --git a/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php index 60372ef..9a4f770 100644 --- a/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php +++ b/app/Http/Requests/UpdateTicketStatusTransitionsRequest.php @@ -29,16 +29,13 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'allowed_ticket_status_ids' => ['nullable', 'array'], 'allowed_ticket_status_ids.*' => [ 'integer', Rule::exists('ticket_statuses', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], ]; diff --git a/app/Http/Requests/ValidateProjectTicketBranchRequest.php b/app/Http/Requests/ValidateProjectTicketBranchRequest.php index ebae66a..56e52c0 100644 --- a/app/Http/Requests/ValidateProjectTicketBranchRequest.php +++ b/app/Http/Requests/ValidateProjectTicketBranchRequest.php @@ -26,9 +26,6 @@ class ValidateProjectTicketBranchRequest extends FormRequest { /** @var Project $project */ $project = $this->route('project'); - $projectTemplateId = $project->project_template_id; - $ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id'; - $ownershipValue = $projectTemplateId ?? $project->id; return [ 'branch_name' => ['required', 'string', 'max:255'], @@ -36,7 +33,7 @@ class ValidateProjectTicketBranchRequest extends FormRequest 'required', 'integer', Rule::exists('repository_connections', 'id')->where( - fn ($query) => $query->where($ownershipColumn, $ownershipValue), + fn ($query) => $query->where('project_id', $project->id), ), ], ]; diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index a4bafea..12022c8 100644 --- a/app/Http/Resources/ProjectResource.php +++ b/app/Http/Resources/ProjectResource.php @@ -37,6 +37,7 @@ class ProjectResource extends JsonResource 'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false), 'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []), 'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []), + 'reset_requirements' => Arr::get($templateState, 'resetRequirements', []), 'template_configuration' => Arr::get($templateState, 'templateConfiguration', []), 'template_id' => Arr::get($templateState, 'templateId'), 'template_name' => Arr::get($templateState, 'templateName'), diff --git a/app/Http/Resources/ProjectTemplateResource.php b/app/Http/Resources/ProjectTemplateResource.php index cdfbc4f..9146fb0 100644 --- a/app/Http/Resources/ProjectTemplateResource.php +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Models\ProjectTemplate; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -17,6 +18,8 @@ class ProjectTemplateResource extends JsonResource public function toArray(Request $request): array { if ($this->resource instanceof ProjectTemplate) { + $configuration = app(DuplicateProjectConfiguration::class); + return [ 'id' => $this->id, 'project_id' => $this->project_id, @@ -27,8 +30,10 @@ class ProjectTemplateResource extends JsonResource 'name' => $this->name, 'slug' => $this->slug, 'is_published' => $this->is_published, - 'copy_areas' => $this->copy_areas ?? [], - 'configuration_snapshot' => $this->configuration_snapshot ?? [], + 'copy_areas' => $configuration->normalizeCopyAreas($this->copy_areas ?? []), + 'configuration_snapshot' => $configuration->normalizeConfiguration( + $this->configuration_snapshot ?? [], + ), 'linked_project_count' => $this->whenCounted('linkedProjects'), 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), diff --git a/app/Models/Project.php b/app/Models/Project.php index ec907cb..57a9b89 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -13,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Str; -#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points'])] +#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points', 'template_overrides'])] class Project extends Model { /** @use HasFactory */ @@ -24,6 +24,7 @@ class Project extends Model return [ 'show_author' => 'boolean', 'show_story_points' => 'boolean', + 'template_overrides' => 'array', ]; } @@ -108,6 +109,21 @@ class Project extends Model ->orderBy('id'); } + /** @return list */ + public function configurationRelations(): array + { + return [ + 'boardColumns', + 'notificationActions', + 'repositoryConnections', + 'ticketFieldDefinitions', + 'ticketLinkTypes', + 'ticketPriorities', + 'ticketStatuses', + 'ticketTypes', + ]; + } + public function tickets(): HasMany { return $this->hasMany(Ticket::class) diff --git a/app/Models/TicketPriority.php b/app/Models/TicketPriority.php index 1f37f56..94588f9 100644 --- a/app/Models/TicketPriority.php +++ b/app/Models/TicketPriority.php @@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; 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'])] class TicketPriority extends Model { /** @use HasFactory */ use HasFactory; + use SyncsProjectTemplateOwnership; protected static function booted(): void @@ -27,4 +29,9 @@ class TicketPriority extends Model { return $this->belongsTo(Project::class); } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class); + } } diff --git a/app/Observers/ProjectConfigurationObserver.php b/app/Observers/ProjectConfigurationObserver.php new file mode 100644 index 0000000..8e9bf21 --- /dev/null +++ b/app/Observers/ProjectConfigurationObserver.php @@ -0,0 +1,66 @@ +markOverride($model); + } + + public function deleted(Model $model): void + { + $this->markOverride($model); + } + + private function markOverride(Model $model): void + { + if (! $this->projectTemplateOverrideManager->isRecording()) { + return; + } + + $project = $model->project()->first(); + + if ($project === null) { + return; + } + + $area = match (true) { + $model instanceof BoardColumn => DuplicateProjectConfiguration::COPY_BOARD_COLUMNS, + $model instanceof TicketStatus => DuplicateProjectConfiguration::COPY_TICKET_STATUSES, + $model instanceof TicketType => DuplicateProjectConfiguration::COPY_TICKET_TYPES, + $model instanceof TicketPriority => DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES, + $model instanceof TicketFieldDefinition => DuplicateProjectConfiguration::COPY_TICKET_FIELDS, + $model instanceof TicketLinkType => DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, + $model instanceof RepositoryConnection => DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS, + $model instanceof NotificationAction => DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS, + default => null, + }; + + if ($area === null) { + return; + } + + $this->refreshProjectBaseTemplate->handle($project, $area); + $this->projectTemplateOverrideManager->mark($project, $area); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e2489fa..f1daa5c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,9 +8,18 @@ use App\Actions\Notifications\TicketCreated; use App\Actions\Notifications\TicketStatusChanged; use App\Actions\Permissions\PermissionService; use App\Enums\Permission; +use App\Models\BoardColumn; +use App\Models\NotificationAction; use App\Models\Project; +use App\Models\RepositoryConnection; use App\Models\Team; +use App\Models\TicketFieldDefinition; +use App\Models\TicketLinkType; +use App\Models\TicketPriority; +use App\Models\TicketStatus; +use App\Models\TicketType; use App\Models\User; +use App\Observers\ProjectConfigurationObserver; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Date; @@ -36,6 +45,18 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + foreach ([ + BoardColumn::class, + NotificationAction::class, + RepositoryConnection::class, + TicketFieldDefinition::class, + TicketLinkType::class, + TicketPriority::class, + TicketStatus::class, + TicketType::class, + ] as $model) { + $model::observe(ProjectConfigurationObserver::class); + } if ($this->app->environment() !== 'local') { URL::forceScheme('https'); diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index 9d0ea0e..d9bffda 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -23,6 +23,7 @@ class ProjectFactory extends Factory 'key' => Str::upper(fake()->unique()->lexify('?????')), 'show_author' => true, 'show_story_points' => true, + 'template_overrides' => [], ]; } } diff --git a/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php b/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php index 01838fc..3105437 100644 --- a/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php +++ b/database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php @@ -44,13 +44,17 @@ return new class extends Migration 'projects.name', 'projects.project_template_id', 'project_templates.project_id as template_project_id', + 'project_templates.is_published as template_is_published', ]) ->get(); foreach ($projects as $project) { $projectTemplateId = $project->project_template_id; - if ($projectTemplateId === null || (int) ($project->template_project_id ?? 0) !== (int) $project->id) { + if ($projectTemplateId === null || ( + ! (bool) $project->template_is_published + && (int) ($project->template_project_id ?? 0) !== (int) $project->id + )) { $projectTemplateId = DB::table('project_templates')->insertGetId([ 'project_id' => $project->id, 'source_project_id' => $project->id, @@ -88,7 +92,7 @@ return new class extends Migration $projectModel = Project::query()->find($project->id); - if ($projectModel !== null) { + if ($projectModel !== null && ! (bool) $project->template_is_published) { DB::table('project_templates') ->where('id', $projectTemplateId) ->update([ diff --git a/database/migrations/2026_09_08_091101_add_template_override_tracking_to_projects_table.php b/database/migrations/2026_09_08_091101_add_template_override_tracking_to_projects_table.php new file mode 100644 index 0000000..4f6c601 --- /dev/null +++ b/database/migrations/2026_09_08_091101_add_template_override_tracking_to_projects_table.php @@ -0,0 +1,63 @@ +json('template_overrides')->default('[]')->after('project_template_id'); + }); + + $configuration = app(DuplicateProjectConfiguration::class); + + ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void { + $template->forceFill([ + 'copy_areas' => $configuration->normalizeCopyAreas($template->copy_areas ?? []), + 'configuration_snapshot' => $configuration->normalizeConfiguration( + $template->configuration_snapshot ?? [], + ), + ])->saveQuietly(); + }); + + Project::query() + ->whereHas('projectTemplate', fn ($query) => $query->where('is_published', true)) + ->with('projectTemplate') + ->eachById(function (Project $project) use ($configuration): void { + $templateConfiguration = $configuration->normalizeConfiguration( + $project->projectTemplate?->configuration_snapshot ?? [], + ); + $areas = array_values(array_unique([ + ...array_keys($templateConfiguration), + ...DuplicateProjectConfiguration::copyableAreas(), + ])); + $currentConfiguration = $configuration->export($project, $areas); + $overrides = collect($areas) + ->filter(fn (string $area): bool => ! array_key_exists($area, $templateConfiguration) + || ($currentConfiguration[$area] ?? null) !== $templateConfiguration[$area]) + ->values() + ->all(); + + $project->forceFill(['template_overrides' => $overrides])->saveQuietly(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('projects', function (Blueprint $table): void { + $table->dropColumn('template_overrides'); + }); + } +}; diff --git a/resources/js/components/projects/TemplateInheritancePanel.vue b/resources/js/components/projects/TemplateInheritancePanel.vue new file mode 100644 index 0000000..cc87e57 --- /dev/null +++ b/resources/js/components/projects/TemplateInheritancePanel.vue @@ -0,0 +1,177 @@ + + + diff --git a/resources/js/pages/projects/settings/Board.vue b/resources/js/pages/projects/settings/Board.vue index 06b7a78..580d5d9 100644 --- a/resources/js/pages/projects/settings/Board.vue +++ b/resources/js/pages/projects/settings/Board.vue @@ -3,6 +3,7 @@ import { Form, Head, Link } from '@inertiajs/vue3'; import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController'; import Heading from '@/components/Heading.vue'; import InputError from '@/components/InputError.vue'; +import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -138,31 +139,15 @@ const statusAllowsTarget = ( {{ status }} - - - - Template inheritance: {{ templateState.templateName }} - - - This project stays linked to its template. Sections below show whether the current configuration is still inherited or has local overrides. - - - - - {{ area.label }} · inherited - - - {{ area.label }} · overridden - - - + diff --git a/resources/js/pages/projects/settings/General.vue b/resources/js/pages/projects/settings/General.vue index 06d47a4..67e12d3 100644 --- a/resources/js/pages/projects/settings/General.vue +++ b/resources/js/pages/projects/settings/General.vue @@ -50,10 +50,6 @@ const switchTemplateForm = useForm({ )?.id, }); -const resetTemplateForm = useForm({ - confirmation_key: '', -}); - const deleteForm = useForm({ confirmation_key: '', }); @@ -76,10 +72,6 @@ const canSwitchTemplate = computed( switchTemplateForm.source_template_id !== undefined, ); -const isResetConfirmationValid = computed( - () => resetTemplateForm.confirmation_key === props.project.key, -); - const isDeleteConfirmationValid = computed( () => deleteForm.confirmation_key === props.project.key, ); @@ -89,19 +81,12 @@ const submitSwitchTemplate = (): void => { return; } - switchTemplateForm.post(ProjectController.switchTemplate.url(props.project.id), { - preserveScroll: true, - }); -}; - -const submitResetTemplate = (): void => { - if (!isResetConfirmationValid.value) { - return; - } - - resetTemplateForm.post(ProjectController.resetTemplate.url(props.project.id), { - preserveScroll: true, - }); + switchTemplateForm.post( + ProjectController.switchTemplate.url(props.project.id), + { + preserveScroll: true, + }, + ); }; const submitDelete = (): void => { @@ -209,56 +194,84 @@ const submitDelete = (): void => { Template baseline - Switch this project to another published template or reset it back to the currently linked template baseline. Both actions can overwrite local configuration. + Switch this project to another published template. Use + the inheritance panels to reset individual settings to + the currently linked template.
-

+

Current linked template

-

- {{ currentTemplate?.name ?? 'No linked template' }} +

+ {{ + currentTemplate?.name ?? + 'No linked template' + }}

{{ templateState?.inheritsFromTemplate - ? 'Inherited and overridden areas stay visible after switching or resetting.' + ? 'Inherited and overridden areas stay visible after switching.' : 'This project currently behaves like a standalone baseline.' }}

-

+

Warning

- Template changes can replace board workflow, ticket metadata, repositories, and notifications for copied areas. + Switching templates can replace inherited board + workflow, ticket metadata, repositories, and + notifications.

- Confirm each action with the exact project key to avoid accidental overwrites. + Confirm each action with the exact project key + to avoid accidental overwrites.

-
-
+
+
-

Switch to another template

+

+ Switch to another template +

- Applying a different template replaces the copied areas with that template's current baseline. + Applying a different template replaces the + copied areas with that template's current + baseline.

- + - +
- + - +
-
+

- Existing project-specific configuration in copied areas may be replaced or removed. + Existing project-specific configuration in + copied areas may be replaced or removed.

- -
-
-

Reset to current template baseline

-

- Re-apply the currently linked template and clear local divergence inside copied areas. -

-
- -
- - - -
- -
-

- This restores the current template baseline for copied areas and rewinds project-level overrides there. -

- - -
-
diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue index e9bd9dd..15c6d2e 100644 --- a/resources/js/pages/projects/settings/Metadata.vue +++ b/resources/js/pages/projects/settings/Metadata.vue @@ -3,6 +3,7 @@ import { Form, Head, Link } from '@inertiajs/vue3'; import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController'; import Heading from '@/components/Heading.vue'; import InputError from '@/components/InputError.vue'; +import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -115,31 +116,17 @@ const replacementTicketTypes = (ticketTypeId: number) => {{ status }}
- - - - Template inheritance: {{ templateState.templateName }} - - - Inherited sections stay synced with the template baseline. Local edits in these settings are treated as project-specific overrides. - - - - - {{ area.label }} · inherited - - - {{ area.label }} · overridden - - - +
@@ -699,7 +686,9 @@ const replacementTicketTypes = (ticketTypeId: number) => required placeholder="Blocked by" /> - +
- - - - Template inheritance: {{ templateState.templateName }} - - - Notification rules can remain inherited from the template or diverge here as project-level overrides. - - - - - {{ area.label }} · inherited - - - {{ area.label }} · overridden - - - +
@@ -543,7 +530,10 @@ const notificationActionTimestamp = ( />
No filters configured. This action runs @@ -552,8 +542,7 @@ const notificationActionTimestamp = (
@@ -1029,8 +1032,7 @@ const notificationActionTimestamp = (
diff --git a/resources/js/pages/projects/settings/Repositories.vue b/resources/js/pages/projects/settings/Repositories.vue index f6be54f..3c2687e 100644 --- a/resources/js/pages/projects/settings/Repositories.vue +++ b/resources/js/pages/projects/settings/Repositories.vue @@ -4,6 +4,7 @@ import { computed, watch } from 'vue'; import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController'; import Heading from '@/components/Heading.vue'; import InputError from '@/components/InputError.vue'; +import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -277,31 +278,11 @@ const submitUpdate = (): void => { {{ status }}
- - - - Template inheritance: {{ templateState.templateName }} - - - Repository links can stay inherited from the template or be replaced with project-specific overrides. - - - - - {{ area.label }} · inherited - - - {{ area.label }} · overridden - - - + @@ -320,9 +301,9 @@ const submitUpdate = (): void => {

- Without a matching personal connection, {{ appName }} only - shows the repository name and links you to your user - settings. + Without a matching personal connection, + {{ appName }} only shows the repository name and links + you to your user settings.

@@ -647,8 +628,8 @@ const submitUpdate = (): void => { > This project link is visible, but you need a matching Git connection in your user - settings before {{ appName }} can open or query - this repository for you. + settings before {{ appName }} can open or + query this repository for you.

@@ -683,9 +664,9 @@ const submitUpdate = (): void => { Detected personal sources - {{ appName }} loads repositories from your personal - Git connections and offers them for project - linking. + {{ appName }} loads repositories from your + personal Git connections and offers them for + project linking. diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index 77b9f6b..00e8ee2 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -78,6 +78,17 @@ export type ProjectTemplateState = { inheritsFromTemplate: boolean; overriddenAreas: ProjectTemplateStateArea[]; overriddenConfiguration?: Record; + resetRequirements?: Record< + string, + { + removedStatuses: Array<{ + id: number; + name: string; + ticketCount: number; + }>; + replacementOptions: Array<{ id: number; name: string }>; + } + >; templateConfiguration?: Record; templateId: number; templateName: string; diff --git a/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php index ff9f293..1fc8846 100644 --- a/tests/Feature/Api/ProjectApiTest.php +++ b/tests/Feature/Api/ProjectApiTest.php @@ -169,6 +169,9 @@ class ProjectApiTest extends TestCase 'position' => 2, 'is_parent' => false, ]); + $project->forceFill([ + 'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], + ])->saveQuietly(); $this->syncProjectPermissions($project, $user, ['view']); diff --git a/tests/Feature/Database/ProjectTemplateBaselineMigrationTest.php b/tests/Feature/Database/ProjectTemplateBaselineMigrationTest.php index dfcdf21..d1113bd 100644 --- a/tests/Feature/Database/ProjectTemplateBaselineMigrationTest.php +++ b/tests/Feature/Database/ProjectTemplateBaselineMigrationTest.php @@ -72,7 +72,7 @@ class ProjectTemplateBaselineMigrationTest extends TestCase $this->assertSame('lgcy-base-template', $template->slug); } - public function test_metadata_migration_moves_existing_configuration_to_the_project_template_and_preserves_snapshot(): void + public function test_metadata_migration_moves_existing_configuration_and_preserves_published_template_links(): void { Artisan::call('migrate:fresh', [ '--path' => [ @@ -131,6 +131,42 @@ class ProjectTemplateBaselineMigrationTest extends TestCase 'updated_at' => now(), ]); + DB::table('projects')->insert([ + 'name' => 'Linked Project', + 'key' => 'LINK', + 'show_author' => 1, + 'show_story_points' => 0, + 'project_template_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $linkedProjectId = (int) DB::table('projects')->where('key', 'LINK')->value('id'); + $publishedSnapshot = [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => false, + 'show_story_points' => true, + ], + ]; + $publishedTemplateId = DB::table('project_templates')->insertGetId([ + 'project_id' => null, + 'source_project_id' => $projectId, + 'name' => 'Published Template', + 'slug' => 'published-template', + 'is_published' => true, + 'copy_areas' => json_encode( + [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], + JSON_THROW_ON_ERROR, + ), + 'configuration_snapshot' => json_encode($publishedSnapshot, JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('projects')->where('id', $linkedProjectId)->update([ + 'project_template_id' => $publishedTemplateId, + ]); + Artisan::call('migrate', [ '--path' => 'database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php', ]); @@ -142,5 +178,18 @@ class ProjectTemplateBaselineMigrationTest extends TestCase $this->assertSame($project->project_template_id, $ticketType->project_template_id); $this->assertSame(['Bug'], array_column($snapshot[DuplicateProjectConfiguration::COPY_TICKET_TYPES], 'name')); + $this->assertSame( + $publishedTemplateId, + DB::table('projects')->where('id', $linkedProjectId)->value('project_template_id'), + ); + $this->assertSame( + $publishedSnapshot, + json_decode( + (string) DB::table('project_templates')->where('id', $publishedTemplateId)->value('configuration_snapshot'), + true, + 512, + JSON_THROW_ON_ERROR, + ), + ); } } diff --git a/tests/Feature/Mcp/KanbanGitMcpTest.php b/tests/Feature/Mcp/KanbanGitMcpTest.php index 1a2b313..aa1f2ed 100644 --- a/tests/Feature/Mcp/KanbanGitMcpTest.php +++ b/tests/Feature/Mcp/KanbanGitMcpTest.php @@ -3,8 +3,8 @@ namespace Tests\Feature\Mcp; use App\Actions\Notifications\DeliverNotificationAction; -use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Enums\NotificationDeliveryStatus; use App\Mcp\Servers\KanbanGitServer; use App\Mcp\Tools\GetTicket; @@ -247,6 +247,9 @@ class KanbanGitMcpTest extends TestCase 'position' => 1, 'is_parent' => false, ]); + $project->forceFill([ + 'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], + ])->saveQuietly(); $status = $project->ticketStatuses()->firstOrFail(); $ticket = $this->ticket($project, $status, [ 'ticket_key' => 'CORE-1', diff --git a/tests/Feature/Projects/ProjectBoardControllerTest.php b/tests/Feature/Projects/ProjectBoardControllerTest.php index 0faef37..b23334d 100644 --- a/tests/Feature/Projects/ProjectBoardControllerTest.php +++ b/tests/Feature/Projects/ProjectBoardControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Projects; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\BoardColumn; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\Ticket; use App\Models\TicketPriority; use App\Models\TicketStatus; @@ -272,6 +273,9 @@ class ProjectBoardControllerTest extends TestCase $user = User::factory()->create(); $project = Project::factory()->create(); $otherProject = Project::factory()->create(); + $sharedTemplate = ProjectTemplate::factory()->create(); + $project->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly(); + $otherProject->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly(); app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($otherProject); diff --git a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php index 964e5b8..994c368 100644 --- a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php @@ -2,8 +2,8 @@ namespace Tests\Feature\Projects; -use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\BoardColumn; use App\Models\Project; use App\Models\ProjectTemplate; @@ -54,7 +54,9 @@ class ProjectBoardSettingsControllerTest extends TestCase ->where('boardColumns.0.name', 'Todo') ->where('ticketStatuses.0.name', 'Backlog') ->where('templateState.templateName', 'Starter Workflow') - ->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW), + ->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_COLUMNS) + ->where('templateState.inheritedAreas.1.value', DuplicateProjectConfiguration::COPY_TICKET_STATUSES) + ->where('templateState.inheritedAreas.2.value', DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS), ); } @@ -308,6 +310,9 @@ class ProjectBoardSettingsControllerTest extends TestCase $otherProject = Project::factory()->create([ 'key' => 'EXT', ]); + $sharedTemplate = ProjectTemplate::factory()->create(); + $project->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly(); + $otherProject->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly(); $this->syncProjectPermissions($project, $user, ['edit']); $sourceStatus = TicketStatus::factory()->for($project)->create([ diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php index d502959..54f9c32 100644 --- a/tests/Feature/Projects/ProjectControllerTest.php +++ b/tests/Feature/Projects/ProjectControllerTest.php @@ -410,6 +410,8 @@ class ProjectControllerTest extends TestCase ], ]); + $response->assertRedirect()->assertSessionHasNoErrors(); + $project = Project::query()->where('key', 'DUPE')->firstOrFail(); $response->assertRedirect(route('projects.show', $project)); diff --git a/tests/Feature/Projects/ProjectTemplateControllerTest.php b/tests/Feature/Projects/ProjectTemplateControllerTest.php index d34ab6d..9bb0057 100644 --- a/tests/Feature/Projects/ProjectTemplateControllerTest.php +++ b/tests/Feature/Projects/ProjectTemplateControllerTest.php @@ -243,6 +243,13 @@ class ProjectTemplateControllerTest extends TestCase 'position' => 1, 'is_parent' => false, ]); + $inheritingProject->refresh()->forceFill(['template_overrides' => []])->saveQuietly(); + $overriddenProject->refresh()->forceFill([ + 'template_overrides' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], + ])->saveQuietly(); $sourceProject->forceFill([ 'show_author' => true, diff --git a/tests/Feature/Projects/ProjectTemplateOverrideTest.php b/tests/Feature/Projects/ProjectTemplateOverrideTest.php new file mode 100644 index 0000000..6a49951 --- /dev/null +++ b/tests/Feature/Projects/ProjectTemplateOverrideTest.php @@ -0,0 +1,263 @@ +create(); + $sourceStatus = TicketStatus::factory()->for($sourceProject)->create(['name' => 'Todo', 'position' => 1]); + $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]); + $project = Project::factory()->create(); + $status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]); + $project->forceFill(['project_template_id' => $template->id])->saveQuietly(); + $ticket = $this->createTicket($project, $status); + + $sourceStatus->update(['name' => 'Ready']); + app(UpdateProjectTemplate::class)->handle($template, [ + 'name' => $template->name, + 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], + ]); + + $this->assertSame('Ready', $status->fresh()->name); + $this->assertSame($status->id, $ticket->fresh()->ticket_status_id); + $this->assertDatabaseHas('tickets', ['id' => $ticket->id]); + } + + public function test_projects_created_from_a_template_start_with_inherited_areas(): void + { + $sourceProject = Project::factory()->create(); + $sourceStatus = TicketStatus::factory()->for($sourceProject)->create(['name' => 'Todo', 'position' => 1]); + $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]); + + $project = app(CreateProject::class)->handle([ + 'name' => 'Inherited project', + 'key' => 'INHR', + 'creation_mode' => 'template', + 'source_template_id' => $template->id, + ], User::factory()->create()); + + $this->assertSame([], $project->template_overrides); + + $sourceStatus->update(['name' => 'Ready']); + app(UpdateProjectTemplate::class)->handle($template, [ + 'name' => $template->name, + 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], + ]); + + $this->assertSame(['Ready'], $project->ticketStatuses()->pluck('name')->all()); + } + + public function test_local_override_remains_detached_even_when_it_temporarily_matches_the_template(): void + { + $sourceProject = Project::factory()->create(); + $sourceStatus = TicketStatus::factory()->for($sourceProject)->create(['name' => 'Todo', 'position' => 1]); + $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]); + $project = Project::factory()->create(); + $status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]); + $project->forceFill(['project_template_id' => $template->id])->saveQuietly(); + + $status->update(['name' => 'Project only']); + $status->update(['name' => 'Todo']); + $this->assertContains( + DuplicateProjectConfiguration::COPY_TICKET_STATUSES, + $project->fresh()->template_overrides, + ); + $sourceStatus->update(['name' => 'Ready']); + app(UpdateProjectTemplate::class)->handle($template, [ + 'name' => $template->name, + 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], + ]); + + $this->assertSame('Todo', $status->fresh()->name); + $this->assertContains( + DuplicateProjectConfiguration::COPY_TICKET_STATUSES, + $project->fresh()->template_overrides, + ); + } + + public function test_reset_requires_and_applies_a_replacement_for_used_local_statuses(): void + { + $user = User::factory()->create(); + $sourceProject = Project::factory()->create(); + TicketStatus::factory()->for($sourceProject)->create(['name' => 'Template status', 'position' => 1]); + $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]); + $project = Project::factory()->create([ + 'project_template_id' => $template->id, + 'template_overrides' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], + ]); + $replacementStatus = TicketStatus::factory()->for($project)->create(['name' => 'Old template status', 'position' => 1]); + $localStatus = TicketStatus::factory()->for($project)->create(['name' => 'Local status', 'position' => 2]); + $ticket = $this->createTicket($project, $localStatus); + $this->syncProjectPermissions($project, $user, ['admin']); + + $url = route('projects.settings.general.reset-template', $project); + $this->actingAs($user)->from($url)->post($url, [ + 'areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], + ])->assertSessionHasErrors("status_replacements.{$localStatus->id}"); + + $this->actingAs($user)->from($url)->post($url, [ + 'areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES], + 'status_replacements' => [$localStatus->id => $replacementStatus->id], + ])->assertSessionHasNoErrors(); + + $this->assertDatabaseMissing('ticket_statuses', ['id' => $localStatus->id]); + $this->assertSame($replacementStatus->id, $ticket->fresh()->ticket_status_id); + $this->assertNotContains( + DuplicateProjectConfiguration::COPY_TICKET_STATUSES, + $project->fresh()->template_overrides, + ); + } + + public function test_reset_clears_only_the_selected_override_area(): void + { + $sourceProject = Project::factory()->create(['show_author' => true, 'show_story_points' => true]); + TicketType::factory()->for($sourceProject)->create(['name' => 'Template type', 'position' => 1]); + $template = $this->createTemplate($sourceProject, [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ]); + $project = Project::factory()->create([ + 'project_template_id' => $template->id, + 'show_author' => false, + 'show_story_points' => false, + 'template_overrides' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], + ]); + TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]); + + app(UpdateProjectTemplateLink::class)->reset( + $project->load('projectTemplate'), + [DuplicateProjectConfiguration::COPY_TICKET_TYPES], + ); + + $this->assertFalse($project->fresh()->show_author); + $this->assertSame(['Template type'], $project->ticketTypes()->pluck('name')->all()); + $this->assertSame( + [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], + $project->fresh()->template_overrides, + ); + } + + public function test_new_template_areas_do_not_replace_existing_local_configuration(): void + { + $sourceProject = Project::factory()->create(); + TicketType::factory()->for($sourceProject)->create(['name' => 'Template type', 'position' => 1]); + $template = $this->createTemplate($sourceProject, [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + ]); + $project = Project::factory()->create(); + TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]); + $project->forceFill(['project_template_id' => $template->id])->saveQuietly(); + + app(UpdateProjectTemplate::class)->handle($template, [ + 'name' => $template->name, + 'copy_areas' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], + ]); + + $this->assertSame(['Local type'], $project->ticketTypes()->pluck('name')->all()); + $this->assertContains( + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + $project->fresh()->template_overrides, + ); + } + + public function test_template_updates_preserve_custom_field_values(): void + { + $sourceProject = Project::factory()->create(); + $sourceField = TicketFieldDefinition::factory()->for($sourceProject)->create([ + 'field_type' => 'number', 'label' => 'Estimate', 'position' => 1, + ]); + $template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_FIELDS]); + $project = Project::factory()->create(); + $field = TicketFieldDefinition::factory()->for($project)->create([ + 'field_type' => 'number', 'label' => 'Estimate', 'position' => 1, + ]); + $project->forceFill(['project_template_id' => $template->id])->saveQuietly(); + $status = TicketStatus::factory()->for($project)->create(); + $ticket = $this->createTicket($project, $status); + $fieldValue = TicketFieldValue::factory()->create([ + 'ticket_id' => $ticket->id, + 'ticket_field_definition_id' => $field->id, + 'value_number' => 8, + ]); + + $sourceField->update(['label' => 'Story points']); + app(UpdateProjectTemplate::class)->handle($template, [ + 'name' => $template->name, + 'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_FIELDS], + ]); + + $this->assertSame('Story points', $field->fresh()->label); + $this->assertSame($field->id, $fieldValue->fresh()->ticket_field_definition_id); + $this->assertEquals(8, $fieldValue->fresh()->value_number); + } + + public function test_removing_a_board_column_never_deletes_its_statuses_or_tickets(): void + { + $project = Project::factory()->create(); + $column = BoardColumn::factory()->for($project)->create(); + $status = TicketStatus::factory()->for($project)->create([ + 'board_column_id' => $column->id, + ]); + $ticket = $this->createTicket($project, $status); + + $blockedAreas = app(DuplicateProjectConfiguration::class)->apply($project, [ + DuplicateProjectConfiguration::COPY_BOARD_COLUMNS => [], + ]); + + $this->assertSame([DuplicateProjectConfiguration::COPY_BOARD_COLUMNS], $blockedAreas); + $this->assertDatabaseHas('board_columns', ['id' => $column->id]); + $this->assertDatabaseHas('ticket_statuses', ['id' => $status->id]); + $this->assertSame($status->id, $ticket->fresh()->ticket_status_id); + } + + /** @param list $areas */ + private function createTemplate(Project $sourceProject, array $areas): ProjectTemplate + { + return ProjectTemplate::factory()->create([ + 'source_project_id' => $sourceProject->id, + 'copy_areas' => $areas, + 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($sourceProject, $areas), + ]); + } + + private function createTicket(Project $project, TicketStatus $status): Ticket + { + $type = TicketType::factory()->for($project)->create(); + $priority = TicketPriority::factory()->for($project)->create(); + + return Ticket::factory()->create([ + 'project_id' => $project->id, + 'ticket_status_id' => $status->id, + 'ticket_type_id' => $type->id, + 'ticket_priority_id' => $priority->id, + 'ticket_key' => $project->key.'-1', + ]); + } +} diff --git a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php index aafae3e..9773916 100644 --- a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php @@ -2,8 +2,8 @@ namespace Tests\Feature\Projects; -use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\Project; use App\Models\ProjectTemplate; use App\Models\Ticket; @@ -55,6 +55,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase $project->update([ 'project_template_id' => $template->id, 'show_story_points' => false, + 'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], ]); $this->syncProjectPermissions($project, $user, ['edit']); -- 2.54.0