Merge pull request 'koda/kan-10-edit-templates' (#102) from koda/kan-10-edit-templates into epic/kan-3
Reviewed-on: OpenSource/KanbanGit#102
This commit was merged in pull request #102.
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class UpdateProjectTemplate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name: string, copy_areas?: array<int, string>} $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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Actions\Projects\CreateProjectTemplate;
|
use App\Actions\Projects\CreateProjectTemplate;
|
||||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\UpdateProjectTemplate;
|
||||||
use App\Http\Requests\StoreProjectTemplateRequest;
|
use App\Http\Requests\StoreProjectTemplateRequest;
|
||||||
|
use App\Http\Requests\UpdateProjectTemplateRequest;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Http\Resources\ProjectTemplateResource;
|
use App\Http\Resources\ProjectTemplateResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -18,6 +20,7 @@ class ProjectTemplateController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CreateProjectTemplate $createProjectTemplate,
|
private readonly CreateProjectTemplate $createProjectTemplate,
|
||||||
|
private readonly UpdateProjectTemplate $updateProjectTemplate,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -28,12 +31,25 @@ class ProjectTemplateController extends Controller
|
|||||||
ProjectTemplate::query()
|
ProjectTemplate::query()
|
||||||
->published()
|
->published()
|
||||||
->with('sourceProject')
|
->with('sourceProject')
|
||||||
|
->withCount('linkedProjects')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(),
|
->get(),
|
||||||
)->resolve(),
|
)->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
|
public function create(Project $project): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('project-templates/Create', [
|
return Inertia::render('project-templates/Create', [
|
||||||
@@ -50,6 +66,16 @@ class ProjectTemplateController extends Controller
|
|||||||
->with('status', "Template {$template->name} was created.");
|
->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
|
public function destroy(ProjectTemplate $projectTemplate): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_if(! $projectTemplate->is_published, 404);
|
abort_if(! $projectTemplate->is_published, 404);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateProjectTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('create-project') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|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<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'confirm_linked_projects_update.accepted' => 'Acknowledge the linked project warning before saving template changes.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ class ProjectTemplateResource extends JsonResource
|
|||||||
'is_published' => $this->is_published,
|
'is_published' => $this->is_published,
|
||||||
'copy_areas' => $this->copy_areas ?? [],
|
'copy_areas' => $this->copy_areas ?? [],
|
||||||
'configuration_snapshot' => $this->configuration_snapshot ?? [],
|
'configuration_snapshot' => $this->configuration_snapshot ?? [],
|
||||||
|
'linked_project_count' => $this->whenCounted('linkedProjects'),
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
'updated_at' => $this->updated_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),
|
'is_published' => Arr::get($this->resource, 'is_published', true),
|
||||||
'copy_areas' => Arr::get($this->resource, 'copy_areas', []),
|
'copy_areas' => Arr::get($this->resource, 'copy_areas', []),
|
||||||
'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []),
|
'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'),
|
'created_at' => Arr::get($this->resource, 'created_at'),
|
||||||
'updated_at' => Arr::get($this->resource, 'updated_at'),
|
'updated_at' => Arr::get($this->resource, 'updated_at'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Database\Factories\ProjectTemplateFactory;
|
use Database\Factories\ProjectTemplateFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
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'])]
|
#[Fillable(['project_id', 'source_project_id', 'name', 'slug', 'is_published', 'copy_areas', 'configuration_snapshot'])]
|
||||||
class ProjectTemplate extends Model
|
class ProjectTemplate extends Model
|
||||||
@@ -38,4 +39,9 @@ class ProjectTemplate extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class, 'source_project_id');
|
return $this->belongsTo(Project::class, 'source_project_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function linkedProjects(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Project::class, 'project_template_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
||||||
|
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<ProjectTemplatesEditPageProps>();
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Project templates',
|
||||||
|
href: projectTemplatesIndex(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: props.template.name,
|
||||||
|
href: `/project-templates/${props.template.id}/edit`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
confirm_linked_projects_update: false,
|
||||||
|
copy_areas: [...props.template.copy_areas],
|
||||||
|
name: props.template.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyAreasError = computed(() => {
|
||||||
|
return (
|
||||||
|
form.errors.copy_areas ??
|
||||||
|
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkedProjectCount = computed(() => props.template.linked_project_count ?? 0);
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
form.patch(`/project-templates/${props.template.id}`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
|
<Head :title="`Edit ${template.name}`" />
|
||||||
|
|
||||||
|
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge variant="outline" class="px-3 py-1">{{ template.slug }}</Badge>
|
||||||
|
<Badge v-if="linkedProjectCount > 0" variant="secondary">
|
||||||
|
{{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Heading
|
||||||
|
:title="`Edit ${template.name}`"
|
||||||
|
description="Refresh the template baseline from its source project while keeping immutable identifiers protected."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Template details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<template v-if="template.source_project">
|
||||||
|
This template baseline is rebuilt from {{ template.source_project.name }} ({{ template.source_project.key }}) when you save.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
This template baseline is rebuilt from its source project when you save.
|
||||||
|
</template>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form class="space-y-6" @submit.prevent="submit">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Template name</Label>
|
||||||
|
<Input id="name" v-model="form.name" required placeholder="Foundation template" />
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="slug">Template slug</Label>
|
||||||
|
<Input id="slug" :model-value="template.slug" disabled />
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
The slug stays locked so existing template references remain stable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Configuration groups</Label>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Saving rebuilds the selected groups from the current source-project configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<Label
|
||||||
|
v-for="copyAreaOption in copyAreaOptions"
|
||||||
|
:key="copyAreaOption.value"
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.copy_areas"
|
||||||
|
type="checkbox"
|
||||||
|
:value="copyAreaOption.value"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">{{ copyAreaOption.label }}</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ copyAreaOption.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<InputError :message="copyAreasError" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="linkedProjectCount > 0"
|
||||||
|
class="space-y-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">Warning</div>
|
||||||
|
<p class="text-sm">
|
||||||
|
Saving this template updates the baseline for {{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}.
|
||||||
|
Inherited areas will follow the new template version, while existing overrides stay untouched.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Label class="flex cursor-pointer items-start gap-3">
|
||||||
|
<input v-model="form.confirm_linked_projects_update" type="checkbox" class="mt-1" />
|
||||||
|
<span class="text-sm">
|
||||||
|
I understand that linked projects inheriting from this template will be affected.
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<InputError :message="form.errors.confirm_linked_projects_update" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button type="submit" :disabled="form.processing">
|
||||||
|
Save template changes
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="projectTemplatesIndex()">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
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 { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index';
|
||||||
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
||||||
|
|
||||||
@@ -69,6 +70,9 @@ const removeTemplate = (templateId: number): void => {
|
|||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<CardTitle>{{ template.name }}</CardTitle>
|
<CardTitle>{{ template.name }}</CardTitle>
|
||||||
<Badge variant="secondary">{{ template.slug }}</Badge>
|
<Badge variant="secondary">{{ template.slug }}</Badge>
|
||||||
|
<Badge v-if="template.linked_project_count" variant="outline">
|
||||||
|
{{ template.linked_project_count }} linked {{ template.linked_project_count === 1 ? 'project' : 'projects' }}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<template v-if="template.source_project">
|
<template v-if="template.source_project">
|
||||||
@@ -91,6 +95,12 @@ const removeTemplate = (templateId: number): void => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Button as-child variant="secondary">
|
||||||
|
<Link :href="projectTemplatesEdit(template.id)">
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="projectsCreate({ query: { template: template.id } })">
|
<Link :href="projectsCreate({ query: { template: template.id } })">
|
||||||
Use template
|
Use template
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ProjectTemplateModel = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
copy_areas: string[];
|
copy_areas: string[];
|
||||||
configuration_snapshot: Record<string, unknown>;
|
configuration_snapshot: Record<string, unknown>;
|
||||||
|
linked_project_count?: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export type ProjectTemplatesCreatePageProps = {
|
|||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectTemplatesEditPageProps = {
|
||||||
|
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||||
|
template: ProjectTemplateModel;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectTemplateStateArea = {
|
export type ProjectTemplateStateArea = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ Route::middleware('can:create-project')
|
|||||||
->name('project-templates.')
|
->name('project-templates.')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('/', [ProjectTemplateController::class, 'index'])->name('index');
|
Route::get('/', [ProjectTemplateController::class, 'index'])->name('index');
|
||||||
|
Route::get('{projectTemplate}/edit', [ProjectTemplateController::class, 'edit'])->name('edit');
|
||||||
|
Route::patch('{projectTemplate}', [ProjectTemplateController::class, 'update'])->name('update');
|
||||||
Route::delete('{projectTemplate}', [ProjectTemplateController::class, 'destroy'])->name('destroy');
|
Route::delete('{projectTemplate}', [ProjectTemplateController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ class ProjectTemplateControllerTest extends TestCase
|
|||||||
->component('project-templates/Create')
|
->component('project-templates/Create')
|
||||||
->where('project.id', $project->id)
|
->where('project.id', $project->id)
|
||||||
->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas())));
|
->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas())));
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('project-templates.edit', $template))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('project-templates/Edit')
|
||||||
|
->where('template.id', $template->id)
|
||||||
|
->where('template.slug', 'foundation-template')
|
||||||
|
->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas())));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_users_can_save_and_delete_templates_from_projects(): void
|
public function test_users_can_save_and_delete_templates_from_projects(): void
|
||||||
@@ -148,5 +157,97 @@ class ProjectTemplateControllerTest extends TestCase
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->delete(route('project-templates.destroy', $template))
|
->delete(route('project-templates.destroy', $template))
|
||||||
->assertNotFound();
|
->assertNotFound();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('project-templates.edit', $template))
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$sourceProject = Project::factory()->create();
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'source_project_id' => $sourceProject->id,
|
||||||
|
]);
|
||||||
|
Project::factory()->create([
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncGlobalPermissions($user, ['project:create']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('project-templates.update', $template), [
|
||||||
|
'name' => 'Updated Template',
|
||||||
|
'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('confirm_linked_projects_update');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_updating_a_template_refreshes_the_baseline_and_preserves_slug_and_overrides(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$sourceProject = Project::factory()->create([
|
||||||
|
'name' => 'Template Source',
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
]);
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'name' => 'Platform Template',
|
||||||
|
'slug' => 'platform-template',
|
||||||
|
'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||||
|
'configuration_snapshot' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'source_project_id' => $sourceProject->id,
|
||||||
|
]);
|
||||||
|
$inheritingProject = Project::factory()->create([
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
]);
|
||||||
|
$overriddenProject = Project::factory()->create([
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
'show_author' => true,
|
||||||
|
'show_story_points' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sourceProject->forceFill([
|
||||||
|
'show_author' => true,
|
||||||
|
'show_story_points' => true,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->syncGlobalPermissions($user, ['project:create']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('project-templates.update', $template), [
|
||||||
|
'name' => 'Platform Template v2',
|
||||||
|
'slug' => 'should-not-change',
|
||||||
|
'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||||
|
'confirm_linked_projects_update' => true,
|
||||||
|
])
|
||||||
|
->assertRedirect('/project-templates');
|
||||||
|
|
||||||
|
$template->refresh();
|
||||||
|
$inheritingProject->refresh();
|
||||||
|
$overriddenProject->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('Platform Template v2', $template->name);
|
||||||
|
$this->assertSame('platform-template', $template->slug);
|
||||||
|
$this->assertSame([
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => true,
|
||||||
|
'show_story_points' => true,
|
||||||
|
],
|
||||||
|
], $template->configuration_snapshot);
|
||||||
|
|
||||||
|
$this->assertTrue($inheritingProject->show_author);
|
||||||
|
$this->assertTrue($inheritingProject->show_story_points);
|
||||||
|
|
||||||
|
$this->assertTrue($overriddenProject->show_author);
|
||||||
|
$this->assertFalse($overriddenProject->show_story_points);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user