Fix KAN-3 project template flows
linter / quality (pull_request) Successful in 1m31s
tests / ci (8.4) (pull_request) Successful in 1m47s

This commit is contained in:
2026-06-24 01:16:25 +02:00
parent c19cf34505
commit d250f0b380
14 changed files with 144 additions and 92 deletions
+23 -10
View File
@@ -27,44 +27,57 @@ class CreateProject
{
return DB::transaction(function () use ($attributes, $actor): Project {
$project = Project::query()->create(Arr::only($attributes, ['key', 'name']));
$creationMode = Arr::get($attributes, 'creation_mode', 'scratch');
$sourceTemplateId = Arr::get($attributes, 'source_template_id');
$baseTemplate = null;
$sourceTemplate = null;
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 ?? [],
);
}
if ($baseTemplate !== null) {
$baseTemplate->forceFill([
'configuration_snapshot' => $this->duplicateProjectConfiguration->export(
$project->fresh() ?? $project,
DuplicateProjectConfiguration::copyableAreas(),
),
])->save();
}
return $project->fresh() ?? $project;
});
@@ -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,
+8 -40
View File
@@ -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');
}
+2 -1
View File
@@ -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<NavItem[]>(() => {
if (page.props.auth.permissions.projectCreate) {
items.push({
title: 'Project templates',
href: '/project-templates',
href: projectTemplatesIndex(),
icon: Layers3,
});
}
+1 -1
View File
@@ -40,7 +40,7 @@ const form = useForm({
});
const submit = (): void => {
form.patch(save(props.user.id), {
form.patch(save.url(props.user.id), {
preserveScroll: true,
});
};
@@ -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<ProjectTemplatesCreatePageProps>();
@@ -23,7 +27,7 @@ const props = defineProps<ProjectTemplatesCreatePageProps>();
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,
});
};
@@ -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<ProjectTemplatesEditPageProps>();
@@ -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,
});
};
@@ -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<ProjectTemplatesIndexPageProps>();
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,
});
};
+1 -1
View File
@@ -63,7 +63,7 @@ const submit = (): void => {
form.source_template_id = '';
}
form.post(ProjectController.store(), {
form.post(ProjectController.store.url(), {
preserveScroll: true,
});
};
+2 -1
View File
@@ -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<ProjectShowPageProps>();
@@ -99,7 +100,7 @@ const projectAreas = [
as-child
variant="outline"
>
<Link :href="`/projects/${project.id}/templates/create`">
<Link :href="projectTemplateCreate(project.id)">
Save as new template
</Link>
</Button>
@@ -89,12 +89,9 @@ const submitSwitchTemplate = (): void => {
return;
}
switchTemplateForm.submit(
ProjectController.switchTemplate(props.project.id),
{
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,
});
};
+8 -4
View File
@@ -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
@@ -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
@@ -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());
}
}