From fed6a282a42a89ea80cf95ca7eb47f832f89de72 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 24 Jun 2026 01:16:25 +0200 Subject: [PATCH] Fix KAN-3 project template flows --- app/Actions/Projects/CreateProject.php | 53 ++++++++++++------- .../Projects/ResolveProjectTemplateState.php | 2 +- app/Models/Project.php | 48 +++-------------- resources/js/components/AppSidebar.vue | 3 +- .../js/pages/project-templates/Create.vue | 12 +++-- resources/js/pages/project-templates/Edit.vue | 10 ++-- .../js/pages/project-templates/Index.vue | 10 ++-- resources/js/pages/projects/Create.vue | 2 +- resources/js/pages/projects/Show.vue | 3 +- .../js/pages/projects/settings/General.vue | 13 ++--- tests/Feature/Api/ProjectApiTest.php | 12 +++-- .../Projects/ProjectControllerTest.php | 16 ++++-- .../ProjectTemplateControllerTest.php | 50 ++++++++++++++++- 13 files changed, 143 insertions(+), 91 deletions(-) diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php index f0e1134..221e3d9 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -27,44 +27,57 @@ class CreateProject { return DB::transaction(function () use ($attributes, $actor): Project { $project = Project::query()->create(Arr::only($attributes, ['key', 'name'])); - $baseTemplate = $this->createProjectBaseTemplate->handle($project); + $creationMode = Arr::get($attributes, 'creation_mode', 'scratch'); + $sourceTemplateId = Arr::get($attributes, 'source_template_id'); + $baseTemplate = null; + $sourceTemplate = null; - $project->forceFill([ - 'project_template_id' => $baseTemplate->id, - ])->save(); + if ($creationMode === 'template' && $sourceTemplateId !== null) { + $sourceTemplate = ProjectTemplate::query() + ->published() + ->findOrFail((int) $sourceTemplateId); + + $project->forceFill([ + 'project_template_id' => $sourceTemplate->id, + ])->save(); + } else { + $baseTemplate = $this->createProjectBaseTemplate->handle($project); + + $project->forceFill([ + 'project_template_id' => $baseTemplate->id, + ])->save(); + } $project = $project->fresh() ?? $project; $this->initializeProjectTicketMetadata->handle($project); $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); - $creationMode = $attributes['creation_mode'] ?? 'scratch'; - - if ($creationMode === 'duplicate' && isset($attributes['source_project_id'])) { - $sourceProject = Project::query()->findOrFail((int) $attributes['source_project_id']); + if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) { + $sourceProject = Project::query()->findOrFail((int) Arr::get($attributes, 'source_project_id')); $this->duplicateProjectConfiguration->handle( $sourceProject, $project, - array_values($attributes['copy_areas'] ?? []), + array_values(Arr::get($attributes, 'copy_areas', [])), ); } - if ($creationMode === 'template' && isset($attributes['source_template_id'])) { - $template = ProjectTemplate::query()->findOrFail((int) $attributes['source_template_id']); - + if ($creationMode === 'template' && $sourceTemplate !== null) { $this->duplicateProjectConfiguration->apply( - $project, - $template->configuration_snapshot ?? [], + $project->fresh() ?? $project, + $sourceTemplate->configuration_snapshot ?? [], ); } - $baseTemplate->forceFill([ - 'configuration_snapshot' => $this->duplicateProjectConfiguration->export( - $project->fresh() ?? $project, - DuplicateProjectConfiguration::copyableAreas(), - ), - ])->save(); + if ($baseTemplate !== null) { + $baseTemplate->forceFill([ + 'configuration_snapshot' => $this->duplicateProjectConfiguration->export( + $project->fresh() ?? $project, + DuplicateProjectConfiguration::copyableAreas(), + ), + ])->save(); + } return $project->fresh() ?? $project; }); diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php index 40e8b0c..8b3c8a0 100644 --- a/app/Actions/Projects/ResolveProjectTemplateState.php +++ b/app/Actions/Projects/ResolveProjectTemplateState.php @@ -69,7 +69,7 @@ class ResolveProjectTemplateState 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)), 'inheritedAreas' => $inheritedAreas, 'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues), - 'inheritsFromTemplate' => true, + 'inheritsFromTemplate' => $projectTemplate->is_published, 'overriddenAreas' => $overriddenAreas, 'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues), 'templateConfiguration' => $templateConfiguration, diff --git a/app/Models/Project.php b/app/Models/Project.php index 043cb8e..ec907cb 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -59,83 +59,51 @@ class Project extends Model public function boardColumns(): HasMany { - return $this->hasMany( - BoardColumn::class, - $this->project_template_id === null ? 'project_id' : 'project_template_id', - $this->project_template_id === null ? 'id' : 'project_template_id', - ) + return $this->hasMany(BoardColumn::class) ->orderBy('position'); } public function ticketFieldDefinitions(): HasMany { - return $this->hasMany( - TicketFieldDefinition::class, - $this->project_template_id === null ? 'project_id' : 'project_template_id', - $this->project_template_id === null ? 'id' : 'project_template_id', - ) + return $this->hasMany(TicketFieldDefinition::class) ->orderBy('position'); } public function ticketPriorities(): HasMany { - return $this->hasMany( - TicketPriority::class, - $this->project_template_id === null ? 'project_id' : 'project_template_id', - $this->project_template_id === null ? 'id' : 'project_template_id', - ) + return $this->hasMany(TicketPriority::class) ->orderBy('position'); } public function ticketLinkTypes(): HasMany { - return $this->hasMany( - TicketLinkType::class, - $this->project_template_id === null ? 'project_id' : 'project_template_id', - $this->project_template_id === null ? 'id' : 'project_template_id', - ) + return $this->hasMany(TicketLinkType::class) ->orderBy('position'); } public function ticketStatuses(): HasMany { - return $this->hasMany( - TicketStatus::class, - $this->project_template_id === null ? 'project_id' : 'project_template_id', - $this->project_template_id === null ? 'id' : 'project_template_id', - ) + return $this->hasMany(TicketStatus::class) ->orderBy('board_column_id') ->orderBy('position'); } public function ticketTypes(): HasMany { - return $this->hasMany( - TicketType::class, - $this->project_template_id === null ? 'project_id' : 'project_template_id', - $this->project_template_id === null ? 'id' : 'project_template_id', - ) + return $this->hasMany(TicketType::class) ->orderBy('position'); } public function repositoryConnections(): HasMany { - return $this->hasMany( - RepositoryConnection::class, - $this->project_template_id === null ? 'project_id' : 'project_template_id', - $this->project_template_id === null ? 'id' : 'project_template_id', - ) + return $this->hasMany(RepositoryConnection::class) ->orderBy('provider') ->orderBy('repository_full_name'); } public function notificationActions(): HasMany { - return $this->hasMany( - NotificationAction::class, - $this->project_template_id === null ? 'project_id' : 'project_template_id', - $this->project_template_id === null ? 'id' : 'project_template_id', - ) + return $this->hasMany(NotificationAction::class) ->orderBy('name') ->orderBy('id'); } diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index f15b3c7..e3e470a 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -25,6 +25,7 @@ import { } from '@/components/ui/sidebar'; import { dashboard } from '@/routes'; import { index as adminUsers } from '@/routes/admin/users'; +import { index as projectTemplatesIndex } from '@/routes/project-templates'; import { index as projectsIndex } from '@/routes/projects'; import { index as teamsIndex } from '@/routes/teams'; import type { NavItem } from '@/types'; @@ -48,7 +49,7 @@ const mainNavItems = computed(() => { if (page.props.auth.permissions.projectCreate) { items.push({ title: 'Project templates', - href: '/project-templates', + href: projectTemplatesIndex(), icon: Layers3, }); } diff --git a/resources/js/pages/project-templates/Create.vue b/resources/js/pages/project-templates/Create.vue index c5d50ca..4a7f98a 100644 --- a/resources/js/pages/project-templates/Create.vue +++ b/resources/js/pages/project-templates/Create.vue @@ -15,7 +15,11 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import AppLayout from '@/layouts/AppLayout.vue'; -import { show as projectShow } from '@/routes/projects/index'; +import { index as projectsIndex, show as projectShow } from '@/routes/projects/index'; +import { + create as projectTemplateCreate, + store as projectTemplateStore, +} from '@/routes/projects/templates'; import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types'; const props = defineProps(); @@ -23,7 +27,7 @@ const props = defineProps(); const breadcrumbs: BreadcrumbItem[] = [ { title: 'Projects', - href: '/projects', + href: projectsIndex(), }, { title: props.project.name, @@ -31,7 +35,7 @@ const breadcrumbs: BreadcrumbItem[] = [ }, { title: 'Save as new template', - href: `/projects/${props.project.id}/templates/create`, + href: projectTemplateCreate(props.project.id), }, ]; @@ -50,7 +54,7 @@ const copyAreasError = computed(() => { }); const submit = (): void => { - form.post(`/projects/${props.project.id}/templates`, { + form.post(projectTemplateStore.url(props.project.id), { preserveScroll: true, }); }; diff --git a/resources/js/pages/project-templates/Edit.vue b/resources/js/pages/project-templates/Edit.vue index 038757b..fccbf70 100644 --- a/resources/js/pages/project-templates/Edit.vue +++ b/resources/js/pages/project-templates/Edit.vue @@ -15,7 +15,11 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import AppLayout from '@/layouts/AppLayout.vue'; -import { index as projectTemplatesIndex } from '@/routes/project-templates'; +import { + edit as projectTemplatesEdit, + index as projectTemplatesIndex, + update as projectTemplatesUpdate, +} from '@/routes/project-templates'; import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types'; const props = defineProps(); @@ -27,7 +31,7 @@ const breadcrumbs: BreadcrumbItem[] = [ }, { title: props.template.name, - href: `/project-templates/${props.template.id}/edit`, + href: projectTemplatesEdit(props.template.id), }, ]; @@ -48,7 +52,7 @@ const copyAreasError = computed(() => { const linkedProjectCount = computed(() => props.template.linked_project_count ?? 0); const submit = (): void => { - form.patch(`/project-templates/${props.template.id}`, { + form.patch(projectTemplatesUpdate.url(props.template.id), { preserveScroll: true, }); }; diff --git a/resources/js/pages/project-templates/Index.vue b/resources/js/pages/project-templates/Index.vue index 5b42fb5..2f5bf61 100644 --- a/resources/js/pages/project-templates/Index.vue +++ b/resources/js/pages/project-templates/Index.vue @@ -11,7 +11,11 @@ import { CardTitle, } from '@/components/ui/card'; import AppLayout from '@/layouts/AppLayout.vue'; -import { edit as projectTemplatesEdit } from '@/routes/project-templates'; +import { + destroy as projectTemplatesDestroy, + edit as projectTemplatesEdit, + index as projectTemplatesIndex, +} from '@/routes/project-templates'; import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index'; import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types'; @@ -20,12 +24,12 @@ const props = defineProps(); const breadcrumbs: BreadcrumbItem[] = [ { title: 'Project templates', - href: '/project-templates', + href: projectTemplatesIndex(), }, ]; const removeTemplate = (templateId: number): void => { - router.delete(`/project-templates/${templateId}`, { + router.delete(projectTemplatesDestroy.url(templateId), { preserveScroll: true, }); }; diff --git a/resources/js/pages/projects/Create.vue b/resources/js/pages/projects/Create.vue index 67985d2..c46c12b 100644 --- a/resources/js/pages/projects/Create.vue +++ b/resources/js/pages/projects/Create.vue @@ -63,7 +63,7 @@ const submit = (): void => { form.source_template_id = ''; } - form.post(ProjectController.store(), { + form.post(ProjectController.store.url(), { preserveScroll: true, }); }; diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index bb91cab..637796f 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -22,6 +22,7 @@ import { notifications as notificationSettings, repositories as repositorySettings, } from '@/routes/projects/settings'; +import { create as projectTemplateCreate } from '@/routes/projects/templates'; import type { BreadcrumbItem, ProjectShowPageProps } from '@/types'; const props = defineProps(); @@ -99,7 +100,7 @@ const projectAreas = [ as-child variant="outline" > - + Save as new template diff --git a/resources/js/pages/projects/settings/General.vue b/resources/js/pages/projects/settings/General.vue index 42bded8..06d47a4 100644 --- a/resources/js/pages/projects/settings/General.vue +++ b/resources/js/pages/projects/settings/General.vue @@ -89,12 +89,9 @@ const submitSwitchTemplate = (): void => { return; } - switchTemplateForm.submit( - ProjectController.switchTemplate(props.project.id), - { - preserveScroll: true, - }, - ); + switchTemplateForm.post(ProjectController.switchTemplate.url(props.project.id), { + preserveScroll: true, + }); }; const submitResetTemplate = (): void => { @@ -102,7 +99,7 @@ const submitResetTemplate = (): void => { return; } - resetTemplateForm.submit(ProjectController.resetTemplate(props.project.id), { + resetTemplateForm.post(ProjectController.resetTemplate.url(props.project.id), { preserveScroll: true, }); }; @@ -112,7 +109,7 @@ const submitDelete = (): void => { return; } - deleteForm.submit(ProjectController.destroy(props.project.id), { + deleteForm.delete(ProjectController.destroy.url(props.project.id), { preserveScroll: true, }); }; diff --git a/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php index b08ef56..ff9f293 100644 --- a/tests/Feature/Api/ProjectApiTest.php +++ b/tests/Feature/Api/ProjectApiTest.php @@ -2,8 +2,8 @@ namespace Tests\Feature\Api; -use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\BoardColumn; use App\Models\Project; use App\Models\ProjectTemplate; @@ -102,15 +102,19 @@ class ProjectApiTest extends TestCase $project->refresh(); - $this->assertNotSame($template->id, $project->project_template_id); - $this->assertDatabaseHas('project_templates', [ - 'id' => $project->project_template_id, + $this->assertSame($template->id, $project->project_template_id); + $this->assertDatabaseMissing('project_templates', [ 'project_id' => $project->id, 'is_published' => false, ]); $this->assertFalse($project->show_author); $this->assertFalse($project->show_story_points); $this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all()); + $this->assertDatabaseHas('ticket_types', [ + 'project_id' => $project->id, + 'project_template_id' => $template->id, + 'name' => 'Spike', + ]); } public function test_project_api_exposes_template_context_and_overrides(): void diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php index 06605d4..d502959 100644 --- a/tests/Feature/Projects/ProjectControllerTest.php +++ b/tests/Feature/Projects/ProjectControllerTest.php @@ -285,9 +285,8 @@ class ProjectControllerTest extends TestCase $project->refresh(); - $this->assertNotSame($template->id, $project->project_template_id); - $this->assertDatabaseHas('project_templates', [ - 'id' => $project->project_template_id, + $this->assertSame($template->id, $project->project_template_id); + $this->assertDatabaseMissing('project_templates', [ 'project_id' => $project->id, 'is_published' => false, ]); @@ -295,6 +294,7 @@ class ProjectControllerTest extends TestCase $this->assertSame(false, $project->show_story_points); $this->assertDatabaseHas('ticket_types', [ 'project_id' => $project->id, + 'project_template_id' => $template->id, 'name' => 'Bug', 'position' => 1, 'is_parent' => false, @@ -661,6 +661,11 @@ class ProjectControllerTest extends TestCase $this->assertTrue($project->show_author); $this->assertTrue($project->show_story_points); $this->assertSame(['Incident', 'Maintenance'], $project->ticketTypes()->pluck('name')->all()); + $this->assertDatabaseHas('ticket_types', [ + 'project_id' => $project->id, + 'project_template_id' => $newTemplate->id, + 'name' => 'Incident', + ]); } public function test_project_admins_can_reset_projects_to_their_current_template_baseline(): void @@ -711,6 +716,11 @@ class ProjectControllerTest extends TestCase $this->assertTrue($project->show_author); $this->assertTrue($project->show_story_points); $this->assertSame(['Bug', 'Feature'], $project->ticketTypes()->pluck('name')->all()); + $this->assertDatabaseHas('ticket_types', [ + 'project_id' => $project->id, + 'project_template_id' => $template->id, + 'name' => 'Bug', + ]); } public function test_project_template_switch_requires_exact_project_key_confirmation(): void diff --git a/tests/Feature/Projects/ProjectTemplateControllerTest.php b/tests/Feature/Projects/ProjectTemplateControllerTest.php index 153e5a9..d34ab6d 100644 --- a/tests/Feature/Projects/ProjectTemplateControllerTest.php +++ b/tests/Feature/Projects/ProjectTemplateControllerTest.php @@ -192,15 +192,32 @@ class ProjectTemplateControllerTest extends TestCase 'show_author' => false, 'show_story_points' => false, ]); + TicketType::factory()->create([ + 'project_id' => $sourceProject->id, + 'name' => 'Feature', + 'position' => 1, + 'is_parent' => false, + ]); + $template = ProjectTemplate::factory()->create([ 'name' => 'Platform Template', 'slug' => 'platform-template', - 'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], + '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, + ], + ], ], 'source_project_id' => $sourceProject->id, ]); @@ -214,6 +231,18 @@ class ProjectTemplateControllerTest extends TestCase 'show_author' => true, 'show_story_points' => false, ]); + TicketType::factory()->create([ + 'project_id' => $inheritingProject->id, + 'name' => 'Bug', + 'position' => 1, + 'is_parent' => false, + ]); + TicketType::factory()->create([ + 'project_id' => $overriddenProject->id, + 'name' => 'Custom', + 'position' => 1, + 'is_parent' => false, + ]); $sourceProject->forceFill([ 'show_author' => true, @@ -226,7 +255,10 @@ class ProjectTemplateControllerTest extends TestCase ->patch(route('project-templates.update', $template), [ 'name' => 'Platform Template v2', 'slug' => 'should-not-change', - 'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], + 'copy_areas' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], 'confirm_linked_projects_update' => true, ]) ->assertRedirect('/project-templates'); @@ -242,12 +274,26 @@ class ProjectTemplateControllerTest extends TestCase 'show_author' => true, 'show_story_points' => true, ], + DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ + [ + 'name' => 'Feature', + 'position' => 1, + 'is_parent' => false, + ], + ], ], $template->configuration_snapshot); $this->assertTrue($inheritingProject->show_author); $this->assertTrue($inheritingProject->show_story_points); + $this->assertSame(['Feature'], $inheritingProject->ticketTypes()->pluck('name')->all()); + $this->assertDatabaseHas('ticket_types', [ + 'project_id' => $inheritingProject->id, + 'project_template_id' => $template->id, + 'name' => 'Feature', + ]); $this->assertTrue($overriddenProject->show_author); $this->assertFalse($overriddenProject->show_story_points); + $this->assertSame(['Custom'], $overriddenProject->ticketTypes()->pluck('name')->all()); } }