From 5e6426067a378fb3a9dda793f5d89bbca19fcdd0 Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 25 May 2026 01:41:27 +0000 Subject: [PATCH] Make projects depend on templates --- app/Actions/Projects/CreateProject.php | 13 ++++ .../Projects/CreateProjectBaseTemplate.php | 28 +++++++ .../Projects/CreateProjectTemplate.php | 2 + app/Http/Controllers/ProjectController.php | 6 +- .../Controllers/ProjectTemplateController.php | 3 + app/Http/Requests/StoreProjectRequest.php | 2 +- app/Http/Resources/ProjectResource.php | 1 + .../Resources/ProjectTemplateResource.php | 4 + app/Models/Project.php | 15 +++- app/Models/ProjectTemplate.php | 14 +++- ...project_templates_as_project_baselines.php | 54 ++++++++++++++ .../js/pages/project-templates/Create.vue | 8 +- .../js/pages/project-templates/Index.vue | 4 +- resources/js/pages/projects/Show.vue | 2 +- .../js/types/models/project-template-model.ts | 2 + tests/Feature/Api/ProjectApiTest.php | 7 ++ .../ProjectTemplateBaselineMigrationTest.php | 73 +++++++++++++++++++ .../Projects/ProjectControllerTest.php | 27 ++++++- .../ProjectTemplateControllerTest.php | 30 +++++++- 19 files changed, 281 insertions(+), 14 deletions(-) create mode 100644 app/Actions/Projects/CreateProjectBaseTemplate.php create mode 100644 database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php create mode 100644 tests/Feature/Database/ProjectTemplateBaselineMigrationTest.php diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php index 9d6f480..98b278f 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB; class CreateProject { public function __construct( + private readonly CreateProjectBaseTemplate $createProjectBaseTemplate, private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly PermissionService $permissionService, @@ -49,6 +50,18 @@ class CreateProject $project, $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; diff --git a/app/Actions/Projects/CreateProjectBaseTemplate.php b/app/Actions/Projects/CreateProjectBaseTemplate.php new file mode 100644 index 0000000..29623e3 --- /dev/null +++ b/app/Actions/Projects/CreateProjectBaseTemplate.php @@ -0,0 +1,28 @@ +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), + ]); + } +} diff --git a/app/Actions/Projects/CreateProjectTemplate.php b/app/Actions/Projects/CreateProjectTemplate.php index 35438db..463267e 100644 --- a/app/Actions/Projects/CreateProjectTemplate.php +++ b/app/Actions/Projects/CreateProjectTemplate.php @@ -20,9 +20,11 @@ class CreateProjectTemplate $copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); return ProjectTemplate::query()->create([ + 'project_id' => null, 'source_project_id' => $sourceProject->id, 'name' => $attributes['name'], 'slug' => $attributes['slug'], + 'is_published' => true, 'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas), 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas), ]); diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 76da24c..1f8ccb3 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -54,7 +54,11 @@ class ProjectController extends Controller 403, ); - $sourceTemplates = ProjectTemplate::query()->with('sourceProject')->orderBy('name')->get(); + $sourceTemplates = ProjectTemplate::query() + ->published() + ->with('sourceProject') + ->orderBy('name') + ->get(); $selectedTemplateId = $request->integer('template'); return Inertia::render('projects/Create', [ diff --git a/app/Http/Controllers/ProjectTemplateController.php b/app/Http/Controllers/ProjectTemplateController.php index 4ae1104..d47908f 100644 --- a/app/Http/Controllers/ProjectTemplateController.php +++ b/app/Http/Controllers/ProjectTemplateController.php @@ -26,6 +26,7 @@ class ProjectTemplateController extends Controller 'status' => session('status'), 'templates' => ProjectTemplateResource::collection( ProjectTemplate::query() + ->published() ->with('sourceProject') ->orderBy('name') ->get(), @@ -51,6 +52,8 @@ class ProjectTemplateController extends Controller public function destroy(ProjectTemplate $projectTemplate): RedirectResponse { + abort_if(! $projectTemplate->is_published, 404); + $templateName = $projectTemplate->name; $projectTemplate->delete(); diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php index c635e91..2e28543 100644 --- a/app/Http/Requests/StoreProjectRequest.php +++ b/app/Http/Requests/StoreProjectRequest.php @@ -85,7 +85,7 @@ class StoreProjectRequest extends FormRequest Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'), 'nullable', '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.*' => [ diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index bc34640..a30fba5 100644 --- a/app/Http/Resources/ProjectResource.php +++ b/app/Http/Resources/ProjectResource.php @@ -18,6 +18,7 @@ class ProjectResource extends JsonResource 'id' => $this->id, 'name' => $this->name, 'key' => $this->key, + 'project_template_id' => $this->project_template_id, 'show_author' => $this->show_author, 'show_story_points' => $this->show_story_points, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Http/Resources/ProjectTemplateResource.php b/app/Http/Resources/ProjectTemplateResource.php index f70102a..bf49dc9 100644 --- a/app/Http/Resources/ProjectTemplateResource.php +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -19,12 +19,14 @@ class ProjectTemplateResource extends JsonResource if ($this->resource instanceof ProjectTemplate) { return [ 'id' => $this->id, + 'project_id' => $this->project_id, 'source_project_id' => $this->source_project_id, 'source_project' => $this->relationLoaded('sourceProject') && $this->sourceProject !== null ? ProjectResource::make($this->sourceProject)->resolve() : null, 'name' => $this->name, 'slug' => $this->slug, + 'is_published' => $this->is_published, 'copy_areas' => $this->copy_areas ?? [], 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), @@ -33,10 +35,12 @@ class ProjectTemplateResource extends JsonResource return [ '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' => Arr::get($this->resource, 'source_project'), 'name' => Arr::get($this->resource, 'name'), 'slug' => Arr::get($this->resource, 'slug'), + 'is_published' => Arr::get($this->resource, 'is_published', true), 'copy_areas' => Arr::get($this->resource, 'copy_areas', []), 'created_at' => Arr::get($this->resource, 'created_at'), 'updated_at' => Arr::get($this->resource, 'updated_at'), diff --git a/app/Models/Project.php b/app/Models/Project.php index 51cd5f5..ec907cb 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; 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 { /** @use HasFactory */ @@ -44,6 +46,17 @@ class Project extends Model 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 { return $this->hasMany(BoardColumn::class) diff --git a/app/Models/ProjectTemplate.php b/app/Models/ProjectTemplate.php index ec1ebaf..272e636 100644 --- a/app/Models/ProjectTemplate.php +++ b/app/Models/ProjectTemplate.php @@ -7,8 +7,9 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; 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 { /** @use HasFactory */ @@ -19,9 +20,20 @@ class ProjectTemplate extends Model return [ 'copy_areas' => '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 { return $this->belongsTo(Project::class, 'source_project_id'); diff --git a/database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php b/database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php new file mode 100644 index 0000000..c0c5489 --- /dev/null +++ b/database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php @@ -0,0 +1,54 @@ +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'); + }); + } +}; diff --git a/resources/js/pages/project-templates/Create.vue b/resources/js/pages/project-templates/Create.vue index b569cca..c5d50ca 100644 --- a/resources/js/pages/project-templates/Create.vue +++ b/resources/js/pages/project-templates/Create.vue @@ -30,7 +30,7 @@ const breadcrumbs: BreadcrumbItem[] = [ href: projectShow(props.project.id), }, { - title: 'Save as template', + title: 'Save as new template', href: `/projects/${props.project.id}/templates/create`, }, ]; @@ -58,13 +58,13 @@ const submit = (): void => {