Merge pull request 'koda/kan-9-switchreset-template-for-existing-projects' (#101) from koda/kan-9-switchreset-template-for-existing-projects into epic/kan-3
Reviewed-on: OpenSource/KanbanGit#101
This commit was merged in pull request #101.
This commit is contained in:
@@ -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 ?? [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user