Add template switch and reset flows for existing projects

This commit is contained in:
2026-09-08 10:25:52 +00:00
committed by Twyco
parent b8a1d65122
commit 49e887753c
10 changed files with 522 additions and 2 deletions
@@ -0,0 +1,44 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class UpdateProjectTemplateLink
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function switch(Project $project, ProjectTemplate $template): void
{
DB::transaction(function () use ($project, $template): void {
$project->forceFill([
'project_template_id' => $template->id,
])->save();
$this->duplicateProjectConfiguration->apply(
$project->fresh(),
$template->configuration_snapshot ?? [],
);
});
}
public function reset(Project $project): void
{
$template = $project->projectTemplate;
if ($template === null) {
return;
}
DB::transaction(function () use ($project, $template): void {
$this->duplicateProjectConfiguration->apply(
$project->fresh(),
$template->configuration_snapshot ?? [],
);
});
}
}
+39 -1
View File
@@ -9,17 +9,20 @@ use App\Actions\Projects\DeleteProject;
use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ListVisibleProjects; use App\Actions\Projects\ListVisibleProjects;
use App\Actions\Projects\ResolveProjectTemplateState; use App\Actions\Projects\ResolveProjectTemplateState;
use App\Actions\Projects\UpdateProjectTemplateLink;
use App\Enums\Permission; use App\Enums\Permission;
use App\Http\Requests\DeleteProjectRequest; use App\Http\Requests\DeleteProjectRequest;
use App\Http\Requests\ResetProjectTemplateRequest;
use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\SwitchProjectTemplateRequest;
use App\Http\Requests\SyncProjectPermissionsRequest; use App\Http\Requests\SyncProjectPermissionsRequest;
use App\Http\Resources\PermissionOptionResource; use App\Http\Resources\PermissionOptionResource;
use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource; use App\Http\Resources\ProjectTemplateResource;
use App\Models\ProjectTemplate;
use App\Http\Resources\TeamResource; use App\Http\Resources\TeamResource;
use App\Http\Resources\UserResource; use App\Http\Resources\UserResource;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -37,6 +40,7 @@ class ProjectController extends Controller
private readonly ListVisibleProjects $listVisibleProjects, private readonly ListVisibleProjects $listVisibleProjects,
private readonly PermissionService $permissionService, private readonly PermissionService $permissionService,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState, private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
) {} ) {}
public function index(Request $request): Response public function index(Request $request): Response
@@ -117,10 +121,44 @@ class ProjectController extends Controller
{ {
return Inertia::render('projects/settings/General', [ return Inertia::render('projects/settings/General', [
'project' => ProjectResource::make($project)->resolve(), 'project' => ProjectResource::make($project)->resolve(),
'sourceTemplates' => ProjectTemplateResource::collection(
ProjectTemplate::query()
->published()
->with('sourceProject')
->orderBy('name')
->get(),
)->resolve(),
'status' => session('status'), 'status' => session('status'),
'templateState' => $this->resolveProjectTemplateState->handle($project),
]); ]);
} }
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
{
$template = ProjectTemplate::query()
->published()
->findOrFail($request->integer('source_template_id'));
$this->updateProjectTemplateLink->switch($project, $template);
return to_route('projects.settings.general', $project)
->with('status', "{$project->name} now uses the {$template->name} template.");
}
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
{
$request->validated();
$template = $project->projectTemplate;
abort_if($template === null, 422, 'This project has no linked template to reset.');
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
return to_route('projects.settings.general', $project)
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
}
public function access(Request $request, Project $project): Response public function access(Request $request, Project $project): Response
{ {
$user = $request->user(); $user = $request->user();
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ResetProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
return [
'confirmation_key' => [
'required',
'string',
Rule::in([$project->key]),
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.',
];
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SwitchProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
return [
'confirmation_key' => [
'required',
'string',
Rule::in([$project->key]),
],
'source_template_id' => [
'required',
'integer',
Rule::exists(ProjectTemplate::class, 'id')->where('is_published', true),
Rule::notIn(array_filter([$project->project_template_id])),
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'confirmation_key.in' => 'Enter the exact project key to confirm the template switch.',
'source_template_id.not_in' => 'Choose a different published template to switch this project.',
];
}
}
@@ -43,14 +43,70 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
]; ];
const switchTemplateForm = useForm({
confirmation_key: '',
source_template_id: props.sourceTemplates.find(
(template) => template.id !== props.project.project_template_id,
)?.id,
});
const resetTemplateForm = useForm({
confirmation_key: '',
});
const deleteForm = useForm({ const deleteForm = useForm({
confirmation_key: '', confirmation_key: '',
}); });
const availableSwitchTemplates = computed(() =>
props.sourceTemplates.filter(
(template) => template.id !== props.project.project_template_id,
),
);
const currentTemplate = computed(() => props.project.project_template ?? null);
const isSwitchConfirmationValid = computed(
() => switchTemplateForm.confirmation_key === props.project.key,
);
const canSwitchTemplate = computed(
() =>
isSwitchConfirmationValid.value &&
switchTemplateForm.source_template_id !== undefined,
);
const isResetConfirmationValid = computed(
() => resetTemplateForm.confirmation_key === props.project.key,
);
const isDeleteConfirmationValid = computed( const isDeleteConfirmationValid = computed(
() => deleteForm.confirmation_key === props.project.key, () => deleteForm.confirmation_key === props.project.key,
); );
const submitSwitchTemplate = (): void => {
if (!canSwitchTemplate.value) {
return;
}
switchTemplateForm.submit(
ProjectController.switchTemplate(props.project.id),
{
preserveScroll: true,
},
);
};
const submitResetTemplate = (): void => {
if (!isResetConfirmationValid.value) {
return;
}
resetTemplateForm.submit(ProjectController.resetTemplate(props.project.id), {
preserveScroll: true,
});
};
const submitDelete = (): void => { const submitDelete = (): void => {
if (!isDeleteConfirmationValid.value) { if (!isDeleteConfirmationValid.value) {
return; return;
@@ -152,6 +208,139 @@ const submitDelete = (): void => {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Template baseline</CardTitle>
<CardDescription>
Switch this project to another published template or reset it back to the currently linked template baseline. Both actions can overwrite local configuration.
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4">
<p class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]">
Current linked template
</p>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ currentTemplate?.name ?? 'No linked template' }}
</p>
<p class="text-muted-foreground mt-2 text-sm">
{{
templateState?.inheritsFromTemplate
? 'Inherited and overridden areas stay visible after switching or resetting.'
: 'This project currently behaves like a standalone baseline.'
}}
</p>
</div>
<div class="border-border/70 rounded-xl border p-4">
<p class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]">
Warning
</p>
<p class="text-foreground mt-2 text-sm font-medium">
Template changes can replace board workflow, ticket metadata, repositories, and notifications for copied areas.
</p>
<p class="text-muted-foreground mt-2 text-sm">
Confirm each action with the exact project key to avoid accidental overwrites.
</p>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-2">
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitSwitchTemplate">
<div class="space-y-1">
<h3 class="text-sm font-semibold">Switch to another template</h3>
<p class="text-muted-foreground text-sm">
Applying a different template replaces the copied areas with that template's current baseline.
</p>
</div>
<div class="grid gap-2">
<Label for="source_template_id">Published template</Label>
<select
id="source_template_id"
v-model="switchTemplateForm.source_template_id"
:disabled="switchTemplateForm.processing || availableSwitchTemplates.length === 0"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option :value="undefined" disabled>
Select a template
</option>
<option
v-for="template in availableSwitchTemplates"
:key="template.id"
:value="template.id"
>
{{ template.name }}
</option>
</select>
<InputError :message="switchTemplateForm.errors.source_template_id" />
</div>
<div class="grid gap-2">
<Label for="switch_confirmation_key">Confirmation key</Label>
<Input
id="switch_confirmation_key"
v-model="switchTemplateForm.confirmation_key"
:disabled="switchTemplateForm.processing"
placeholder="Type the project key exactly"
/>
<InputError :message="switchTemplateForm.errors.confirmation_key" />
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
Existing project-specific configuration in copied areas may be replaced or removed.
</p>
<Button
type="submit"
variant="outline"
:disabled="switchTemplateForm.processing || !canSwitchTemplate"
>
Switch template
</Button>
</div>
</form>
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitResetTemplate">
<div class="space-y-1">
<h3 class="text-sm font-semibold">Reset to current template baseline</h3>
<p class="text-muted-foreground text-sm">
Re-apply the currently linked template and clear local divergence inside copied areas.
</p>
</div>
<div class="grid gap-2">
<Label for="reset_confirmation_key">Confirmation key</Label>
<Input
id="reset_confirmation_key"
v-model="resetTemplateForm.confirmation_key"
:disabled="resetTemplateForm.processing || !currentTemplate"
placeholder="Type the project key exactly"
/>
<InputError :message="resetTemplateForm.errors.confirmation_key" />
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
This restores the current template baseline for copied areas and rewinds project-level overrides there.
</p>
<Button
type="submit"
variant="outline"
:disabled="resetTemplateForm.processing || !isResetConfirmationValid || !currentTemplate"
>
Reset to baseline
</Button>
</div>
</form>
</div>
</CardContent>
</Card>
<Card <Card
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20" class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
> >
@@ -1,7 +1,11 @@
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
export type ProjectModel = { export type ProjectModel = {
id: number; id: number;
name: string; name: string;
key: string; key: string;
project_template_id?: number | null;
project_template?: ProjectTemplateModel | null;
show_author: boolean; show_author: boolean;
show_story_points: boolean; show_story_points: boolean;
created_at: string; created_at: string;
@@ -9,6 +9,7 @@ export type ProjectTemplateModel = {
name: string; name: string;
slug: string; slug: string;
copy_areas: string[]; copy_areas: string[];
configuration_snapshot: Record<string, unknown>;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
}; };
+5
View File
@@ -69,8 +69,11 @@ export type ProjectTemplateStateArea = {
export type ProjectTemplateState = { export type ProjectTemplateState = {
effectiveConfiguration: Record<string, unknown>; effectiveConfiguration: Record<string, unknown>;
inheritedAreas: ProjectTemplateStateArea[]; inheritedAreas: ProjectTemplateStateArea[];
inheritedConfiguration?: Record<string, unknown>;
inheritsFromTemplate: boolean; inheritsFromTemplate: boolean;
overriddenAreas: ProjectTemplateStateArea[]; overriddenAreas: ProjectTemplateStateArea[];
overriddenConfiguration?: Record<string, unknown>;
templateConfiguration?: Record<string, unknown>;
templateId: number; templateId: number;
templateName: string; templateName: string;
templateSlug: string; templateSlug: string;
@@ -117,7 +120,9 @@ export type ProjectBoardSettingsPageProps = {
export type ProjectGeneralSettingsPageProps = { export type ProjectGeneralSettingsPageProps = {
project: ProjectModel; project: ProjectModel;
sourceTemplates: ProjectTemplateModel[];
status?: string | null; status?: string | null;
templateState?: ProjectTemplateState | null;
}; };
export type ProjectBoardPageProps = { export type ProjectBoardPageProps = {
+2
View File
@@ -8,6 +8,8 @@ use App\Http\Controllers\ProjectTicketMetadataSettingsController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('general', [ProjectController::class, 'general'])->name('general'); Route::get('general', [ProjectController::class, 'general'])->name('general');
Route::post('general/switch-template', [ProjectController::class, 'switchTemplate'])->name('general.switch-template');
Route::post('general/reset-template', [ProjectController::class, 'resetTemplate'])->name('general.reset-template');
Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board'); Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board');
@@ -2,8 +2,8 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\NotificationAction; use App\Models\NotificationAction;
use App\Models\Project; use App\Models\Project;
@@ -590,6 +590,149 @@ class ProjectControllerTest extends TestCase
); );
} }
public function test_project_admins_can_switch_projects_to_another_template(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
'name' => 'Core Platform',
'show_author' => false,
'show_story_points' => false,
]);
$currentTemplate = ProjectTemplate::factory()->create([
'name' => 'Current Template',
'slug' => 'current-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],
],
],
]);
$newTemplate = ProjectTemplate::factory()->create([
'name' => 'Operations Template',
'slug' => 'operations-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
['name' => 'Incident', 'position' => 1, 'is_parent' => false],
['name' => 'Maintenance', 'position' => 2, 'is_parent' => false],
],
],
]);
$project->update(['project_template_id' => $currentTemplate->id]);
$project->ticketTypes()->delete();
TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Bug', 'position' => 1, 'is_parent' => false]);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->from(route('projects.settings.general', $project))
->post(route('projects.settings.general.switch-template', $project), [
'confirmation_key' => 'CORE',
'source_template_id' => $newTemplate->id,
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHas('status', 'Core Platform now uses the Operations Template template.');
$project->refresh();
$this->assertSame($newTemplate->id, $project->project_template_id);
$this->assertTrue($project->show_author);
$this->assertTrue($project->show_story_points);
$this->assertSame(['Incident', 'Maintenance'], $project->ticketTypes()->pluck('name')->all());
}
public function test_project_admins_can_reset_projects_to_their_current_template_baseline(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
'name' => 'Core Platform',
'show_author' => false,
'show_story_points' => false,
]);
$template = ProjectTemplate::factory()->create([
'name' => 'Workflow Template',
'slug' => 'workflow-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
['name' => 'Bug', 'position' => 1, 'is_parent' => false],
['name' => 'Feature', 'position' => 2, 'is_parent' => false],
],
],
]);
$project->update(['project_template_id' => $template->id]);
$project->ticketTypes()->delete();
TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Custom', 'position' => 1, 'is_parent' => false]);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->from(route('projects.settings.general', $project))
->post(route('projects.settings.general.reset-template', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHas('status', 'Core Platform was reset to the Workflow Template template baseline.');
$project->refresh();
$this->assertSame($template->id, $project->project_template_id);
$this->assertTrue($project->show_author);
$this->assertTrue($project->show_story_points);
$this->assertSame(['Bug', 'Feature'], $project->ticketTypes()->pluck('name')->all());
}
public function test_project_template_switch_requires_exact_project_key_confirmation(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$project->update([
'project_template_id' => ProjectTemplate::factory()->create()->id,
]);
$newTemplate = ProjectTemplate::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->from(route('projects.settings.general', $project))
->post(route('projects.settings.general.switch-template', $project), [
'confirmation_key' => 'core',
'source_template_id' => $newTemplate->id,
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHasErrors('confirmation_key');
$this->assertNotSame($newTemplate->id, $project->fresh()->project_template_id);
}
/** /**
* Ensure admins can delete projects and their dependent project-scoped data. * Ensure admins can delete projects and their dependent project-scoped data.
*/ */