From 57711b96593816f68729d2632c87ae9a1f038ed6 Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 25 May 2026 01:53:03 +0000 Subject: [PATCH] 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), ); }