Make projects depend on templates

This commit is contained in:
2026-09-08 10:25:52 +00:00
committed by Twyco
parent 40f9b473fc
commit fef82cb88f
19 changed files with 281 additions and 14 deletions
+13
View File
@@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB;
class CreateProject class CreateProject
{ {
public function __construct( public function __construct(
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly PermissionService $permissionService, private readonly PermissionService $permissionService,
@@ -49,6 +50,18 @@ class CreateProject
$project, $project,
$template->configuration_snapshot ?? [], $template->configuration_snapshot ?? [],
); );
$project->forceFill([
'project_template_id' => $template->id,
])->save();
}
if ($project->project_template_id === null) {
$baseTemplate = $this->createProjectBaseTemplate->handle($project->fresh() ?? $project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
])->save();
} }
return $project->fresh() ?? $project; return $project->fresh() ?? $project;
@@ -0,0 +1,28 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
class CreateProjectBaseTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function handle(Project $project): ProjectTemplate
{
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
return ProjectTemplate::query()->create([
'project_id' => $project->id,
'source_project_id' => $project->id,
'name' => "{$project->name} base template",
'slug' => strtolower($project->key).'-base-template',
'is_published' => false,
'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
]);
}
}
@@ -20,9 +20,11 @@ class CreateProjectTemplate
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); $copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
return ProjectTemplate::query()->create([ return ProjectTemplate::query()->create([
'project_id' => null,
'source_project_id' => $sourceProject->id, 'source_project_id' => $sourceProject->id,
'name' => $attributes['name'], 'name' => $attributes['name'],
'slug' => $attributes['slug'], 'slug' => $attributes['slug'],
'is_published' => true,
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas), 'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas), 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
]); ]);
+5 -1
View File
@@ -54,7 +54,11 @@ class ProjectController extends Controller
403, 403,
); );
$sourceTemplates = ProjectTemplate::query()->with('sourceProject')->orderBy('name')->get(); $sourceTemplates = ProjectTemplate::query()
->published()
->with('sourceProject')
->orderBy('name')
->get();
$selectedTemplateId = $request->integer('template'); $selectedTemplateId = $request->integer('template');
return Inertia::render('projects/Create', [ return Inertia::render('projects/Create', [
@@ -26,6 +26,7 @@ class ProjectTemplateController extends Controller
'status' => session('status'), 'status' => session('status'),
'templates' => ProjectTemplateResource::collection( 'templates' => ProjectTemplateResource::collection(
ProjectTemplate::query() ProjectTemplate::query()
->published()
->with('sourceProject') ->with('sourceProject')
->orderBy('name') ->orderBy('name')
->get(), ->get(),
@@ -51,6 +52,8 @@ class ProjectTemplateController extends Controller
public function destroy(ProjectTemplate $projectTemplate): RedirectResponse public function destroy(ProjectTemplate $projectTemplate): RedirectResponse
{ {
abort_if(! $projectTemplate->is_published, 404);
$templateName = $projectTemplate->name; $templateName = $projectTemplate->name;
$projectTemplate->delete(); $projectTemplate->delete();
+1 -1
View File
@@ -85,7 +85,7 @@ class StoreProjectRequest extends FormRequest
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'), Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'),
'nullable', 'nullable',
'integer', 'integer',
Rule::exists('project_templates', 'id'), Rule::exists('project_templates', 'id')->where(fn ($query) => $query->where('is_published', true)),
], ],
'copy_areas' => ['nullable', 'array'], 'copy_areas' => ['nullable', 'array'],
'copy_areas.*' => [ 'copy_areas.*' => [
+1
View File
@@ -18,6 +18,7 @@ class ProjectResource extends JsonResource
'id' => $this->id, 'id' => $this->id,
'name' => $this->name, 'name' => $this->name,
'key' => $this->key, 'key' => $this->key,
'project_template_id' => $this->project_template_id,
'show_author' => $this->show_author, 'show_author' => $this->show_author,
'show_story_points' => $this->show_story_points, 'show_story_points' => $this->show_story_points,
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
@@ -19,12 +19,14 @@ class ProjectTemplateResource extends JsonResource
if ($this->resource instanceof ProjectTemplate) { if ($this->resource instanceof ProjectTemplate) {
return [ return [
'id' => $this->id, 'id' => $this->id,
'project_id' => $this->project_id,
'source_project_id' => $this->source_project_id, 'source_project_id' => $this->source_project_id,
'source_project' => $this->relationLoaded('sourceProject') && $this->sourceProject !== null 'source_project' => $this->relationLoaded('sourceProject') && $this->sourceProject !== null
? ProjectResource::make($this->sourceProject)->resolve() ? ProjectResource::make($this->sourceProject)->resolve()
: null, : null,
'name' => $this->name, 'name' => $this->name,
'slug' => $this->slug, 'slug' => $this->slug,
'is_published' => $this->is_published,
'copy_areas' => $this->copy_areas ?? [], 'copy_areas' => $this->copy_areas ?? [],
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(),
@@ -33,10 +35,12 @@ class ProjectTemplateResource extends JsonResource
return [ return [
'id' => Arr::get($this->resource, 'id'), 'id' => Arr::get($this->resource, 'id'),
'project_id' => Arr::get($this->resource, 'project_id'),
'source_project_id' => Arr::get($this->resource, 'source_project_id'), 'source_project_id' => Arr::get($this->resource, 'source_project_id'),
'source_project' => Arr::get($this->resource, 'source_project'), 'source_project' => Arr::get($this->resource, 'source_project'),
'name' => Arr::get($this->resource, 'name'), 'name' => Arr::get($this->resource, 'name'),
'slug' => Arr::get($this->resource, 'slug'), 'slug' => Arr::get($this->resource, 'slug'),
'is_published' => Arr::get($this->resource, 'is_published', true),
'copy_areas' => Arr::get($this->resource, 'copy_areas', []), 'copy_areas' => Arr::get($this->resource, 'copy_areas', []),
'created_at' => Arr::get($this->resource, 'created_at'), 'created_at' => Arr::get($this->resource, 'created_at'),
'updated_at' => Arr::get($this->resource, 'updated_at'), 'updated_at' => Arr::get($this->resource, 'updated_at'),
+14 -1
View File
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Fillable(['name', 'key', 'show_author', 'show_story_points'])] #[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points'])]
class Project extends Model class Project extends Model
{ {
/** @use HasFactory<ProjectFactory> */ /** @use HasFactory<ProjectFactory> */
@@ -44,6 +46,17 @@ class Project extends Model
return $this->morphMany(PermissionAssignment::class, 'scope'); return $this->morphMany(PermissionAssignment::class, 'scope');
} }
public function projectTemplate(): BelongsTo
{
return $this->belongsTo(ProjectTemplate::class, 'project_template_id');
}
public function baseTemplate(): HasOne
{
return $this->hasOne(ProjectTemplate::class)
->where('is_published', false);
}
public function boardColumns(): HasMany public function boardColumns(): HasMany
{ {
return $this->hasMany(BoardColumn::class) return $this->hasMany(BoardColumn::class)
+13 -1
View File
@@ -7,8 +7,9 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Builder;
#[Fillable(['source_project_id', 'name', 'slug', 'copy_areas', 'configuration_snapshot'])] #[Fillable(['project_id', 'source_project_id', 'name', 'slug', 'is_published', 'copy_areas', 'configuration_snapshot'])]
class ProjectTemplate extends Model class ProjectTemplate extends Model
{ {
/** @use HasFactory<ProjectTemplateFactory> */ /** @use HasFactory<ProjectTemplateFactory> */
@@ -19,9 +20,20 @@ class ProjectTemplate extends Model
return [ return [
'copy_areas' => 'array', 'copy_areas' => 'array',
'configuration_snapshot' => 'array', 'configuration_snapshot' => 'array',
'is_published' => 'boolean',
]; ];
} }
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true);
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function sourceProject(): BelongsTo public function sourceProject(): BelongsTo
{ {
return $this->belongsTo(Project::class, 'source_project_id'); return $this->belongsTo(Project::class, 'source_project_id');
@@ -0,0 +1,54 @@
<?php
use App\Actions\Projects\CreateProjectBaseTemplate;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('project_templates', function (Blueprint $table): void {
$table->foreignId('project_id')->nullable()->after('id')->constrained('projects')->nullOnDelete();
$table->boolean('is_published')->default(true)->after('slug');
});
Schema::table('projects', function (Blueprint $table): void {
$table->foreignId('project_template_id')->nullable()->after('key')->constrained('project_templates')->nullOnDelete();
});
$projects = Project::query()->whereNull('project_template_id')->get();
$createProjectBaseTemplate = app(CreateProjectBaseTemplate::class);
foreach ($projects as $project) {
$baseTemplate = $createProjectBaseTemplate->handle($project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
])->save();
}
ProjectTemplate::query()
->whereNull('project_id')
->update(['is_published' => true]);
}
public function down(): void
{
Project::query()->update(['project_template_id' => null]);
ProjectTemplate::query()->where('is_published', false)->delete();
Schema::table('projects', function (Blueprint $table): void {
$table->dropConstrainedForeignId('project_template_id');
});
Schema::table('project_templates', function (Blueprint $table): void {
$table->dropConstrainedForeignId('project_id');
$table->dropColumn('is_published');
});
}
};
@@ -30,7 +30,7 @@ const breadcrumbs: BreadcrumbItem[] = [
href: projectShow(props.project.id), href: projectShow(props.project.id),
}, },
{ {
title: 'Save as template', title: 'Save as new template',
href: `/projects/${props.project.id}/templates/create`, href: `/projects/${props.project.id}/templates/create`,
}, },
]; ];
@@ -58,13 +58,13 @@ const submit = (): void => {
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Save ${project.name} as template`" /> <Head :title="`Save ${project.name} as new template`" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<div class="space-y-3"> <div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">{{ project.key }}</Badge> <Badge variant="outline" class="px-3 py-1">{{ project.key }}</Badge>
<Heading <Heading
:title="`Save ${project.name} as template`" :title="`Save ${project.name} as new template`"
description="Capture reusable project configuration without copying operational ticket data." description="Capture reusable project configuration without copying operational ticket data."
/> />
</div> </div>
@@ -127,7 +127,7 @@ const submit = (): void => {
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing"> <Button type="submit" :disabled="form.processing">
Save template Save new template
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
@@ -38,7 +38,7 @@ const removeTemplate = (templateId: number): void => {
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<Heading <Heading
title="Project templates" title="Project templates"
description="Reusable project configuration templates saved from existing projects." description="Reusable published project templates. Internal project base templates stay hidden here by default."
/> />
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
@@ -60,7 +60,7 @@ const removeTemplate = (templateId: number): void => {
</div> </div>
<div v-if="templates.length === 0" class="text-muted-foreground text-sm"> <div v-if="templates.length === 0" class="text-muted-foreground text-sm">
No project templates have been saved yet. No published project templates have been saved yet.
</div> </div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
+1 -1
View File
@@ -100,7 +100,7 @@ const projectAreas = [
variant="outline" variant="outline"
> >
<Link :href="`/projects/${project.id}/templates/create`"> <Link :href="`/projects/${project.id}/templates/create`">
Save as template Save as new template
</Link> </Link>
</Button> </Button>
@@ -2,6 +2,8 @@ import type { ProjectModel } from '@/types/models/project-model';
export type ProjectTemplateModel = { export type ProjectTemplateModel = {
id: number; id: number;
is_published: boolean;
project_id: number | null;
source_project_id: number | null; source_project_id: number | null;
source_project: ProjectModel | null; source_project: ProjectModel | null;
name: string; name: string;
+7
View File
@@ -43,6 +43,7 @@ class ProjectApiTest extends TestCase
$this->withApplicationKey($user) $this->withApplicationKey($user)
->postJson(route('api.projects.store'), [ ->postJson(route('api.projects.store'), [
'creation_mode' => 'scratch',
'key' => 'api', 'key' => 'api',
'name' => 'API Project', 'name' => 'API Project',
]) ])
@@ -52,8 +53,11 @@ class ProjectApiTest extends TestCase
->assertJsonPath('meta.message', 'API Project was created.'); ->assertJsonPath('meta.message', 'API Project was created.');
$project = Project::query()->where('key', 'API')->firstOrFail(); $project = Project::query()->where('key', 'API')->firstOrFail();
$project->refresh();
$this->assertSame(2, $project->ticketTypes()->count()); $this->assertSame(2, $project->ticketTypes()->count());
$this->assertSame(1, $project->ticketStatuses()->count()); $this->assertSame(1, $project->ticketStatuses()->count());
$this->assertNotNull($project->project_template_id);
} }
public function test_users_can_create_projects_from_templates_via_api(): void public function test_users_can_create_projects_from_templates_via_api(): void
@@ -96,6 +100,9 @@ class ProjectApiTest extends TestCase
$project = Project::query()->where('key', 'TPL1')->firstOrFail(); $project = Project::query()->where('key', 'TPL1')->firstOrFail();
$project->refresh();
$this->assertSame($template->id, $project->project_template_id);
$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());
@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature\Database;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ProjectTemplateBaselineMigrationTest extends TestCase
{
public function test_migration_backfills_hidden_base_templates_for_existing_projects(): void
{
Artisan::call('migrate:fresh', [
'--path' => [
'database/migrations/0001_01_01_000000_create_users_table.php',
'database/migrations/0001_01_01_000001_create_cache_table.php',
'database/migrations/0001_01_01_000002_create_jobs_table.php',
'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php',
'database/migrations/2026_03_24_234007_create_projects_table.php',
'database/migrations/2026_03_24_234734_create_teams_table.php',
'database/migrations/2026_03_24_234735_create_team_user_table.php',
'database/migrations/2026_03_25_002100_create_board_columns_table.php',
'database/migrations/2026_03_25_003100_create_ticket_types_table.php',
'database/migrations/2026_03_25_003101_create_ticket_priorities_table.php',
'database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php',
'database/migrations/2026_03_25_004098_create_ticket_statuses_table.php',
'database/migrations/2026_03_25_004099_create_ticket_statuses_transitionstable.php',
'database/migrations/2026_03_25_004100_create_tickets_table.php',
'database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php',
'database/migrations/2026_03_25_070706_create_ticket_field_values_table.php',
'database/migrations/2026_03_25_073715_create_ticket_links_table.php',
'database/migrations/2026_03_25_073715_create_ticket_link_types_table.php',
'database/migrations/2026_03_26_074522_create_user_git_connections_table.php',
'database/migrations/2026_03_26_075849_create_repository_connections_table.php',
'database/migrations/2026_04_12_120000_create_permission_assignments_table.php',
'database/migrations/2026_04_20_174556_create_user_applications_table.php',
'database/migrations/2026_04_20_194646_create_notification_actions_table.php',
'database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php',
'database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php',
'database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php',
'database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php',
'database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php',
'database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php',
'database/migrations/2026_05_25_011500_create_project_templates_table.php',
],
]);
DB::table('projects')->insert([
'name' => 'Legacy Project',
'key' => 'LGCY',
'show_author' => 0,
'show_story_points' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$projectId = (int) DB::table('projects')->where('key', 'LGCY')->value('id');
Artisan::call('migrate', [
'--path' => 'database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php',
]);
$project = DB::table('projects')->where('id', $projectId)->first();
$template = DB::table('project_templates')->where('id', $project->project_template_id)->first();
$this->assertNotNull($project->project_template_id);
$this->assertNotNull($template);
$this->assertSame($projectId, $template->project_id);
$this->assertSame($projectId, $template->source_project_id);
$this->assertSame(0, $template->is_published);
$this->assertSame('lgcy-base-template', $template->slug);
}
}
@@ -121,6 +121,13 @@ class ProjectControllerTest extends TestCase
'slug' => 'reusable-setup', 'slug' => 'reusable-setup',
'source_project_id' => $visibleProject->id, 'source_project_id' => $visibleProject->id,
]); ]);
ProjectTemplate::factory()->create([
'name' => 'Hidden Base',
'slug' => 'hidden-base',
'project_id' => $visibleProject->id,
'source_project_id' => $visibleProject->id,
'is_published' => false,
]);
$this->syncProjectPermissions($visibleProject, $user, ['view']); $this->syncProjectPermissions($visibleProject, $user, ['view']);
$this->actingAs($user) $this->actingAs($user)
@@ -160,6 +167,7 @@ class ProjectControllerTest extends TestCase
$response = $this->actingAs($user) $response = $this->actingAs($user)
->post(route('projects.store'), [ ->post(route('projects.store'), [
'creation_mode' => 'scratch',
'name' => 'Kanban Foundation', 'name' => 'Kanban Foundation',
'key' => 'core', 'key' => 'core',
]); ]);
@@ -173,6 +181,18 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE', 'key' => 'CORE',
]); ]);
$project->refresh();
$this->assertNotNull($project->project_template_id);
$this->assertDatabaseHas('project_templates', [
'id' => $project->project_template_id,
'project_id' => $project->id,
'source_project_id' => $project->id,
'is_published' => false,
'slug' => 'core-base-template',
]);
$this->assertDatabaseHas('permission_assignments', [ $this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $user->id, 'assignee_id' => $user->id,
'assignee_type' => $user->getMorphClass(), 'assignee_type' => $user->getMorphClass(),
@@ -263,8 +283,11 @@ class ProjectControllerTest extends TestCase
$response->assertRedirect(route('projects.show', $project)); $response->assertRedirect(route('projects.show', $project));
$this->assertSame(false, $project->fresh()->show_author); $project->refresh();
$this->assertSame(false, $project->fresh()->show_story_points);
$this->assertSame($template->id, $project->project_template_id);
$this->assertSame(false, $project->show_author);
$this->assertSame(false, $project->show_story_points);
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id, 'project_id' => $project->id,
'name' => 'Bug', 'name' => 'Bug',
@@ -29,6 +29,13 @@ class ProjectTemplateControllerTest extends TestCase
'slug' => 'foundation-template', 'slug' => 'foundation-template',
'source_project_id' => $project->id, 'source_project_id' => $project->id,
]); ]);
ProjectTemplate::factory()->create([
'name' => 'Hidden Base Template',
'slug' => 'hidden-base-template',
'project_id' => $project->id,
'source_project_id' => $project->id,
'is_published' => false,
]);
$this->syncGlobalPermissions($user, ['project:create']); $this->syncGlobalPermissions($user, ['project:create']);
$this->syncProjectPermissions($project, $user, ['view']); $this->syncProjectPermissions($project, $user, ['view']);
@@ -40,7 +47,8 @@ class ProjectTemplateControllerTest extends TestCase
->component('project-templates/Index') ->component('project-templates/Index')
->has('templates', 1) ->has('templates', 1)
->where('templates.0.id', $template->id) ->where('templates.0.id', $template->id)
->where('templates.0.slug', 'foundation-template')); ->where('templates.0.slug', 'foundation-template')
->where('templates.0.is_published', true));
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.templates.create', $project)) ->get(route('projects.templates.create', $project))
@@ -99,6 +107,9 @@ class ProjectTemplateControllerTest extends TestCase
$template = ProjectTemplate::query()->where('slug', 'core-reusable')->firstOrFail(); $template = ProjectTemplate::query()->where('slug', 'core-reusable')->firstOrFail();
$this->assertTrue($template->is_published);
$this->assertNull($template->project_id);
$this->assertSame([ $this->assertSame([
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => false, 'show_author' => false,
@@ -121,4 +132,21 @@ class ProjectTemplateControllerTest extends TestCase
'id' => $template->id, 'id' => $template->id,
]); ]);
} }
public function test_hidden_base_templates_cannot_be_deleted_from_the_management_flow(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$template = ProjectTemplate::factory()->create([
'project_id' => $project->id,
'source_project_id' => $project->id,
'is_published' => false,
]);
$this->syncGlobalPermissions($user, ['project:create']);
$this->actingAs($user)
->delete(route('project-templates.destroy', $template))
->assertNotFound();
}
} }