From 57711b96593816f68729d2632c87ae9a1f038ed6 Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 25 May 2026 01:53:03 +0000 Subject: [PATCH 1/6] Show template inheritance overrides --- ...uildProjectNotificationSettingsPayload.php | 6 ++ .../BuildProjectBoardSettingsPayload.php | 7 ++ ...ldProjectTicketMetadataSettingsPayload.php | 11 +++ .../Projects/ResolveProjectTemplateState.php | 86 +++++++++++++++++++ .../BuildProjectRepositorySettingsPayload.php | 6 ++ app/Http/Controllers/ProjectController.php | 3 + resources/js/pages/projects/Show.vue | 26 ++++++ .../js/pages/projects/settings/Board.vue | 26 ++++++ .../js/pages/projects/settings/Metadata.vue | 26 ++++++ .../pages/projects/settings/Notifications.vue | 26 ++++++ .../pages/projects/settings/Repositories.vue | 26 ++++++ resources/js/types/page-props.ts | 20 +++++ .../ProjectBoardSettingsControllerTest.php | 15 +++- ...jectNotificationSettingsControllerTest.php | 13 +++ ...rojectRepositorySettingsControllerTest.php | 15 +++- ...ctTicketMetadataSettingsControllerTest.php | 26 +++++- 16 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 app/Actions/Projects/ResolveProjectTemplateState.php diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php index ef2a338..65d1e02 100644 --- a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -2,6 +2,8 @@ namespace App\Actions\Notifications; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ResolveProjectTemplateState; use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; @@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload { public function __construct( private readonly ListProjectNotificationActions $listProjectNotificationActions, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, ) {} /** @@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload )->resolve(), 'project' => ProjectResource::make($project)->resolve(), 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS, + ]), 'templateVariables' => $this->templateVariables(), ]; } diff --git a/app/Actions/Projects/BuildProjectBoardSettingsPayload.php b/app/Actions/Projects/BuildProjectBoardSettingsPayload.php index a1992e6..72a5449 100644 --- a/app/Actions/Projects/BuildProjectBoardSettingsPayload.php +++ b/app/Actions/Projects/BuildProjectBoardSettingsPayload.php @@ -9,6 +9,10 @@ use App\Models\Project; class BuildProjectBoardSettingsPayload { + public function __construct( + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, + ) {} + /** * @return array */ @@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload )->resolve(), 'project' => ProjectResource::make($project)->resolve(), 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW, + ]), 'ticketStatuses' => TicketStatusResource::collection( $project->ticketStatuses() ->with(['allowedTargetStatuses.boardColumn', 'boardColumn']) diff --git a/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php b/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php index 1cc9c2c..3ab6fcf 100644 --- a/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php +++ b/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php @@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition; class BuildProjectTicketMetadataSettingsPayload { + public function __construct( + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, + ) {} + /** * @return array */ @@ -23,6 +27,13 @@ class BuildProjectTicketMetadataSettingsPayload 'project' => ProjectResource::make($project)->resolve(), 'error' => $error, 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES, + DuplicateProjectConfiguration::COPY_TICKET_FIELDS, + DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, + ]), 'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(), 'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(), 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php new file mode 100644 index 0000000..8c69824 --- /dev/null +++ b/app/Actions/Projects/ResolveProjectTemplateState.php @@ -0,0 +1,86 @@ +|null $areas + * @return array|null + */ + public function handle(Project $project, ?array $areas = null): ?array + { + $project->loadMissing('projectTemplate'); + + /** @var ProjectTemplate|null $projectTemplate */ + $projectTemplate = $project->projectTemplate; + + if ($projectTemplate === null) { + return null; + } + + $selectedAreas = $areas === null + ? DuplicateProjectConfiguration::copyableAreas() + : $this->duplicateProjectConfiguration->normalizeCopyAreas($areas); + + $templateConfiguration = collect($projectTemplate->configuration_snapshot ?? []) + ->only($selectedAreas) + ->all(); + $currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas); + + $overriddenAreas = []; + $inheritedAreas = []; + + foreach ($selectedAreas as $area) { + $label = $this->labelForArea($area); + $hasTemplateValue = array_key_exists($area, $templateConfiguration); + $hasCurrentValue = array_key_exists($area, $currentConfiguration); + + if (! $hasTemplateValue && ! $hasCurrentValue) { + continue; + } + + if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) { + $inheritedAreas[] = [ + 'label' => $label, + 'value' => $area, + ]; + + continue; + } + + $overriddenAreas[] = [ + 'label' => $label, + 'value' => $area, + ]; + } + + return [ + 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, array_flip(array_column($overriddenAreas, 'value')))), + 'inheritedAreas' => $inheritedAreas, + 'inheritsFromTemplate' => true, + 'overriddenAreas' => $overriddenAreas, + 'templateId' => $projectTemplate->id, + 'templateName' => $projectTemplate->name, + 'templateSlug' => $projectTemplate->slug, + ]; + } + + private function labelForArea(string $area): string + { + foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) { + if ($option['value'] === $area) { + return $option['label']; + } + } + + return $area; + } +} diff --git a/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php b/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php index e49b10f..ce22d98 100644 --- a/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php +++ b/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php @@ -4,6 +4,8 @@ namespace App\Actions\Repositories; use App\Actions\Git\Data\GitRepository; use App\Actions\Git\GitProviderManager; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\ResolveProjectTemplateState; use App\Http\Resources\AvailableGitRepositorySourceResource; use App\Http\Resources\ProjectResource; use App\Http\Resources\RepositoryConnectionResource; @@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload { public function __construct( private readonly GitProviderManager $gitProviderManager, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, ) {} /** @@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload 'project' => ProjectResource::make($project)->resolve(), 'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(), 'status' => $status, + 'templateState' => $this->resolveProjectTemplateState->handle($project, [ + DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS, + ]), ]; } diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 1f8ccb3..235d746 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -8,6 +8,7 @@ use App\Actions\Projects\CreateProject; use App\Actions\Projects\DeleteProject; use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\ListVisibleProjects; +use App\Actions\Projects\ResolveProjectTemplateState; use App\Enums\Permission; use App\Http\Requests\DeleteProjectRequest; use App\Http\Requests\StoreProjectRequest; @@ -35,6 +36,7 @@ class ProjectController extends Controller private readonly DeleteProject $deleteProject, private readonly ListVisibleProjects $listVisibleProjects, private readonly PermissionService $permissionService, + private readonly ResolveProjectTemplateState $resolveProjectTemplateState, ) {} public function index(Request $request): Response @@ -92,6 +94,7 @@ class ProjectController extends Controller return Inertia::render('projects/Show', [ 'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project), 'project' => ProjectResource::make($project)->resolve(), + 'templateState' => $this->resolveProjectTemplateState->handle($project), ]); } diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index d224dc5..bb91cab 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -110,6 +110,32 @@ const projectAreas = [ + + + + Built from template {{ templateState.templateName }} + + + This project keeps its template link. Inherited areas follow the template baseline; overridden areas were customized locally. + + + + + {{ area.label }} · inherited + + + {{ area.label }} · overridden + + + +
+ + + + 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 + + + + Workflow model diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue index 82eb655..e9bd9dd 100644 --- a/resources/js/pages/projects/settings/Metadata.vue +++ b/resources/js/pages/projects/settings/Metadata.vue @@ -115,6 +115,32 @@ 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 + + + +
diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue index e0740b5..5ad756f 100644 --- a/resources/js/pages/projects/settings/Notifications.vue +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -305,6 +305,32 @@ const notificationActionTimestamp = ( {{ status }}
+ + + + 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 + + + +
diff --git a/resources/js/pages/projects/settings/Repositories.vue b/resources/js/pages/projects/settings/Repositories.vue index 42efbb0..f6be54f 100644 --- a/resources/js/pages/projects/settings/Repositories.vue +++ b/resources/js/pages/projects/settings/Repositories.vue @@ -277,6 +277,32 @@ 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 + + + + Personal Git access diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index beef35b..f3cd3b4 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -61,9 +61,25 @@ export type ProjectTemplatesCreatePageProps = { project: ProjectModel; }; +export type ProjectTemplateStateArea = { + label: string; + value: string; +}; + +export type ProjectTemplateState = { + effectiveConfiguration: Record; + inheritedAreas: ProjectTemplateStateArea[]; + inheritsFromTemplate: boolean; + overriddenAreas: ProjectTemplateStateArea[]; + templateId: number; + templateName: string; + templateSlug: string; +}; + export type ProjectShowPageProps = { canManageAccess: boolean; project: ProjectModel; + templateState?: ProjectTemplateState | null; }; export type PermissionOption = { @@ -95,6 +111,7 @@ export type ProjectBoardSettingsPageProps = { boardColumns: BoardColumnModel[]; project: ProjectModel; status?: string | null; + templateState?: ProjectTemplateState | null; ticketStatuses: TicketStatusModel[]; }; @@ -121,6 +138,7 @@ export type ProjectTicketMetadataSettingsPageProps = { project: ProjectModel; error?: boolean; status?: string | null; + templateState?: ProjectTemplateState | null; ticketFieldDefinitions: TicketFieldDefinitionModel[]; ticketLinkTypes: TicketLinkTypeModel[]; ticketPriorities: TicketPriorityModel[]; @@ -132,6 +150,7 @@ export type ProjectRepositorySettingsPageProps = { project: ProjectModel; repositoryConnections: RepositoryConnectionModel[]; status?: string | null; + templateState?: ProjectTemplateState | null; }; export type ProjectNotificationSettingsPageProps = { @@ -143,6 +162,7 @@ export type ProjectNotificationSettingsPageProps = { notificationActions: NotificationActionModel[]; project: ProjectModel; status?: string | null; + templateState?: ProjectTemplateState | null; templateVariables: NotificationActionTemplateVariableModel[]; }; diff --git a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php index 160c9f6..964e5b8 100644 --- a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php @@ -3,8 +3,10 @@ namespace Tests\Feature\Projects; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Models\BoardColumn; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\Ticket; use App\Models\TicketStatus; use App\Models\User; @@ -31,6 +33,15 @@ class ProjectBoardSettingsControllerTest extends TestCase 'position' => 1, ]); + $template = ProjectTemplate::factory()->create([ + 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [ + DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW, + ]), + 'copy_areas' => [DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW], + 'name' => 'Starter Workflow', + ]); + $project->update(['project_template_id' => $template->id]); + $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) @@ -41,7 +52,9 @@ class ProjectBoardSettingsControllerTest extends TestCase ->where('project.id', $project->id) ->where('backlogTicketStatuses.0.name', 'Backlog') ->where('boardColumns.0.name', 'Todo') - ->where('ticketStatuses.0.name', 'Backlog'), + ->where('ticketStatuses.0.name', 'Backlog') + ->where('templateState.templateName', 'Starter Workflow') + ->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW), ); } diff --git a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php index 5d2c362..f48b384 100644 --- a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php @@ -2,10 +2,12 @@ namespace Tests\Feature\Projects; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; use App\Models\NotificationAction; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Inertia\Testing\AssertableInertia as Assert; @@ -41,6 +43,15 @@ class ProjectNotificationSettingsControllerTest extends TestCase ], ]); + $template = ProjectTemplate::factory()->create([ + 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [ + DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS, + ]), + 'copy_areas' => [DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS], + 'name' => 'Alert Template', + ]); + $project->update(['project_template_id' => $template->id]); + $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) @@ -72,6 +83,8 @@ class ProjectNotificationSettingsControllerTest extends TestCase ->where('templateVariables.6.value', '$actor_name') ->where('templateVariables.7.value', '$assignee_from') ->where('templateVariables.8.value', '$assignee_to') + ->where('templateState.templateName', 'Alert Template') + ->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS) ->missing('notificationActions.0.delivery_url'), ); } diff --git a/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php b/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php index 83b61e1..2da038c 100644 --- a/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature\Projects; use App\Actions\Git\GitProviderKey; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\RepositoryConnection; use App\Models\User; use App\Models\UserGitConnection; @@ -36,6 +38,15 @@ class ProjectRepositorySettingsControllerTest extends TestCase 'repository_url' => 'https://git.example.test/core-team/kanbangit', ]); + $template = ProjectTemplate::factory()->create([ + 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [ + DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS, + ]), + 'copy_areas' => [DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS], + 'name' => 'Connected Template', + ]); + $project->update(['project_template_id' => $template->id]); + $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) @@ -46,7 +57,9 @@ class ProjectRepositorySettingsControllerTest extends TestCase ->where('project.id', $project->id) ->where('repositoryConnections.0.repository_full_name', 'core-team/kanbangit') ->where('repositoryConnections.0.has_personal_access', false) - ->where('availableRepositorySources', []), + ->where('availableRepositorySources', []) + ->where('templateState.templateName', 'Connected Template') + ->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS), ); } diff --git a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php index 1387297..aafae3e 100644 --- a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature\Projects; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\Ticket; use App\Models\TicketFieldDefinition; use App\Models\TicketLinkType; @@ -37,6 +39,24 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase 'position' => 1, ]); + $template = ProjectTemplate::factory()->create([ + 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, + ]), + 'copy_areas' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, + ], + 'name' => 'Issue Tracker Template', + ]); + $project->update([ + 'project_template_id' => $template->id, + 'show_story_points' => false, + ]); + $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) @@ -45,12 +65,14 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase ->assertInertia(fn (Assert $page) => $page ->component('projects/settings/Metadata') ->where('project.show_author', true) - ->where('project.show_story_points', true) + ->where('project.show_story_points', false) ->where('ticketTypes.0.name', 'Task') ->where('ticketTypes.1.name', 'Release') ->where('ticketTypes.1.is_parent', true) ->where('ticketTypes.2.name', 'Bug') - ->where('ticketLinkTypes.0.label', 'Relates to'), + ->where('ticketLinkTypes.0.label', 'Relates to') + ->where('templateState.templateName', 'Issue Tracker Template') + ->where('templateState.overriddenAreas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS), ); } From ee9b9a276581525c60b1617db5f9a0d90090afef Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 25 May 2026 08:19:09 +0000 Subject: [PATCH 2/6] Add template context to API and MCP project payloads --- .../Projects/ResolveProjectTemplateState.php | 8 ++- app/Http/Resources/ProjectResource.php | 23 ++++++ .../Resources/ProjectTemplateResource.php | 2 + tests/Feature/Api/ProjectApiTest.php | 70 +++++++++++++++++++ tests/Feature/Mcp/KanbanGitMcpTest.php | 63 +++++++++++++++++ 5 files changed, 165 insertions(+), 1 deletion(-) diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php index 8c69824..40e8b0c 100644 --- a/app/Actions/Projects/ResolveProjectTemplateState.php +++ b/app/Actions/Projects/ResolveProjectTemplateState.php @@ -62,11 +62,17 @@ class ResolveProjectTemplateState ]; } + $overriddenAreaValues = array_flip(array_column($overriddenAreas, 'value')); + $inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value')); + return [ - 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, array_flip(array_column($overriddenAreas, 'value')))), + 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)), 'inheritedAreas' => $inheritedAreas, + 'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues), 'inheritsFromTemplate' => true, 'overriddenAreas' => $overriddenAreas, + 'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues), + 'templateConfiguration' => $templateConfiguration, 'templateId' => $projectTemplate->id, 'templateName' => $projectTemplate->name, 'templateSlug' => $projectTemplate->slug, diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index a30fba5..a4bafea 100644 --- a/app/Http/Resources/ProjectResource.php +++ b/app/Http/Resources/ProjectResource.php @@ -2,8 +2,10 @@ namespace App\Http\Resources; +use App\Actions\Projects\ResolveProjectTemplateState; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Arr; class ProjectResource extends JsonResource { @@ -14,11 +16,32 @@ class ProjectResource extends JsonResource */ public function toArray(Request $request): array { + $this->resource->loadMissing('projectTemplate'); + + $templateState = app(ResolveProjectTemplateState::class)->handle($this->resource); + return [ 'id' => $this->id, 'name' => $this->name, 'key' => $this->key, 'project_template_id' => $this->project_template_id, + 'project_template' => $this->projectTemplate === null + ? null + : ProjectTemplateResource::make($this->projectTemplate)->resolve(), + 'template_state' => $templateState === null + ? null + : [ + 'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []), + 'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []), + 'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []), + 'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false), + 'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []), + 'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []), + 'template_configuration' => Arr::get($templateState, 'templateConfiguration', []), + 'template_id' => Arr::get($templateState, 'templateId'), + 'template_name' => Arr::get($templateState, 'templateName'), + 'template_slug' => Arr::get($templateState, 'templateSlug'), + ], 'show_author' => $this->show_author, 'show_story_points' => $this->show_story_points, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Http/Resources/ProjectTemplateResource.php b/app/Http/Resources/ProjectTemplateResource.php index bf49dc9..3bcdcc9 100644 --- a/app/Http/Resources/ProjectTemplateResource.php +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -28,6 +28,7 @@ class ProjectTemplateResource extends JsonResource 'slug' => $this->slug, 'is_published' => $this->is_published, 'copy_areas' => $this->copy_areas ?? [], + 'configuration_snapshot' => $this->configuration_snapshot ?? [], 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; @@ -42,6 +43,7 @@ class ProjectTemplateResource extends JsonResource 'slug' => Arr::get($this->resource, 'slug'), 'is_published' => Arr::get($this->resource, 'is_published', true), 'copy_areas' => Arr::get($this->resource, 'copy_areas', []), + 'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []), 'created_at' => Arr::get($this->resource, 'created_at'), 'updated_at' => Arr::get($this->resource, 'updated_at'), ]; diff --git a/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php index 4d2ec2e..ff069f8 100644 --- a/tests/Feature/Api/ProjectApiTest.php +++ b/tests/Feature/Api/ProjectApiTest.php @@ -108,6 +108,76 @@ class ProjectApiTest extends TestCase $this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all()); } + public function test_project_api_exposes_template_context_and_overrides(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'TMP1', + 'name' => 'Template Driven Project', + 'show_author' => false, + 'show_story_points' => false, + ]); + $template = ProjectTemplate::factory()->create([ + 'name' => 'Workflow Template', + 'slug' => 'workflow-template', + 'copy_areas' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], + 'configuration_snapshot' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => true, + 'show_story_points' => true, + ], + DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ + [ + 'name' => 'Bug', + 'position' => 1, + 'is_parent' => false, + ], + [ + 'name' => 'Feature', + 'position' => 2, + 'is_parent' => false, + ], + ], + ], + ]); + + $project->update([ + 'project_template_id' => $template->id, + ]); + $project->ticketTypes()->delete(); + TicketType::factory()->create([ + 'project_id' => $project->id, + 'name' => 'Bug', + 'position' => 1, + 'is_parent' => false, + ]); + TicketType::factory()->create([ + 'project_id' => $project->id, + 'name' => 'Feature', + 'position' => 2, + 'is_parent' => false, + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + $this->withApplicationKey($user) + ->getJson(route('api.projects.show', $project)) + ->assertOk() + ->assertJsonPath('data.project_template.id', $template->id) + ->assertJsonPath('data.project_template.slug', 'workflow-template') + ->assertJsonPath('data.project_template.configuration_snapshot.project_settings.show_author', true) + ->assertJsonPath('data.template_state.inherits_from_template', true) + ->assertJsonPath('data.template_state.template_id', $template->id) + ->assertJsonPath('data.template_state.template_configuration.project_settings.show_author', true) + ->assertJsonPath('data.template_state.effective_configuration.ticket_types.0.name', 'Bug') + ->assertJsonPath('data.template_state.inherited_configuration.ticket_types.1.name', 'Feature') + ->assertJsonPath('data.template_state.overridden_configuration.project_settings.show_author', false) + ->assertJsonPath('data.template_state.overridden_areas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS); + } + public function test_users_can_duplicate_projects_via_api(): void { $user = User::factory()->create(); diff --git a/tests/Feature/Mcp/KanbanGitMcpTest.php b/tests/Feature/Mcp/KanbanGitMcpTest.php index 59adf59..1a2b313 100644 --- a/tests/Feature/Mcp/KanbanGitMcpTest.php +++ b/tests/Feature/Mcp/KanbanGitMcpTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Mcp; use App\Actions\Notifications\DeliverNotificationAction; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Enums\NotificationDeliveryStatus; use App\Mcp\Servers\KanbanGitServer; use App\Mcp\Tools\GetTicket; @@ -15,8 +16,10 @@ use App\Mcp\Tools\UpdateTicketDescription; use App\Models\BoardColumn; use App\Models\NotificationAction; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\Ticket; use App\Models\TicketStatus; +use App\Models\TicketType; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; @@ -207,6 +210,66 @@ class KanbanGitMcpTest extends TestCase ->etc()); } + public function test_get_ticket_exposes_project_template_context_and_overrides(): void + { + $user = User::factory()->create(); + $project = $this->project('CORE', 'Core project'); + $template = ProjectTemplate::factory()->create([ + 'name' => 'Agent Workflow Template', + 'slug' => 'agent-workflow-template', + 'copy_areas' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], + 'configuration_snapshot' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => true, + 'show_story_points' => true, + ], + DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ + [ + 'name' => 'Bug', + 'position' => 1, + 'is_parent' => false, + ], + ], + ], + ]); + $project->update([ + 'project_template_id' => $template->id, + 'show_author' => false, + 'show_story_points' => false, + ]); + $project->ticketTypes()->delete(); + TicketType::factory()->create([ + 'project_id' => $project->id, + 'name' => 'Bug', + 'position' => 1, + 'is_parent' => false, + ]); + $status = $project->ticketStatuses()->firstOrFail(); + $ticket = $this->ticket($project, $status, [ + 'ticket_key' => 'CORE-1', + 'title' => 'Template aware ticket', + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(GetTicket::class, [ + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.project.project_template.id', $template->id) + ->where('data.project.project_template.slug', 'agent-workflow-template') + ->where('data.project.template_state.inherits_from_template', true) + ->where('data.project.template_state.template_configuration.project_settings.show_author', true) + ->where('data.project.template_state.overridden_configuration.project_settings.show_author', false) + ->where('data.project.template_state.effective_configuration.ticket_types.0.name', 'Bug') + ->etc()); + } + public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void { $user = User::factory()->create(); From e38c77198da90a01bb98ebd0ef32dd70cc397c05 Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 25 May 2026 08:31:35 +0000 Subject: [PATCH 3/6] Add template switch and reset flows for existing projects --- .../Projects/UpdateProjectTemplateLink.php | 44 ++++ app/Http/Controllers/ProjectController.php | 40 +++- .../Requests/ResetProjectTemplateRequest.php | 43 ++++ .../Requests/SwitchProjectTemplateRequest.php | 51 +++++ .../js/pages/projects/settings/General.vue | 189 ++++++++++++++++++ resources/js/types/models/project-model.ts | 4 + .../js/types/models/project-template-model.ts | 1 + resources/js/types/page-props.ts | 5 + routes/web/project/settings.php | 2 + .../Projects/ProjectControllerTest.php | 145 +++++++++++++- 10 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 app/Actions/Projects/UpdateProjectTemplateLink.php create mode 100644 app/Http/Requests/ResetProjectTemplateRequest.php create mode 100644 app/Http/Requests/SwitchProjectTemplateRequest.php diff --git a/app/Actions/Projects/UpdateProjectTemplateLink.php b/app/Actions/Projects/UpdateProjectTemplateLink.php new file mode 100644 index 0000000..a7bcc39 --- /dev/null +++ b/app/Actions/Projects/UpdateProjectTemplateLink.php @@ -0,0 +1,44 @@ +forceFill([ + 'project_template_id' => $template->id, + ])->save(); + + $this->duplicateProjectConfiguration->apply( + $project->fresh(), + $template->configuration_snapshot ?? [], + ); + }); + } + + public function reset(Project $project): void + { + $template = $project->projectTemplate; + + if ($template === null) { + return; + } + + DB::transaction(function () use ($project, $template): void { + $this->duplicateProjectConfiguration->apply( + $project->fresh(), + $template->configuration_snapshot ?? [], + ); + }); + } +} diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 235d746..898a9df 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -9,17 +9,20 @@ use App\Actions\Projects\DeleteProject; use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\ListVisibleProjects; use App\Actions\Projects\ResolveProjectTemplateState; +use App\Actions\Projects\UpdateProjectTemplateLink; use App\Enums\Permission; use App\Http\Requests\DeleteProjectRequest; +use App\Http\Requests\ResetProjectTemplateRequest; use App\Http\Requests\StoreProjectRequest; +use App\Http\Requests\SwitchProjectTemplateRequest; use App\Http\Requests\SyncProjectPermissionsRequest; use App\Http\Resources\PermissionOptionResource; use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectTemplateResource; -use App\Models\ProjectTemplate; use App\Http\Resources\TeamResource; use App\Http\Resources\UserResource; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\Team; use App\Models\User; use Illuminate\Http\RedirectResponse; @@ -37,6 +40,7 @@ class ProjectController extends Controller private readonly ListVisibleProjects $listVisibleProjects, private readonly PermissionService $permissionService, private readonly ResolveProjectTemplateState $resolveProjectTemplateState, + private readonly UpdateProjectTemplateLink $updateProjectTemplateLink, ) {} public function index(Request $request): Response @@ -117,10 +121,44 @@ class ProjectController extends Controller { return Inertia::render('projects/settings/General', [ 'project' => ProjectResource::make($project)->resolve(), + 'sourceTemplates' => ProjectTemplateResource::collection( + ProjectTemplate::query() + ->published() + ->with('sourceProject') + ->orderBy('name') + ->get(), + )->resolve(), 'status' => session('status'), + 'templateState' => $this->resolveProjectTemplateState->handle($project), ]); } + public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse + { + $template = ProjectTemplate::query() + ->published() + ->findOrFail($request->integer('source_template_id')); + + $this->updateProjectTemplateLink->switch($project, $template); + + return to_route('projects.settings.general', $project) + ->with('status', "{$project->name} now uses the {$template->name} template."); + } + + public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse + { + $request->validated(); + + $template = $project->projectTemplate; + + abort_if($template === null, 422, 'This project has no linked template to reset.'); + + $this->updateProjectTemplateLink->reset($project->load('projectTemplate')); + + return to_route('projects.settings.general', $project) + ->with('status', "{$project->name} was reset to the {$template->name} template baseline."); + } + public function access(Request $request, Project $project): Response { $user = $request->user(); diff --git a/app/Http/Requests/ResetProjectTemplateRequest.php b/app/Http/Requests/ResetProjectTemplateRequest.php new file mode 100644 index 0000000..25b0dda --- /dev/null +++ b/app/Http/Requests/ResetProjectTemplateRequest.php @@ -0,0 +1,43 @@ +|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'confirmation_key' => [ + 'required', + 'string', + Rule::in([$project->key]), + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.', + ]; + } +} diff --git a/app/Http/Requests/SwitchProjectTemplateRequest.php b/app/Http/Requests/SwitchProjectTemplateRequest.php new file mode 100644 index 0000000..4d898a9 --- /dev/null +++ b/app/Http/Requests/SwitchProjectTemplateRequest.php @@ -0,0 +1,51 @@ +|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'confirmation_key' => [ + 'required', + 'string', + Rule::in([$project->key]), + ], + 'source_template_id' => [ + 'required', + 'integer', + Rule::exists(ProjectTemplate::class, 'id')->where('is_published', true), + Rule::notIn(array_filter([$project->project_template_id])), + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirmation_key.in' => 'Enter the exact project key to confirm the template switch.', + 'source_template_id.not_in' => 'Choose a different published template to switch this project.', + ]; + } +} diff --git a/resources/js/pages/projects/settings/General.vue b/resources/js/pages/projects/settings/General.vue index f309dd8..42bded8 100644 --- a/resources/js/pages/projects/settings/General.vue +++ b/resources/js/pages/projects/settings/General.vue @@ -43,14 +43,70 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; +const switchTemplateForm = useForm({ + confirmation_key: '', + source_template_id: props.sourceTemplates.find( + (template) => template.id !== props.project.project_template_id, + )?.id, +}); + +const resetTemplateForm = useForm({ + confirmation_key: '', +}); + const deleteForm = useForm({ confirmation_key: '', }); +const availableSwitchTemplates = computed(() => + props.sourceTemplates.filter( + (template) => template.id !== props.project.project_template_id, + ), +); + +const currentTemplate = computed(() => props.project.project_template ?? null); + +const isSwitchConfirmationValid = computed( + () => switchTemplateForm.confirmation_key === props.project.key, +); + +const canSwitchTemplate = computed( + () => + isSwitchConfirmationValid.value && + switchTemplateForm.source_template_id !== undefined, +); + +const isResetConfirmationValid = computed( + () => resetTemplateForm.confirmation_key === props.project.key, +); + const isDeleteConfirmationValid = computed( () => deleteForm.confirmation_key === props.project.key, ); +const submitSwitchTemplate = (): void => { + if (!canSwitchTemplate.value) { + return; + } + + switchTemplateForm.submit( + ProjectController.switchTemplate(props.project.id), + { + preserveScroll: true, + }, + ); +}; + +const submitResetTemplate = (): void => { + if (!isResetConfirmationValid.value) { + return; + } + + resetTemplateForm.submit(ProjectController.resetTemplate(props.project.id), { + preserveScroll: true, + }); +}; + const submitDelete = (): void => { if (!isDeleteConfirmationValid.value) { return; @@ -152,6 +208,139 @@ 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. + + + + +
+
+

+ Current linked template +

+

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

+

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

+
+ +
+

+ Warning +

+

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

+

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

+
+
+ +
+
+
+

Switch to another template

+

+ 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. +

+ + +
+
+ +
+
+

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/types/models/project-model.ts b/resources/js/types/models/project-model.ts index 3cc97ce..5003a44 100644 --- a/resources/js/types/models/project-model.ts +++ b/resources/js/types/models/project-model.ts @@ -1,7 +1,11 @@ +import type { ProjectTemplateModel } from '@/types/models/project-template-model'; + export type ProjectModel = { id: number; name: string; key: string; + project_template_id?: number | null; + project_template?: ProjectTemplateModel | null; show_author: boolean; show_story_points: boolean; created_at: string; diff --git a/resources/js/types/models/project-template-model.ts b/resources/js/types/models/project-template-model.ts index c78f512..7024ebc 100644 --- a/resources/js/types/models/project-template-model.ts +++ b/resources/js/types/models/project-template-model.ts @@ -9,6 +9,7 @@ export type ProjectTemplateModel = { name: string; slug: string; copy_areas: string[]; + configuration_snapshot: Record; created_at: string | null; updated_at: string | null; }; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index f3cd3b4..fc6df96 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -69,8 +69,11 @@ export type ProjectTemplateStateArea = { export type ProjectTemplateState = { effectiveConfiguration: Record; inheritedAreas: ProjectTemplateStateArea[]; + inheritedConfiguration?: Record; inheritsFromTemplate: boolean; overriddenAreas: ProjectTemplateStateArea[]; + overriddenConfiguration?: Record; + templateConfiguration?: Record; templateId: number; templateName: string; templateSlug: string; @@ -117,7 +120,9 @@ export type ProjectBoardSettingsPageProps = { export type ProjectGeneralSettingsPageProps = { project: ProjectModel; + sourceTemplates: ProjectTemplateModel[]; status?: string | null; + templateState?: ProjectTemplateState | null; }; export type ProjectBoardPageProps = { diff --git a/routes/web/project/settings.php b/routes/web/project/settings.php index 5a99db3..ab2891f 100644 --- a/routes/web/project/settings.php +++ b/routes/web/project/settings.php @@ -8,6 +8,8 @@ use App\Http\Controllers\ProjectTicketMetadataSettingsController; use Illuminate\Support\Facades\Route; Route::get('general', [ProjectController::class, 'general'])->name('general'); +Route::post('general/switch-template', [ProjectController::class, 'switchTemplate'])->name('general.switch-template'); +Route::post('general/reset-template', [ProjectController::class, 'resetTemplate'])->name('general.reset-template'); Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board'); diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php index cddbbd4..a6ef6ce 100644 --- a/tests/Feature/Projects/ProjectControllerTest.php +++ b/tests/Feature/Projects/ProjectControllerTest.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\NotificationAction; use App\Models\Project; @@ -590,6 +590,149 @@ class ProjectControllerTest extends TestCase ); } + public function test_project_admins_can_switch_projects_to_another_template(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + 'name' => 'Core Platform', + 'show_author' => false, + 'show_story_points' => false, + ]); + $currentTemplate = ProjectTemplate::factory()->create([ + 'name' => 'Current Template', + 'slug' => 'current-template', + 'copy_areas' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], + 'configuration_snapshot' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => false, + 'show_story_points' => false, + ], + DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ + ['name' => 'Bug', 'position' => 1, 'is_parent' => false], + ], + ], + ]); + $newTemplate = ProjectTemplate::factory()->create([ + 'name' => 'Operations Template', + 'slug' => 'operations-template', + 'copy_areas' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], + 'configuration_snapshot' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => true, + 'show_story_points' => true, + ], + DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ + ['name' => 'Incident', 'position' => 1, 'is_parent' => false], + ['name' => 'Maintenance', 'position' => 2, 'is_parent' => false], + ], + ], + ]); + + $project->update(['project_template_id' => $currentTemplate->id]); + $project->ticketTypes()->delete(); + TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Bug', 'position' => 1, 'is_parent' => false]); + + $this->syncProjectPermissions($project, $user, ['admin']); + + $this->actingAs($user) + ->from(route('projects.settings.general', $project)) + ->post(route('projects.settings.general.switch-template', $project), [ + 'confirmation_key' => 'CORE', + 'source_template_id' => $newTemplate->id, + ]) + ->assertRedirect(route('projects.settings.general', $project)) + ->assertSessionHas('status', 'Core Platform now uses the Operations Template template.'); + + $project->refresh(); + + $this->assertSame($newTemplate->id, $project->project_template_id); + $this->assertTrue($project->show_author); + $this->assertTrue($project->show_story_points); + $this->assertSame(['Incident', 'Maintenance'], $project->ticketTypes()->pluck('name')->all()); + } + + public function test_project_admins_can_reset_projects_to_their_current_template_baseline(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + 'name' => 'Core Platform', + 'show_author' => false, + 'show_story_points' => false, + ]); + $template = ProjectTemplate::factory()->create([ + 'name' => 'Workflow Template', + 'slug' => 'workflow-template', + 'copy_areas' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], + 'configuration_snapshot' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => true, + 'show_story_points' => true, + ], + DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ + ['name' => 'Bug', 'position' => 1, 'is_parent' => false], + ['name' => 'Feature', 'position' => 2, 'is_parent' => false], + ], + ], + ]); + + $project->update(['project_template_id' => $template->id]); + $project->ticketTypes()->delete(); + TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Custom', 'position' => 1, 'is_parent' => false]); + + $this->syncProjectPermissions($project, $user, ['admin']); + + $this->actingAs($user) + ->from(route('projects.settings.general', $project)) + ->post(route('projects.settings.general.reset-template', $project), [ + 'confirmation_key' => 'CORE', + ]) + ->assertRedirect(route('projects.settings.general', $project)) + ->assertSessionHas('status', 'Core Platform was reset to the Workflow Template template baseline.'); + + $project->refresh(); + + $this->assertSame($template->id, $project->project_template_id); + $this->assertTrue($project->show_author); + $this->assertTrue($project->show_story_points); + $this->assertSame(['Bug', 'Feature'], $project->ticketTypes()->pluck('name')->all()); + } + + public function test_project_template_switch_requires_exact_project_key_confirmation(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + $project->update([ + 'project_template_id' => ProjectTemplate::factory()->create()->id, + ]); + $newTemplate = ProjectTemplate::factory()->create(); + + $this->syncProjectPermissions($project, $user, ['admin']); + + $this->actingAs($user) + ->from(route('projects.settings.general', $project)) + ->post(route('projects.settings.general.switch-template', $project), [ + 'confirmation_key' => 'core', + 'source_template_id' => $newTemplate->id, + ]) + ->assertRedirect(route('projects.settings.general', $project)) + ->assertSessionHasErrors('confirmation_key'); + + $this->assertNotSame($newTemplate->id, $project->fresh()->project_template_id); + } + /** * Ensure admins can delete projects and their dependent project-scoped data. */ From 39a039cd1e2e4389f33d62afee3a0bb11adbabd3 Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 25 May 2026 11:12:55 +0000 Subject: [PATCH 4/6] Add editable project template flow --- .../Projects/UpdateProjectTemplate.php | 66 +++++++ .../Controllers/ProjectTemplateController.php | 26 +++ .../Requests/UpdateProjectTemplateRequest.php | 47 +++++ .../Resources/ProjectTemplateResource.php | 2 + app/Models/ProjectTemplate.php | 8 +- resources/js/pages/project-templates/Edit.vue | 171 ++++++++++++++++++ .../js/pages/project-templates/Index.vue | 10 + .../js/types/models/project-template-model.ts | 1 + resources/js/types/page-props.ts | 5 + routes/web/project-templates.php | 2 + .../ProjectTemplateControllerTest.php | 101 +++++++++++ 11 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 app/Actions/Projects/UpdateProjectTemplate.php create mode 100644 app/Http/Requests/UpdateProjectTemplateRequest.php create mode 100644 resources/js/pages/project-templates/Edit.vue diff --git a/app/Actions/Projects/UpdateProjectTemplate.php b/app/Actions/Projects/UpdateProjectTemplate.php new file mode 100644 index 0000000..f0d5528 --- /dev/null +++ b/app/Actions/Projects/UpdateProjectTemplate.php @@ -0,0 +1,66 @@ +} $attributes + */ + public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate + { + $projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate'); + + $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 ?? []; + $nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas); + $affectedAreas = array_values(array_unique([ + ...array_keys($previousConfiguration), + ...array_keys($nextConfiguration), + ])); + + DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void { + $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 = []; + + 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]; + + if ($isNewTemplateArea || $matchesPreviousTemplate) { + $replacementConfiguration[$area] = $configuration; + } + } + + if ($replacementConfiguration === []) { + continue; + } + + $this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration); + } + }); + + return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects'); + } +} diff --git a/app/Http/Controllers/ProjectTemplateController.php b/app/Http/Controllers/ProjectTemplateController.php index d47908f..e9c1b6a 100644 --- a/app/Http/Controllers/ProjectTemplateController.php +++ b/app/Http/Controllers/ProjectTemplateController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers; use App\Actions\Projects\CreateProjectTemplate; use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\UpdateProjectTemplate; use App\Http\Requests\StoreProjectTemplateRequest; +use App\Http\Requests\UpdateProjectTemplateRequest; use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectTemplateResource; use App\Models\Project; @@ -18,6 +20,7 @@ class ProjectTemplateController extends Controller { public function __construct( private readonly CreateProjectTemplate $createProjectTemplate, + private readonly UpdateProjectTemplate $updateProjectTemplate, ) {} public function index(Request $request): Response @@ -28,12 +31,25 @@ class ProjectTemplateController extends Controller ProjectTemplate::query() ->published() ->with('sourceProject') + ->withCount('linkedProjects') ->orderBy('name') ->get(), )->resolve(), ]); } + public function edit(ProjectTemplate $projectTemplate): Response + { + abort_if(! $projectTemplate->is_published, 404); + + $projectTemplate->load('sourceProject')->loadCount('linkedProjects'); + + return Inertia::render('project-templates/Edit', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'template' => ProjectTemplateResource::make($projectTemplate)->resolve(), + ]); + } + public function create(Project $project): Response { return Inertia::render('project-templates/Create', [ @@ -50,6 +66,16 @@ class ProjectTemplateController extends Controller ->with('status', "Template {$template->name} was created."); } + public function update(UpdateProjectTemplateRequest $request, ProjectTemplate $projectTemplate): RedirectResponse + { + abort_if(! $projectTemplate->is_published, 404); + + $template = $this->updateProjectTemplate->handle($projectTemplate, $request->validated()); + + return to_route('project-templates.index') + ->with('status', "Template {$template->name} was updated."); + } + public function destroy(ProjectTemplate $projectTemplate): RedirectResponse { abort_if(! $projectTemplate->is_published, 404); diff --git a/app/Http/Requests/UpdateProjectTemplateRequest.php b/app/Http/Requests/UpdateProjectTemplateRequest.php new file mode 100644 index 0000000..85bfdfe --- /dev/null +++ b/app/Http/Requests/UpdateProjectTemplateRequest.php @@ -0,0 +1,47 @@ +user()?->can('create-project') ?? false; + } + + /** + * @return array|string> + */ + public function rules(): array + { + /** @var ProjectTemplate|null $projectTemplate */ + $projectTemplate = $this->route('projectTemplate'); + $requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'copy_areas' => ['required', 'array', 'min:1'], + 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())], + 'confirm_linked_projects_update' => [ + Rule::requiredIf($requiresImpactConfirmation), + 'accepted', + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirm_linked_projects_update.accepted' => 'Acknowledge the linked project warning before saving template changes.', + ]; + } +} diff --git a/app/Http/Resources/ProjectTemplateResource.php b/app/Http/Resources/ProjectTemplateResource.php index 3bcdcc9..cdfbc4f 100644 --- a/app/Http/Resources/ProjectTemplateResource.php +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -29,6 +29,7 @@ class ProjectTemplateResource extends JsonResource 'is_published' => $this->is_published, 'copy_areas' => $this->copy_areas ?? [], 'configuration_snapshot' => $this->configuration_snapshot ?? [], + 'linked_project_count' => $this->whenCounted('linkedProjects'), 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; @@ -44,6 +45,7 @@ class ProjectTemplateResource extends JsonResource 'is_published' => Arr::get($this->resource, 'is_published', true), 'copy_areas' => Arr::get($this->resource, 'copy_areas', []), 'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []), + 'linked_project_count' => Arr::get($this->resource, 'linked_project_count'), 'created_at' => Arr::get($this->resource, 'created_at'), 'updated_at' => Arr::get($this->resource, 'updated_at'), ]; diff --git a/app/Models/ProjectTemplate.php b/app/Models/ProjectTemplate.php index 272e636..726e775 100644 --- a/app/Models/ProjectTemplate.php +++ b/app/Models/ProjectTemplate.php @@ -4,10 +4,11 @@ namespace App\Models; use Database\Factories\ProjectTemplateFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable(['project_id', 'source_project_id', 'name', 'slug', 'is_published', 'copy_areas', 'configuration_snapshot'])] class ProjectTemplate extends Model @@ -38,4 +39,9 @@ class ProjectTemplate extends Model { return $this->belongsTo(Project::class, 'source_project_id'); } + + public function linkedProjects(): HasMany + { + return $this->hasMany(Project::class, 'project_template_id'); + } } diff --git a/resources/js/pages/project-templates/Edit.vue b/resources/js/pages/project-templates/Edit.vue new file mode 100644 index 0000000..038757b --- /dev/null +++ b/resources/js/pages/project-templates/Edit.vue @@ -0,0 +1,171 @@ + + + diff --git a/resources/js/pages/project-templates/Index.vue b/resources/js/pages/project-templates/Index.vue index dcba323..5b42fb5 100644 --- a/resources/js/pages/project-templates/Index.vue +++ b/resources/js/pages/project-templates/Index.vue @@ -11,6 +11,7 @@ import { CardTitle, } from '@/components/ui/card'; import AppLayout from '@/layouts/AppLayout.vue'; +import { edit as projectTemplatesEdit } from '@/routes/project-templates'; import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index'; import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types'; @@ -69,6 +70,9 @@ const removeTemplate = (templateId: number): void => {
{{ template.name }} {{ template.slug }} + + {{ template.linked_project_count }} linked {{ template.linked_project_count === 1 ? 'project' : 'projects' }} +