From 40f9b473fcc86926e7158f310bd3d5a5e48a7055 Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 25 May 2026 01:26:08 +0000 Subject: [PATCH] Add reusable project templates --- app/Actions/Projects/CreateProject.php | 16 +- .../Projects/CreateProjectTemplate.php | 30 ++ .../DuplicateProjectConfiguration.php | 308 +++++++++++++----- app/Http/Controllers/ProjectController.php | 9 + .../Controllers/ProjectTemplateController.php | 60 ++++ app/Http/Requests/StoreProjectRequest.php | 9 +- .../Requests/StoreProjectTemplateRequest.php | 39 +++ .../Resources/ProjectTemplateResource.php | 45 +++ app/Models/ProjectTemplate.php | 29 ++ database/factories/ProjectTemplateFactory.php | 36 ++ ..._011500_create_project_templates_table.php | 34 ++ resources/js/components/AppSidebar.vue | 9 + .../js/pages/project-templates/Create.vue | 142 ++++++++ .../js/pages/project-templates/Index.vue | 113 +++++++ resources/js/pages/projects/Create.vue | 68 +++- resources/js/pages/projects/Show.vue | 10 + .../js/types/models/project-template-model.ts | 12 + resources/js/types/page-props.ts | 13 + routes/web.php | 1 + routes/web/project-templates.php | 12 + routes/web/projects.php | 6 + tests/Feature/Api/ProjectApiTest.php | 46 +++ .../Projects/ProjectControllerTest.php | 71 +++- .../ProjectTemplateControllerTest.php | 124 +++++++ 24 files changed, 1154 insertions(+), 88 deletions(-) create mode 100644 app/Actions/Projects/CreateProjectTemplate.php create mode 100644 app/Http/Controllers/ProjectTemplateController.php create mode 100644 app/Http/Requests/StoreProjectTemplateRequest.php create mode 100644 app/Http/Resources/ProjectTemplateResource.php create mode 100644 app/Models/ProjectTemplate.php create mode 100644 database/factories/ProjectTemplateFactory.php create mode 100644 database/migrations/2026_05_25_011500_create_project_templates_table.php create mode 100644 resources/js/pages/project-templates/Create.vue create mode 100644 resources/js/pages/project-templates/Index.vue create mode 100644 resources/js/types/models/project-template-model.ts create mode 100644 routes/web/project-templates.php create mode 100644 tests/Feature/Projects/ProjectTemplateControllerTest.php diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php index e9a53a8..9d6f480 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -6,6 +6,7 @@ use App\Actions\Permissions\PermissionService; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Enums\Permission; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\User; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -19,7 +20,7 @@ class CreateProject ) {} /** - * @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, copy_areas?: array|null} $attributes + * @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, source_template_id?: int|string|null, copy_areas?: array|null} $attributes */ public function handle(array $attributes, User $actor): Project { @@ -29,7 +30,9 @@ class CreateProject $this->initializeProjectTicketMetadata->handle($project); $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); - if (($attributes['creation_mode'] ?? 'scratch') === 'duplicate' && isset($attributes['source_project_id'])) { + $creationMode = $attributes['creation_mode'] ?? 'scratch'; + + if ($creationMode === 'duplicate' && isset($attributes['source_project_id'])) { $sourceProject = Project::query()->findOrFail((int) $attributes['source_project_id']); $this->duplicateProjectConfiguration->handle( @@ -39,6 +42,15 @@ class CreateProject ); } + if ($creationMode === 'template' && isset($attributes['source_template_id'])) { + $template = ProjectTemplate::query()->findOrFail((int) $attributes['source_template_id']); + + $this->duplicateProjectConfiguration->apply( + $project, + $template->configuration_snapshot ?? [], + ); + } + return $project->fresh() ?? $project; }); } diff --git a/app/Actions/Projects/CreateProjectTemplate.php b/app/Actions/Projects/CreateProjectTemplate.php new file mode 100644 index 0000000..35438db --- /dev/null +++ b/app/Actions/Projects/CreateProjectTemplate.php @@ -0,0 +1,30 @@ +} $attributes + */ + public function handle(Project $sourceProject, array $attributes): ProjectTemplate + { + $copyAreas = array_values(Arr::get($attributes, 'copy_areas', [])); + + return ProjectTemplate::query()->create([ + 'source_project_id' => $sourceProject->id, + 'name' => $attributes['name'], + 'slug' => $attributes['slug'], + 'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas), + 'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas), + ]); + } +} diff --git a/app/Actions/Projects/DuplicateProjectConfiguration.php b/app/Actions/Projects/DuplicateProjectConfiguration.php index 54c0eb1..01e2eed 100644 --- a/app/Actions/Projects/DuplicateProjectConfiguration.php +++ b/app/Actions/Projects/DuplicateProjectConfiguration.php @@ -90,13 +90,23 @@ class DuplicateProjectConfiguration /** * @param list $copyAreas + * @return list */ - public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void + public function normalizeCopyAreas(array $copyAreas): array { - $selectedAreas = array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas))); + return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas))); + } + + /** + * @param list $copyAreas + * @return array + */ + public function export(Project $sourceProject, array $copyAreas): array + { + $selectedAreas = $this->normalizeCopyAreas($copyAreas); if ($selectedAreas === []) { - return; + return []; } $sourceProject->loadMissing([ @@ -110,141 +120,281 @@ class DuplicateProjectConfiguration 'ticketTypes', ]); + $configuration = []; + if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) { - $targetProject->forceFill([ + $configuration[self::COPY_PROJECT_SETTINGS] = [ 'show_author' => $sourceProject->show_author, 'show_story_points' => $sourceProject->show_story_points, - ])->save(); + ]; } if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) { - $targetProject->ticketStatuses()->delete(); - $targetProject->boardColumns()->delete(); + $boardColumnIndexes = []; + $boardColumns = []; - $boardColumnIdMap = []; - - foreach ($sourceProject->boardColumns as $boardColumn) { - $newBoardColumn = $targetProject->boardColumns()->create([ + foreach ($sourceProject->boardColumns as $index => $boardColumn) { + $boardColumnIndexes[$boardColumn->id] = $index; + $boardColumns[] = [ 'name' => $boardColumn->name, 'position' => $boardColumn->position, - ]); - - $boardColumnIdMap[$boardColumn->id] = $newBoardColumn->id; + ]; } - $ticketStatusIdMap = []; + $ticketStatusIndexes = []; + $ticketStatuses = []; - foreach ($sourceProject->ticketStatuses as $ticketStatus) { - $newTicketStatus = $targetProject->ticketStatuses()->create([ - 'board_column_id' => $ticketStatus->board_column_id === null + foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { + $ticketStatusIndexes[$ticketStatus->id] = $index; + $ticketStatuses[] = [ + 'board_column_index' => $ticketStatus->board_column_id === null ? null - : ($boardColumnIdMap[$ticketStatus->board_column_id] ?? null), + : ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null), 'name' => $ticketStatus->name, 'position' => $ticketStatus->position, - ]); - - $ticketStatusIdMap[$ticketStatus->id] = $newTicketStatus->id; + 'allowed_target_indexes' => [], + ]; } - foreach ($sourceProject->ticketStatuses as $ticketStatus) { - $newTicketStatusId = $ticketStatusIdMap[$ticketStatus->id] ?? null; - - if ($newTicketStatusId === null) { - continue; - } - - $targetProject->ticketStatuses() - ->findOrFail($newTicketStatusId) - ->allowedTargetStatuses() - ->sync( - $ticketStatus->allowedTargetStatuses - ->pluck('id') - ->map(fn (int $statusId): ?int => $ticketStatusIdMap[$statusId] ?? null) - ->filter() - ->values() - ->all(), - ); + foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { + $ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses + ->pluck('id') + ->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null) + ->filter(static fn (?int $statusIndex): bool => $statusIndex !== null) + ->values() + ->all(); } + + $configuration[self::COPY_BOARD_WORKFLOW] = [ + 'board_columns' => $boardColumns, + 'ticket_statuses' => $ticketStatuses, + ]; } if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) { - $targetProject->ticketTypes()->delete(); - - foreach ($sourceProject->ticketTypes as $ticketType) { - $targetProject->ticketTypes()->create([ + $configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes + ->map(fn ($ticketType): array => [ 'name' => $ticketType->name, 'position' => $ticketType->position, 'is_parent' => $ticketType->is_parent, - ]); - } + ]) + ->values() + ->all(); } if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) { - $targetProject->ticketPriorities()->delete(); - - foreach ($sourceProject->ticketPriorities as $ticketPriority) { - $targetProject->ticketPriorities()->create([ + $configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities + ->map(fn ($ticketPriority): array => [ 'name' => $ticketPriority->name, 'position' => $ticketPriority->position, - ]); - } + ]) + ->values() + ->all(); } if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) { - $targetProject->ticketFieldDefinitions()->delete(); - - foreach ($sourceProject->ticketFieldDefinitions as $ticketFieldDefinition) { - $targetProject->ticketFieldDefinitions()->create([ + $configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions + ->map(fn ($ticketFieldDefinition): array => [ 'field_type' => $ticketFieldDefinition->field_type, 'label' => $ticketFieldDefinition->label, 'position' => $ticketFieldDefinition->position, - ]); - } + ]) + ->values() + ->all(); } if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) { - $targetProject->ticketLinkTypes()->delete(); - - foreach ($sourceProject->ticketLinkTypes as $ticketLinkType) { - $targetProject->ticketLinkTypes()->create([ + $configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes + ->map(fn ($ticketLinkType): array => [ 'label' => $ticketLinkType->label, 'counterpart_label' => $ticketLinkType->counterpart_label, 'position' => $ticketLinkType->position, - ]); - } + ]) + ->values() + ->all(); } if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) { - $targetProject->repositoryConnections()->delete(); - - foreach ($sourceProject->repositoryConnections as $repositoryConnection) { - $targetProject->repositoryConnections()->create([ - 'provider' => $repositoryConnection->provider, + $configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections + ->map(fn ($repositoryConnection): array => [ + 'provider' => $repositoryConnection->provider->value, 'base_url' => $repositoryConnection->base_url, 'provider_repository_id' => $repositoryConnection->provider_repository_id, 'owner_name' => $repositoryConnection->owner_name, 'repository_name' => $repositoryConnection->repository_name, 'repository_full_name' => $repositoryConnection->repository_full_name, 'repository_url' => $repositoryConnection->repository_url, + ]) + ->values() + ->all(); + } + + if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) { + $configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions + ->map(fn ($notificationAction): array => [ + 'name' => $notificationAction->name, + 'enabled' => $notificationAction->enabled, + 'event_type' => $notificationAction->event_type->value, + 'channel' => $notificationAction->channel->value, + 'channel_configuration' => $notificationAction->channel_configuration ?? [], + 'message_template' => $notificationAction->message_template, + 'delivery_url' => $notificationAction->delivery_url, + 'filters' => $notificationAction->filters ?? [], + ]) + ->values() + ->all(); + } + + return $configuration; + } + + /** + * @param array $configuration + */ + public function apply(Project $targetProject, array $configuration): void + { + if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) { + $projectSettings = $configuration[self::COPY_PROJECT_SETTINGS]; + + $targetProject->forceFill([ + 'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author), + 'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points), + ])->save(); + } + + if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) { + $targetProject->ticketStatuses()->delete(); + $targetProject->boardColumns()->delete(); + + $boardColumnIds = []; + + foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) { + $newBoardColumn = $targetProject->boardColumns()->create([ + 'name' => $boardColumn['name'], + 'position' => $boardColumn['position'], + ]); + + $boardColumnIds[] = $newBoardColumn->id; + } + + $ticketStatusIds = []; + + foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) { + $boardColumnIndex = $ticketStatus['board_column_index'] ?? null; + $newTicketStatus = $targetProject->ticketStatuses()->create([ + 'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null), + 'name' => $ticketStatus['name'], + 'position' => $ticketStatus['position'], + ]); + + $ticketStatusIds[] = $newTicketStatus->id; + } + + foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) { + $newTicketStatusId = $ticketStatusIds[$index] ?? null; + + if ($newTicketStatusId === null) { + continue; + } + + $targetProject->ticketStatuses()->findOrFail($newTicketStatusId) + ->allowedTargetStatuses() + ->sync( + collect($ticketStatus['allowed_target_indexes'] ?? []) + ->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null) + ->filter(static fn (?int $statusId): bool => $statusId !== null) + ->values() + ->all(), + ); + } + } + + if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) { + $targetProject->ticketTypes()->delete(); + + foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) { + $targetProject->ticketTypes()->create([ + 'name' => $ticketType['name'], + 'position' => $ticketType['position'], + 'is_parent' => $ticketType['is_parent'], ]); } } - if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) { + if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) { + $targetProject->ticketPriorities()->delete(); + + foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) { + $targetProject->ticketPriorities()->create([ + 'name' => $ticketPriority['name'], + 'position' => $ticketPriority['position'], + ]); + } + } + + if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) { + $targetProject->ticketFieldDefinitions()->delete(); + + foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) { + $targetProject->ticketFieldDefinitions()->create([ + 'field_type' => $ticketFieldDefinition['field_type'], + 'label' => $ticketFieldDefinition['label'], + 'position' => $ticketFieldDefinition['position'], + ]); + } + } + + if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) { + $targetProject->ticketLinkTypes()->delete(); + + foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) { + $targetProject->ticketLinkTypes()->create([ + 'label' => $ticketLinkType['label'], + 'counterpart_label' => $ticketLinkType['counterpart_label'], + 'position' => $ticketLinkType['position'], + ]); + } + } + + if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) { + $targetProject->repositoryConnections()->delete(); + + foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) { + $targetProject->repositoryConnections()->create([ + 'provider' => $repositoryConnection['provider'], + 'base_url' => $repositoryConnection['base_url'], + 'provider_repository_id' => $repositoryConnection['provider_repository_id'], + 'owner_name' => $repositoryConnection['owner_name'], + 'repository_name' => $repositoryConnection['repository_name'], + 'repository_full_name' => $repositoryConnection['repository_full_name'], + 'repository_url' => $repositoryConnection['repository_url'], + ]); + } + } + + if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) { $targetProject->notificationActions()->delete(); - foreach ($sourceProject->notificationActions as $notificationAction) { + foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) { $targetProject->notificationActions()->create([ - 'name' => $notificationAction->name, - 'enabled' => $notificationAction->enabled, - 'event_type' => $notificationAction->event_type, - 'channel' => $notificationAction->channel, - 'channel_configuration' => $notificationAction->channel_configuration, - 'message_template' => $notificationAction->message_template, - 'delivery_url' => $notificationAction->delivery_url, - 'filters' => $notificationAction->filters, + 'name' => $notificationAction['name'], + 'enabled' => $notificationAction['enabled'], + 'event_type' => $notificationAction['event_type'], + 'channel' => $notificationAction['channel'], + 'channel_configuration' => $notificationAction['channel_configuration'] ?? [], + 'message_template' => $notificationAction['message_template'] ?? null, + 'delivery_url' => $notificationAction['delivery_url'], + 'filters' => $notificationAction['filters'] ?? [], ]); } } } + + /** + * @param list $copyAreas + */ + public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void + { + $this->apply($targetProject, $this->export($sourceProject, $copyAreas)); + } } diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index d5ffb43..76da24c 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -14,6 +14,8 @@ use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\SyncProjectPermissionsRequest; use App\Http\Resources\PermissionOptionResource; use App\Http\Resources\ProjectResource; +use App\Http\Resources\ProjectTemplateResource; +use App\Models\ProjectTemplate; use App\Http\Resources\TeamResource; use App\Http\Resources\UserResource; use App\Models\Project; @@ -52,11 +54,18 @@ class ProjectController extends Controller 403, ); + $sourceTemplates = ProjectTemplate::query()->with('sourceProject')->orderBy('name')->get(); + $selectedTemplateId = $request->integer('template'); + return Inertia::render('projects/Create', [ 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), 'sourceProjects' => ProjectResource::collection( $this->listVisibleProjects->handle($request->user()), )->resolve(), + 'sourceTemplates' => ProjectTemplateResource::collection($sourceTemplates)->resolve(), + 'selectedSourceTemplateId' => $sourceTemplates->contains('id', $selectedTemplateId) + ? $selectedTemplateId + : null, ]); } diff --git a/app/Http/Controllers/ProjectTemplateController.php b/app/Http/Controllers/ProjectTemplateController.php new file mode 100644 index 0000000..4ae1104 --- /dev/null +++ b/app/Http/Controllers/ProjectTemplateController.php @@ -0,0 +1,60 @@ + session('status'), + 'templates' => ProjectTemplateResource::collection( + ProjectTemplate::query() + ->with('sourceProject') + ->orderBy('name') + ->get(), + )->resolve(), + ]); + } + + public function create(Project $project): Response + { + return Inertia::render('project-templates/Create', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'project' => ProjectResource::make($project)->resolve(), + ]); + } + + public function store(StoreProjectTemplateRequest $request, Project $project): RedirectResponse + { + $template = $this->createProjectTemplate->handle($project, $request->validated()); + + return to_route('project-templates.index') + ->with('status', "Template {$template->name} was created."); + } + + public function destroy(ProjectTemplate $projectTemplate): RedirectResponse + { + $templateName = $projectTemplate->name; + $projectTemplate->delete(); + + return to_route('project-templates.index') + ->with('status', "Template {$templateName} was deleted."); + } +} diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php index 8e1da6b..c635e91 100644 --- a/app/Http/Requests/StoreProjectRequest.php +++ b/app/Http/Requests/StoreProjectRequest.php @@ -49,7 +49,7 @@ class StoreProjectRequest extends FormRequest public function rules(): array { return [ - 'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate'])], + 'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])], 'key' => [ 'required', 'string', @@ -81,6 +81,12 @@ class StoreProjectRequest extends FormRequest } }, ], + 'source_template_id' => [ + Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'), + 'nullable', + 'integer', + Rule::exists('project_templates', 'id'), + ], 'copy_areas' => ['nullable', 'array'], 'copy_areas.*' => [ 'string', @@ -96,6 +102,7 @@ class StoreProjectRequest extends FormRequest { return [ 'source_project_id.required' => 'Select a source project to duplicate from.', + 'source_template_id.required' => 'Select a template to create the project from.', ]; } } diff --git a/app/Http/Requests/StoreProjectTemplateRequest.php b/app/Http/Requests/StoreProjectTemplateRequest.php new file mode 100644 index 0000000..a210f53 --- /dev/null +++ b/app/Http/Requests/StoreProjectTemplateRequest.php @@ -0,0 +1,39 @@ +user()?->can('create-project') ?? false; + } + + protected function prepareForValidation(): void + { + if ($this->filled('slug')) { + $this->merge([ + 'slug' => Str::slug((string) $this->input('slug')), + ]); + } + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')], + 'copy_areas' => ['required', 'array', 'min:1'], + 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())], + ]; + } +} diff --git a/app/Http/Resources/ProjectTemplateResource.php b/app/Http/Resources/ProjectTemplateResource.php new file mode 100644 index 0000000..f70102a --- /dev/null +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -0,0 +1,45 @@ + + */ + public function toArray(Request $request): array + { + if ($this->resource instanceof ProjectTemplate) { + return [ + 'id' => $this->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, + 'copy_areas' => $this->copy_areas ?? [], + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } + + return [ + 'id' => Arr::get($this->resource, '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'), + '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/ProjectTemplate.php b/app/Models/ProjectTemplate.php new file mode 100644 index 0000000..ec1ebaf --- /dev/null +++ b/app/Models/ProjectTemplate.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'copy_areas' => 'array', + 'configuration_snapshot' => 'array', + ]; + } + + public function sourceProject(): BelongsTo + { + return $this->belongsTo(Project::class, 'source_project_id'); + } +} diff --git a/database/factories/ProjectTemplateFactory.php b/database/factories/ProjectTemplateFactory.php new file mode 100644 index 0000000..2751f96 --- /dev/null +++ b/database/factories/ProjectTemplateFactory.php @@ -0,0 +1,36 @@ + + */ +class ProjectTemplateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'source_project_id' => Project::factory(), + 'name' => Str::title(fake()->unique()->words(3, true)), + 'slug' => fake()->unique()->slug(), + 'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], + 'configuration_snapshot' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => true, + 'show_story_points' => true, + ], + ], + ]; + } +} diff --git a/database/migrations/2026_05_25_011500_create_project_templates_table.php b/database/migrations/2026_05_25_011500_create_project_templates_table.php new file mode 100644 index 0000000..3147e8a --- /dev/null +++ b/database/migrations/2026_05_25_011500_create_project_templates_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('source_project_id')->nullable()->constrained('projects')->nullOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->json('copy_areas'); + $table->json('configuration_snapshot'); + $table->timestamps(); + + $table->index(['source_project_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('project_templates'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index a612285..f15b3c7 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -5,6 +5,7 @@ import { Folder, FolderGit2, LayoutGrid, + Layers3, Shield, Users, } from 'lucide-vue-next'; @@ -44,6 +45,14 @@ const mainNavItems = computed(() => { }, ]; + if (page.props.auth.permissions.projectCreate) { + items.push({ + title: 'Project templates', + href: '/project-templates', + icon: Layers3, + }); + } + if (page.props.auth.permissions.globalAdmin) { items.push({ title: 'Users', diff --git a/resources/js/pages/project-templates/Create.vue b/resources/js/pages/project-templates/Create.vue new file mode 100644 index 0000000..b569cca --- /dev/null +++ b/resources/js/pages/project-templates/Create.vue @@ -0,0 +1,142 @@ + + + diff --git a/resources/js/pages/project-templates/Index.vue b/resources/js/pages/project-templates/Index.vue new file mode 100644 index 0000000..fea7b48 --- /dev/null +++ b/resources/js/pages/project-templates/Index.vue @@ -0,0 +1,113 @@ + + + diff --git a/resources/js/pages/projects/Create.vue b/resources/js/pages/projects/Create.vue index 926222e..67985d2 100644 --- a/resources/js/pages/projects/Create.vue +++ b/resources/js/pages/projects/Create.vue @@ -31,15 +31,19 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; +const defaultTemplateId = props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null; + const form = useForm({ copy_areas: [] as string[], - creation_mode: 'scratch', + creation_mode: defaultTemplateId !== null ? 'template' : 'scratch', key: '', name: '', source_project_id: props.sourceProjects[0]?.id ? String(props.sourceProjects[0].id) : '', + source_template_id: defaultTemplateId !== null ? String(defaultTemplateId) : '', }); const hasSourceProjects = computed(() => props.sourceProjects.length > 0); +const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0); const copyAreasError = computed(() => { return ( @@ -55,6 +59,10 @@ const submit = (): void => { form.copy_areas = []; } + if (form.creation_mode !== 'template') { + form.source_template_id = ''; + } + form.post(ProjectController.store(), { preserveScroll: true, }); @@ -68,7 +76,7 @@ const submit = (): void => {
@@ -84,7 +92,7 @@ const submit = (): void => {
-
+
+ +
@@ -213,8 +240,39 @@ const submit = (): void => {
-
- No accessible source projects are available for duplication yet. +
+
+ + +

+ Templates apply their saved configuration groups to the new project. +

+ +
+
+ +
+ No accessible source projects or saved templates are available yet.
diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index 46d86bd..b55acdc 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -94,6 +94,16 @@ const projectAreas = [ + + diff --git a/resources/js/types/models/project-template-model.ts b/resources/js/types/models/project-template-model.ts new file mode 100644 index 0000000..ebb915d --- /dev/null +++ b/resources/js/types/models/project-template-model.ts @@ -0,0 +1,12 @@ +import type { ProjectModel } from '@/types/models/project-model'; + +export type ProjectTemplateModel = { + id: number; + source_project_id: number | null; + source_project: ProjectModel | null; + name: string; + slug: string; + copy_areas: string[]; + created_at: string | null; + updated_at: string | null; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index edadc15..9dc80aa 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -4,6 +4,7 @@ import type { NotificationActionModel } from '@/types/models/notification-action import type { NotificationActionOptionModel } from '@/types/models/notification-action-option-model'; import type { NotificationActionTemplateVariableModel } from '@/types/models/notification-action-template-variable-model'; import type { ProjectModel } from '@/types/models/project-model'; +import type { ProjectTemplateModel } from '@/types/models/project-template-model'; import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model'; import type { TeamModel } from '@/types/models/team-model'; import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model'; @@ -45,7 +46,19 @@ export type ProjectsIndexPageProps = { export type ProjectsCreatePageProps = { copyAreaOptions: ProjectDuplicateCopyAreaOption[]; + selectedSourceTemplateId?: number | null; sourceProjects: ProjectModel[]; + sourceTemplates: ProjectTemplateModel[]; +}; + +export type ProjectTemplatesIndexPageProps = { + status?: string | null; + templates: ProjectTemplateModel[]; +}; + +export type ProjectTemplatesCreatePageProps = { + copyAreaOptions: ProjectDuplicateCopyAreaOption[]; + project: ProjectModel; }; export type ProjectShowPageProps = { diff --git a/routes/web.php b/routes/web.php index 430c0e4..7e87613 100644 --- a/routes/web.php +++ b/routes/web.php @@ -20,6 +20,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::inertia('dashboard', 'Dashboard')->name('dashboard'); require __DIR__.'/web/admin.php'; + require __DIR__.'/web/project-templates.php'; require __DIR__.'/web/projects.php'; require __DIR__.'/web/teams.php'; }); diff --git a/routes/web/project-templates.php b/routes/web/project-templates.php new file mode 100644 index 0000000..6c9983a --- /dev/null +++ b/routes/web/project-templates.php @@ -0,0 +1,12 @@ +prefix('project-templates') + ->name('project-templates.') + ->group(function (): void { + Route::get('/', [ProjectTemplateController::class, 'index'])->name('index'); + Route::delete('{projectTemplate}', [ProjectTemplateController::class, 'destroy'])->name('destroy'); + }); diff --git a/routes/web/projects.php b/routes/web/projects.php index 3330cbe..60a5553 100644 --- a/routes/web/projects.php +++ b/routes/web/projects.php @@ -2,6 +2,7 @@ use App\Http\Controllers\ProjectBoardController; use App\Http\Controllers\ProjectController; +use App\Http\Controllers\ProjectTemplateController; use Illuminate\Support\Facades\Route; Route::prefix('projects')->name('projects.')->group(function (): void { @@ -18,6 +19,11 @@ Route::prefix('projects')->name('projects.')->group(function (): void { Route::get('/', [ProjectController::class, 'show'])->name('show'); Route::get('board', [ProjectBoardController::class, 'index'])->name('board.index'); Route::get('backlog', [ProjectController::class, 'backlog'])->name('backlog.index'); + + Route::middleware('can:create-project')->group(function (): void { + Route::get('templates/create', [ProjectTemplateController::class, 'create'])->name('templates.create'); + Route::post('templates', [ProjectTemplateController::class, 'store'])->name('templates.store'); + }); }); Route::delete('/', [ProjectController::class, 'destroy']) diff --git a/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php index dc3be22..7d3188a 100644 --- a/tests/Feature/Api/ProjectApiTest.php +++ b/tests/Feature/Api/ProjectApiTest.php @@ -6,6 +6,7 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\Projects\DuplicateProjectConfiguration; use App\Models\BoardColumn; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\TicketStatus; use App\Models\TicketType; use App\Models\User; @@ -55,6 +56,51 @@ class ProjectApiTest extends TestCase $this->assertSame(1, $project->ticketStatuses()->count()); } + public function test_users_can_create_projects_from_templates_via_api(): void + { + $user = User::factory()->create(); + $this->syncGlobalPermissions($user, ['create']); + + $template = ProjectTemplate::factory()->create([ + 'name' => 'API Template', + 'slug' => 'api-template', + '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' => 'Spike', + 'position' => 1, + 'is_parent' => false, + ], + ], + ], + ]); + + $this->withApplicationKey($user) + ->postJson(route('api.projects.store'), [ + 'key' => 'tpl1', + 'name' => 'Template API Project', + 'creation_mode' => 'template', + 'source_template_id' => $template->id, + ]) + ->assertCreated() + ->assertJsonPath('data.key', 'TPL1') + ->assertJsonPath('data.name', 'Template API Project'); + + $project = Project::query()->where('key', 'TPL1')->firstOrFail(); + + $this->assertFalse($project->show_author); + $this->assertFalse($project->show_story_points); + $this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all()); + } + public function test_users_can_duplicate_projects_via_api(): void { $user = User::factory()->create(); diff --git a/tests/Feature/Projects/ProjectControllerTest.php b/tests/Feature/Projects/ProjectControllerTest.php index 8bfe157..d367fc5 100644 --- a/tests/Feature/Projects/ProjectControllerTest.php +++ b/tests/Feature/Projects/ProjectControllerTest.php @@ -7,6 +7,7 @@ use App\Actions\Projects\DuplicateProjectConfiguration; use App\Models\BoardColumn; use App\Models\NotificationAction; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\RepositoryConnection; use App\Models\Team; use App\Models\Ticket; @@ -115,16 +116,24 @@ class ProjectControllerTest extends TestCase 'name' => 'Hidden Source', 'key' => 'SRC2', ]); + $template = ProjectTemplate::factory()->create([ + 'name' => 'Reusable Setup', + 'slug' => 'reusable-setup', + 'source_project_id' => $visibleProject->id, + ]); $this->syncProjectPermissions($visibleProject, $user, ['view']); $this->actingAs($user) - ->get(route('projects.create')) + ->get(route('projects.create', ['template' => $template->id])) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Create') ->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas())) ->has('sourceProjects', 1) + ->has('sourceTemplates', 1) ->where('sourceProjects.0.id', $visibleProject->id) + ->where('sourceTemplates.0.id', $template->id) + ->where('selectedSourceTemplateId', $template->id) ->where('copyAreaOptions.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS), ); } @@ -207,6 +216,66 @@ class ProjectControllerTest extends TestCase ->assertRedirect(route('projects.create')); } + public function test_authenticated_users_can_create_projects_from_saved_templates(): void + { + $user = User::factory()->create(); + $this->syncGlobalPermissions($user, ['project:create']); + + $sourceProject = Project::factory()->create([ + 'key' => 'SRC1', + 'name' => 'Source Project', + 'show_author' => false, + 'show_story_points' => false, + ]); + + $template = ProjectTemplate::factory()->create([ + 'source_project_id' => $sourceProject->id, + 'name' => 'Kanban Template', + 'slug' => 'kanban-template', + '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, + ], + ], + ], + ]); + + $response = $this->actingAs($user) + ->post(route('projects.store'), [ + 'creation_mode' => 'template', + 'name' => 'Template Based Project', + 'key' => 'TMP1', + 'source_template_id' => $template->id, + ]); + + $project = Project::query()->where('key', 'TMP1')->firstOrFail(); + + $response->assertRedirect(route('projects.show', $project)); + + $this->assertSame(false, $project->fresh()->show_author); + $this->assertSame(false, $project->fresh()->show_story_points); + $this->assertDatabaseHas('ticket_types', [ + 'project_id' => $project->id, + 'name' => 'Bug', + 'position' => 1, + 'is_parent' => false, + ]); + $this->assertDatabaseMissing('tickets', [ + 'project_id' => $project->id, + ]); + } + public function test_authenticated_users_can_duplicate_projects_with_selected_settings_only(): void { $user = User::factory()->create(); diff --git a/tests/Feature/Projects/ProjectTemplateControllerTest.php b/tests/Feature/Projects/ProjectTemplateControllerTest.php new file mode 100644 index 0000000..51829d5 --- /dev/null +++ b/tests/Feature/Projects/ProjectTemplateControllerTest.php @@ -0,0 +1,124 @@ +create(); + $project = Project::factory()->create([ + 'name' => 'Core Platform', + 'key' => 'CORE', + ]); + $template = ProjectTemplate::factory()->create([ + 'name' => 'Foundation Template', + 'slug' => 'foundation-template', + 'source_project_id' => $project->id, + ]); + + $this->syncGlobalPermissions($user, ['project:create']); + $this->syncProjectPermissions($project, $user, ['view']); + + $this->actingAs($user) + ->get('/project-templates') + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('project-templates/Index') + ->has('templates', 1) + ->where('templates.0.id', $template->id) + ->where('templates.0.slug', 'foundation-template')); + + $this->actingAs($user) + ->get(route('projects.templates.create', $project)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('project-templates/Create') + ->where('project.id', $project->id) + ->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas()))); + } + + public function test_users_can_save_and_delete_templates_from_projects(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'name' => 'Source Project', + 'key' => 'SRC1', + 'show_author' => false, + 'show_story_points' => false, + ]); + + $this->syncGlobalPermissions($user, ['project:create']); + $this->syncProjectPermissions($project, $user, ['view']); + + $project->ticketTypes()->delete(); + $project->ticketStatuses()->delete(); + $project->boardColumns()->delete(); + + $boardColumn = BoardColumn::factory()->create([ + 'project_id' => $project->id, + 'name' => 'Doing', + 'position' => 1, + ]); + $ticketStatus = TicketStatus::factory()->create([ + 'project_id' => $project->id, + 'board_column_id' => $boardColumn->id, + 'name' => 'Doing', + 'position' => 1, + ]); + $ticketStatus->allowedTargetStatuses()->sync([$ticketStatus->id]); + TicketType::factory()->create([ + 'project_id' => $project->id, + 'name' => 'Bug', + 'position' => 1, + ]); + + $this->actingAs($user) + ->post(route('projects.templates.store', $project), [ + 'name' => 'Core Reusable', + 'slug' => 'core-reusable', + 'copy_areas' => [ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, + DuplicateProjectConfiguration::COPY_TICKET_TYPES, + ], + ]) + ->assertRedirect('/project-templates'); + + $template = ProjectTemplate::query()->where('slug', 'core-reusable')->firstOrFail(); + + $this->assertSame([ + DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ + 'show_author' => false, + 'show_story_points' => false, + ], + DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ + [ + 'name' => 'Bug', + 'position' => 1, + 'is_parent' => false, + ], + ], + ], $template->configuration_snapshot); + + $this->actingAs($user) + ->delete(route('project-templates.destroy', $template)) + ->assertRedirect('/project-templates'); + + $this->assertDatabaseMissing('project_templates', [ + 'id' => $template->id, + ]); + } +}