From 39a039cd1e2e4389f33d62afee3a0bb11adbabd3 Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 25 May 2026 11:12:55 +0000 Subject: [PATCH] Add editable project template flow --- .../Projects/UpdateProjectTemplate.php | 66 +++++++ .../Controllers/ProjectTemplateController.php | 26 +++ .../Requests/UpdateProjectTemplateRequest.php | 47 +++++ .../Resources/ProjectTemplateResource.php | 2 + app/Models/ProjectTemplate.php | 8 +- resources/js/pages/project-templates/Edit.vue | 171 ++++++++++++++++++ .../js/pages/project-templates/Index.vue | 10 + .../js/types/models/project-template-model.ts | 1 + resources/js/types/page-props.ts | 5 + routes/web/project-templates.php | 2 + .../ProjectTemplateControllerTest.php | 101 +++++++++++ 11 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 app/Actions/Projects/UpdateProjectTemplate.php create mode 100644 app/Http/Requests/UpdateProjectTemplateRequest.php create mode 100644 resources/js/pages/project-templates/Edit.vue diff --git a/app/Actions/Projects/UpdateProjectTemplate.php b/app/Actions/Projects/UpdateProjectTemplate.php new file mode 100644 index 0000000..f0d5528 --- /dev/null +++ b/app/Actions/Projects/UpdateProjectTemplate.php @@ -0,0 +1,66 @@ +} $attributes + */ + public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate + { + $projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate'); + + $sourceProject = $projectTemplate->sourceProject; + abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.'); + + $copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); + $normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas); + $previousConfiguration = $projectTemplate->configuration_snapshot ?? []; + $nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas); + $affectedAreas = array_values(array_unique([ + ...array_keys($previousConfiguration), + ...array_keys($nextConfiguration), + ])); + + DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void { + $projectTemplate->forceFill([ + 'name' => $attributes['name'], + 'copy_areas' => $normalizedCopyAreas, + 'configuration_snapshot' => $nextConfiguration, + ])->save(); + + foreach ($projectTemplate->linkedProjects as $linkedProject) { + $currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject->fresh(), $affectedAreas); + $replacementConfiguration = []; + + foreach ($nextConfiguration as $area => $configuration) { + $isNewTemplateArea = ! array_key_exists($area, $previousConfiguration); + $matchesPreviousTemplate = array_key_exists($area, $previousConfiguration) + && array_key_exists($area, $currentConfiguration) + && $currentConfiguration[$area] === $previousConfiguration[$area]; + + if ($isNewTemplateArea || $matchesPreviousTemplate) { + $replacementConfiguration[$area] = $configuration; + } + } + + if ($replacementConfiguration === []) { + continue; + } + + $this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration); + } + }); + + return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects'); + } +} diff --git a/app/Http/Controllers/ProjectTemplateController.php b/app/Http/Controllers/ProjectTemplateController.php index d47908f..e9c1b6a 100644 --- a/app/Http/Controllers/ProjectTemplateController.php +++ b/app/Http/Controllers/ProjectTemplateController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers; use App\Actions\Projects\CreateProjectTemplate; use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Actions\Projects\UpdateProjectTemplate; use App\Http\Requests\StoreProjectTemplateRequest; +use App\Http\Requests\UpdateProjectTemplateRequest; use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectTemplateResource; use App\Models\Project; @@ -18,6 +20,7 @@ class ProjectTemplateController extends Controller { public function __construct( private readonly CreateProjectTemplate $createProjectTemplate, + private readonly UpdateProjectTemplate $updateProjectTemplate, ) {} public function index(Request $request): Response @@ -28,12 +31,25 @@ class ProjectTemplateController extends Controller ProjectTemplate::query() ->published() ->with('sourceProject') + ->withCount('linkedProjects') ->orderBy('name') ->get(), )->resolve(), ]); } + public function edit(ProjectTemplate $projectTemplate): Response + { + abort_if(! $projectTemplate->is_published, 404); + + $projectTemplate->load('sourceProject')->loadCount('linkedProjects'); + + return Inertia::render('project-templates/Edit', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'template' => ProjectTemplateResource::make($projectTemplate)->resolve(), + ]); + } + public function create(Project $project): Response { return Inertia::render('project-templates/Create', [ @@ -50,6 +66,16 @@ class ProjectTemplateController extends Controller ->with('status', "Template {$template->name} was created."); } + public function update(UpdateProjectTemplateRequest $request, ProjectTemplate $projectTemplate): RedirectResponse + { + abort_if(! $projectTemplate->is_published, 404); + + $template = $this->updateProjectTemplate->handle($projectTemplate, $request->validated()); + + return to_route('project-templates.index') + ->with('status', "Template {$template->name} was updated."); + } + public function destroy(ProjectTemplate $projectTemplate): RedirectResponse { abort_if(! $projectTemplate->is_published, 404); diff --git a/app/Http/Requests/UpdateProjectTemplateRequest.php b/app/Http/Requests/UpdateProjectTemplateRequest.php new file mode 100644 index 0000000..85bfdfe --- /dev/null +++ b/app/Http/Requests/UpdateProjectTemplateRequest.php @@ -0,0 +1,47 @@ +user()?->can('create-project') ?? false; + } + + /** + * @return array|string> + */ + public function rules(): array + { + /** @var ProjectTemplate|null $projectTemplate */ + $projectTemplate = $this->route('projectTemplate'); + $requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'copy_areas' => ['required', 'array', 'min:1'], + 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())], + 'confirm_linked_projects_update' => [ + Rule::requiredIf($requiresImpactConfirmation), + 'accepted', + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'confirm_linked_projects_update.accepted' => 'Acknowledge the linked project warning before saving template changes.', + ]; + } +} diff --git a/app/Http/Resources/ProjectTemplateResource.php b/app/Http/Resources/ProjectTemplateResource.php index 3bcdcc9..cdfbc4f 100644 --- a/app/Http/Resources/ProjectTemplateResource.php +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -29,6 +29,7 @@ class ProjectTemplateResource extends JsonResource 'is_published' => $this->is_published, 'copy_areas' => $this->copy_areas ?? [], 'configuration_snapshot' => $this->configuration_snapshot ?? [], + 'linked_project_count' => $this->whenCounted('linkedProjects'), 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; @@ -44,6 +45,7 @@ class ProjectTemplateResource extends JsonResource 'is_published' => Arr::get($this->resource, 'is_published', true), 'copy_areas' => Arr::get($this->resource, 'copy_areas', []), 'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []), + 'linked_project_count' => Arr::get($this->resource, 'linked_project_count'), 'created_at' => Arr::get($this->resource, 'created_at'), 'updated_at' => Arr::get($this->resource, 'updated_at'), ]; diff --git a/app/Models/ProjectTemplate.php b/app/Models/ProjectTemplate.php index 272e636..726e775 100644 --- a/app/Models/ProjectTemplate.php +++ b/app/Models/ProjectTemplate.php @@ -4,10 +4,11 @@ namespace App\Models; use Database\Factories\ProjectTemplateFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable(['project_id', 'source_project_id', 'name', 'slug', 'is_published', 'copy_areas', 'configuration_snapshot'])] class ProjectTemplate extends Model @@ -38,4 +39,9 @@ class ProjectTemplate extends Model { return $this->belongsTo(Project::class, 'source_project_id'); } + + public function linkedProjects(): HasMany + { + return $this->hasMany(Project::class, 'project_template_id'); + } } diff --git a/resources/js/pages/project-templates/Edit.vue b/resources/js/pages/project-templates/Edit.vue new file mode 100644 index 0000000..038757b --- /dev/null +++ b/resources/js/pages/project-templates/Edit.vue @@ -0,0 +1,171 @@ + + + diff --git a/resources/js/pages/project-templates/Index.vue b/resources/js/pages/project-templates/Index.vue index dcba323..5b42fb5 100644 --- a/resources/js/pages/project-templates/Index.vue +++ b/resources/js/pages/project-templates/Index.vue @@ -11,6 +11,7 @@ import { CardTitle, } from '@/components/ui/card'; import AppLayout from '@/layouts/AppLayout.vue'; +import { edit as projectTemplatesEdit } from '@/routes/project-templates'; import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index'; import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types'; @@ -69,6 +70,9 @@ const removeTemplate = (templateId: number): void => {
{{ template.name }} {{ template.slug }} + + {{ template.linked_project_count }} linked {{ template.linked_project_count === 1 ? 'project' : 'projects' }} +