epic/kan-3 #95

Merged
Twyco merged 10 commits from epic/kan-3 into main 2026-09-08 10:27:26 +00:00
13 changed files with 143 additions and 91 deletions
Showing only changes of commit fed6a282a4 - Show all commits
+33 -20
View File
@@ -27,44 +27,57 @@ class CreateProject
{ {
return DB::transaction(function () use ($attributes, $actor): Project { return DB::transaction(function () use ($attributes, $actor): Project {
$project = Project::query()->create(Arr::only($attributes, ['key', 'name'])); $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([ if ($creationMode === 'template' && $sourceTemplateId !== null) {
'project_template_id' => $baseTemplate->id, $sourceTemplate = ProjectTemplate::query()
])->save(); ->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; $project = $project->fresh() ?? $project;
$this->initializeProjectTicketMetadata->handle($project); $this->initializeProjectTicketMetadata->handle($project);
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project); $this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
$creationMode = $attributes['creation_mode'] ?? 'scratch'; if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) {
$sourceProject = Project::query()->findOrFail((int) Arr::get($attributes, 'source_project_id'));
if ($creationMode === 'duplicate' && isset($attributes['source_project_id'])) {
$sourceProject = Project::query()->findOrFail((int) $attributes['source_project_id']);
$this->duplicateProjectConfiguration->handle( $this->duplicateProjectConfiguration->handle(
$sourceProject, $sourceProject,
$project, $project,
array_values($attributes['copy_areas'] ?? []), array_values(Arr::get($attributes, 'copy_areas', [])),
); );
} }
if ($creationMode === 'template' && isset($attributes['source_template_id'])) { if ($creationMode === 'template' && $sourceTemplate !== null) {
$template = ProjectTemplate::query()->findOrFail((int) $attributes['source_template_id']);
$this->duplicateProjectConfiguration->apply( $this->duplicateProjectConfiguration->apply(
$project, $project->fresh() ?? $project,
$template->configuration_snapshot ?? [], $sourceTemplate->configuration_snapshot ?? [],
); );
} }
$baseTemplate->forceFill([ if ($baseTemplate !== null) {
'configuration_snapshot' => $this->duplicateProjectConfiguration->export( $baseTemplate->forceFill([
$project->fresh() ?? $project, 'configuration_snapshot' => $this->duplicateProjectConfiguration->export(
DuplicateProjectConfiguration::copyableAreas(), $project->fresh() ?? $project,
), DuplicateProjectConfiguration::copyableAreas(),
])->save(); ),
])->save();
}
return $project->fresh() ?? $project; return $project->fresh() ?? $project;
}); });
@@ -69,7 +69,7 @@ class ResolveProjectTemplateState
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)), 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
'inheritedAreas' => $inheritedAreas, 'inheritedAreas' => $inheritedAreas,
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues), 'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
'inheritsFromTemplate' => true, 'inheritsFromTemplate' => $projectTemplate->is_published,
'overriddenAreas' => $overriddenAreas, 'overriddenAreas' => $overriddenAreas,
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues), 'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
'templateConfiguration' => $templateConfiguration, 'templateConfiguration' => $templateConfiguration,
+8 -40
View File
@@ -59,83 +59,51 @@ class Project extends Model
public function boardColumns(): HasMany public function boardColumns(): HasMany
{ {
return $this->hasMany( return $this->hasMany(BoardColumn::class)
BoardColumn::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function ticketFieldDefinitions(): HasMany public function ticketFieldDefinitions(): HasMany
{ {
return $this->hasMany( return $this->hasMany(TicketFieldDefinition::class)
TicketFieldDefinition::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function ticketPriorities(): HasMany public function ticketPriorities(): HasMany
{ {
return $this->hasMany( return $this->hasMany(TicketPriority::class)
TicketPriority::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function ticketLinkTypes(): HasMany public function ticketLinkTypes(): HasMany
{ {
return $this->hasMany( return $this->hasMany(TicketLinkType::class)
TicketLinkType::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function ticketStatuses(): HasMany public function ticketStatuses(): HasMany
{ {
return $this->hasMany( return $this->hasMany(TicketStatus::class)
TicketStatus::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('board_column_id') ->orderBy('board_column_id')
->orderBy('position'); ->orderBy('position');
} }
public function ticketTypes(): HasMany public function ticketTypes(): HasMany
{ {
return $this->hasMany( return $this->hasMany(TicketType::class)
TicketType::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function repositoryConnections(): HasMany public function repositoryConnections(): HasMany
{ {
return $this->hasMany( return $this->hasMany(RepositoryConnection::class)
RepositoryConnection::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('provider') ->orderBy('provider')
->orderBy('repository_full_name'); ->orderBy('repository_full_name');
} }
public function notificationActions(): HasMany public function notificationActions(): HasMany
{ {
return $this->hasMany( return $this->hasMany(NotificationAction::class)
NotificationAction::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('name') ->orderBy('name')
->orderBy('id'); ->orderBy('id');
} }
+2 -1
View File
@@ -25,6 +25,7 @@ import {
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users'; 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 projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams'; import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
@@ -48,7 +49,7 @@ const mainNavItems = computed<NavItem[]>(() => {
if (page.props.auth.permissions.projectCreate) { if (page.props.auth.permissions.projectCreate) {
items.push({ items.push({
title: 'Project templates', title: 'Project templates',
href: '/project-templates', href: projectTemplatesIndex(),
icon: Layers3, icon: Layers3,
}); });
} }
@@ -15,7 +15,11 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; 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'; import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
const props = defineProps<ProjectTemplatesCreatePageProps>(); const props = defineProps<ProjectTemplatesCreatePageProps>();
@@ -23,7 +27,7 @@ const props = defineProps<ProjectTemplatesCreatePageProps>();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
title: 'Projects', title: 'Projects',
href: '/projects', href: projectsIndex(),
}, },
{ {
title: props.project.name, title: props.project.name,
@@ -31,7 +35,7 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
{ {
title: 'Save as new template', 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 => { const submit = (): void => {
form.post(`/projects/${props.project.id}/templates`, { form.post(projectTemplateStore.url(props.project.id), {
preserveScroll: true, preserveScroll: true,
}); });
}; };
@@ -15,7 +15,11 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; 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'; import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
const props = defineProps<ProjectTemplatesEditPageProps>(); const props = defineProps<ProjectTemplatesEditPageProps>();
@@ -27,7 +31,7 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
{ {
title: props.template.name, 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 linkedProjectCount = computed(() => props.template.linked_project_count ?? 0);
const submit = (): void => { const submit = (): void => {
form.patch(`/project-templates/${props.template.id}`, { form.patch(projectTemplatesUpdate.url(props.template.id), {
preserveScroll: true, preserveScroll: true,
}); });
}; };
@@ -11,7 +11,11 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; 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 { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types'; import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
@@ -20,12 +24,12 @@ const props = defineProps<ProjectTemplatesIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
title: 'Project templates', title: 'Project templates',
href: '/project-templates', href: projectTemplatesIndex(),
}, },
]; ];
const removeTemplate = (templateId: number): void => { const removeTemplate = (templateId: number): void => {
router.delete(`/project-templates/${templateId}`, { router.delete(projectTemplatesDestroy.url(templateId), {
preserveScroll: true, preserveScroll: true,
}); });
}; };
+1 -1
View File
@@ -63,7 +63,7 @@ const submit = (): void => {
form.source_template_id = ''; form.source_template_id = '';
} }
form.post(ProjectController.store(), { form.post(ProjectController.store.url(), {
preserveScroll: true, preserveScroll: true,
}); });
}; };
+2 -1
View File
@@ -22,6 +22,7 @@ import {
notifications as notificationSettings, notifications as notificationSettings,
repositories as repositorySettings, repositories as repositorySettings,
} from '@/routes/projects/settings'; } from '@/routes/projects/settings';
import { create as projectTemplateCreate } from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types'; import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>(); const props = defineProps<ProjectShowPageProps>();
@@ -99,7 +100,7 @@ const projectAreas = [
as-child as-child
variant="outline" variant="outline"
> >
<Link :href="`/projects/${project.id}/templates/create`"> <Link :href="projectTemplateCreate(project.id)">
Save as new template Save as new template
</Link> </Link>
</Button> </Button>
@@ -89,12 +89,9 @@ const submitSwitchTemplate = (): void => {
return; return;
} }
switchTemplateForm.submit( switchTemplateForm.post(ProjectController.switchTemplate.url(props.project.id), {
ProjectController.switchTemplate(props.project.id), preserveScroll: true,
{ });
preserveScroll: true,
},
);
}; };
const submitResetTemplate = (): void => { const submitResetTemplate = (): void => {
@@ -102,7 +99,7 @@ const submitResetTemplate = (): void => {
return; return;
} }
resetTemplateForm.submit(ProjectController.resetTemplate(props.project.id), { resetTemplateForm.post(ProjectController.resetTemplate.url(props.project.id), {
preserveScroll: true, preserveScroll: true,
}); });
}; };
@@ -112,7 +109,7 @@ const submitDelete = (): void => {
return; return;
} }
deleteForm.submit(ProjectController.destroy(props.project.id), { deleteForm.delete(ProjectController.destroy.url(props.project.id), {
preserveScroll: true, preserveScroll: true,
}); });
}; };
+8 -4
View File
@@ -2,8 +2,8 @@
namespace Tests\Feature\Api; namespace Tests\Feature\Api;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate; use App\Models\ProjectTemplate;
@@ -102,15 +102,19 @@ class ProjectApiTest extends TestCase
$project->refresh(); $project->refresh();
$this->assertNotSame($template->id, $project->project_template_id); $this->assertSame($template->id, $project->project_template_id);
$this->assertDatabaseHas('project_templates', [ $this->assertDatabaseMissing('project_templates', [
'id' => $project->project_template_id,
'project_id' => $project->id, 'project_id' => $project->id,
'is_published' => false, 'is_published' => false,
]); ]);
$this->assertFalse($project->show_author); $this->assertFalse($project->show_author);
$this->assertFalse($project->show_story_points); $this->assertFalse($project->show_story_points);
$this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all()); $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 public function test_project_api_exposes_template_context_and_overrides(): void
@@ -285,9 +285,8 @@ class ProjectControllerTest extends TestCase
$project->refresh(); $project->refresh();
$this->assertNotSame($template->id, $project->project_template_id); $this->assertSame($template->id, $project->project_template_id);
$this->assertDatabaseHas('project_templates', [ $this->assertDatabaseMissing('project_templates', [
'id' => $project->project_template_id,
'project_id' => $project->id, 'project_id' => $project->id,
'is_published' => false, 'is_published' => false,
]); ]);
@@ -295,6 +294,7 @@ class ProjectControllerTest extends TestCase
$this->assertSame(false, $project->show_story_points); $this->assertSame(false, $project->show_story_points);
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id, 'project_id' => $project->id,
'project_template_id' => $template->id,
'name' => 'Bug', 'name' => 'Bug',
'position' => 1, 'position' => 1,
'is_parent' => false, 'is_parent' => false,
@@ -661,6 +661,11 @@ class ProjectControllerTest extends TestCase
$this->assertTrue($project->show_author); $this->assertTrue($project->show_author);
$this->assertTrue($project->show_story_points); $this->assertTrue($project->show_story_points);
$this->assertSame(['Incident', 'Maintenance'], $project->ticketTypes()->pluck('name')->all()); $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 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_author);
$this->assertTrue($project->show_story_points); $this->assertTrue($project->show_story_points);
$this->assertSame(['Bug', 'Feature'], $project->ticketTypes()->pluck('name')->all()); $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 public function test_project_template_switch_requires_exact_project_key_confirmation(): void
@@ -192,15 +192,32 @@ class ProjectTemplateControllerTest extends TestCase
'show_author' => false, 'show_author' => false,
'show_story_points' => false, 'show_story_points' => false,
]); ]);
TicketType::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Feature',
'position' => 1,
'is_parent' => false,
]);
$template = ProjectTemplate::factory()->create([ $template = ProjectTemplate::factory()->create([
'name' => 'Platform Template', 'name' => 'Platform Template',
'slug' => 'platform-template', 'slug' => 'platform-template',
'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], 'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [ 'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => false, 'show_author' => false,
'show_story_points' => false, 'show_story_points' => false,
], ],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
],
],
], ],
'source_project_id' => $sourceProject->id, 'source_project_id' => $sourceProject->id,
]); ]);
@@ -214,6 +231,18 @@ class ProjectTemplateControllerTest extends TestCase
'show_author' => true, 'show_author' => true,
'show_story_points' => false, '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([ $sourceProject->forceFill([
'show_author' => true, 'show_author' => true,
@@ -226,7 +255,10 @@ class ProjectTemplateControllerTest extends TestCase
->patch(route('project-templates.update', $template), [ ->patch(route('project-templates.update', $template), [
'name' => 'Platform Template v2', 'name' => 'Platform Template v2',
'slug' => 'should-not-change', '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, 'confirm_linked_projects_update' => true,
]) ])
->assertRedirect('/project-templates'); ->assertRedirect('/project-templates');
@@ -242,12 +274,26 @@ class ProjectTemplateControllerTest extends TestCase
'show_author' => true, 'show_author' => true,
'show_story_points' => true, 'show_story_points' => true,
], ],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Feature',
'position' => 1,
'is_parent' => false,
],
],
], $template->configuration_snapshot); ], $template->configuration_snapshot);
$this->assertTrue($inheritingProject->show_author); $this->assertTrue($inheritingProject->show_author);
$this->assertTrue($inheritingProject->show_story_points); $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->assertTrue($overriddenProject->show_author);
$this->assertFalse($overriddenProject->show_story_points); $this->assertFalse($overriddenProject->show_story_points);
$this->assertSame(['Custom'], $overriddenProject->ticketTypes()->pluck('name')->all());
} }
} }