epic/kan-3 #95
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ const submit = (): void => {
|
||||
form.source_template_id = '';
|
||||
}
|
||||
|
||||
form.post(ProjectController.store(), {
|
||||
form.post(ProjectController.store.url(), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
{
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user