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 31b8528..0e9f9eb 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. */