fix(KAN-14): preserve template inheritance and project data
CI/CD / preconditions (pull_request) Successful in 4s
CI/CD / test_and_check_app (pull_request) Failing after 2m14s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-10-15 11:38:17 +02:00
parent 5769d84526
commit 1291a0fcad
61 changed files with 2696 additions and 335 deletions
@@ -31,6 +31,7 @@ class DiscordLoggerTest extends TestCase
'logging.channels.discord_errors.url' => 'https://discord.example.test/webhook',
'logging.channels.discord_errors.mention' => '@here',
'logging.channels.discord_errors.title' => '## KanbanGit error',
'logging.channels.discord_errors.max_trace_lines' => 2,
]);
Log::forgetChannel('discord_errors');
@@ -38,7 +38,7 @@ class ProjectTemplateControllerTest extends TestCase
]);
$this->syncGlobalPermissions($user, ['project:create']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->get('/project-templates')
@@ -79,7 +79,7 @@ class ProjectTemplateControllerTest extends TestCase
]);
$this->syncGlobalPermissions($user, ['project:create']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->syncProjectPermissions($project, $user, ['admin']);
$project->ticketTypes()->delete();
$project->ticketStatuses()->delete();
@@ -119,19 +119,8 @@ class ProjectTemplateControllerTest extends TestCase
$this->assertTrue($template->is_published);
$this->assertNull($template->project_id);
$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->assertSame(app(DuplicateProjectConfiguration::class)->export($project, $template->copy_areas), $template->configuration_snapshot);
$this->assertSame($template->id, $project->fresh()->project_template_id);
$this->actingAs($user)
->delete(route('project-templates.destroy', $template))
@@ -271,6 +260,11 @@ class ProjectTemplateControllerTest extends TestCase
],
])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($inheritingProject, $template->configuration_snapshot, adoptExisting: true);
$nextConfiguration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($template->configuration_snapshot);
$nextConfiguration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
$nextConfiguration['ticket_types'][0]['name'] = 'Feature';
$sourceProject->forceFill([
'show_author' => true,
'show_story_points' => true,
@@ -281,6 +275,7 @@ class ProjectTemplateControllerTest extends TestCase
$this->actingAs($user)
->patch(route('project-templates.update', $template), [
'name' => 'Platform Template v2',
'configuration_snapshot' => $nextConfiguration,
'slug' => 'should-not-change',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
@@ -296,19 +291,7 @@ class ProjectTemplateControllerTest extends TestCase
$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,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Feature',
'position' => 1,
'is_parent' => false,
],
],
], $template->configuration_snapshot);
$this->assertSame($nextConfiguration, $template->configuration_snapshot);
$this->assertTrue($inheritingProject->show_author);
$this->assertTrue($inheritingProject->show_story_points);
@@ -0,0 +1,628 @@
<?php
namespace Tests\Feature\Projects;
use App\Actions\Notifications\MatchNotificationFilters;
use App\Actions\Notifications\TicketStatusChangedPayload;
use App\Actions\ProjectBoard\DeleteTicketStatus;
use App\Actions\ProjectBoard\StoreTicketStatus;
use App\Actions\ProjectBoard\UpdateBoardColumn;
use App\Actions\Projects\CreateProject;
use App\Actions\Projects\CreateProjectTemplate;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Actions\Projects\ResolveProjectTemplateState;
use App\Actions\Projects\UpdateProjectTemplate;
use App\Actions\Projects\UpdateProjectTemplateLink;
use App\Models\BoardColumn;
use App\Models\NotificationAction;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\RepositoryConnection;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
use App\Models\TicketLink;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Tests\TestCase;
class ProjectTemplateIdentityTest extends TestCase
{
use RefreshDatabase;
public function test_deleting_an_earlier_template_status_preserves_ticket_meaning(): void
{
$source = Project::factory()->create();
$todo = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1]);
TicketStatus::factory()->for($source)->create(['name' => 'Doing', 'position' => 2]);
TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 3]);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$ticket = $this->ticket($project, $project->ticketStatuses()->where('name', 'Doing')->firstOrFail());
app(DeleteTicketStatus::class)->handle($source, $todo);
$this->refreshTemplate($template);
$this->assertSame('Doing', $ticket->fresh()->ticketStatus->name, 'Deleting Todo made a Doing ticket Done.');
}
public function test_inherited_transitions_map_to_correct_statuses_after_local_insertion(): void
{
$source = Project::factory()->create();
$first = BoardColumn::factory()->for($source)->create(['name' => 'First', 'position' => 1]);
$second = BoardColumn::factory()->for($source)->create(['name' => 'Second', 'position' => 2]);
$todo = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1, 'board_column_id' => $first->id]);
$done = TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 1, 'board_column_id' => $second->id]);
$template = $this->template($source, ['board_columns', 'ticket_statuses', 'status_transitions']);
$project = $this->linkedProject($template);
app(StoreTicketStatus::class)->handle($project, ['name' => 'Local', 'board_column_id' => $project->boardColumns()->where('name', 'First')->firstOrFail()->id]);
$todo->allowedTargetStatuses()->sync([$done->id]);
$this->refreshTemplate($template);
$projectTodo = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail();
$this->assertSame(['Done'], $projectTodo->allowedTargetStatuses()->pluck('name')->all(), 'Template transition targeted Local instead of Done.');
}
public function test_effective_configuration_includes_local_areas_absent_from_template(): void
{
$source = Project::factory()->create();
$template = $this->template($source, ['project_settings']);
$project = $this->linkedProject($template);
TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]);
$state = app(ResolveProjectTemplateState::class)->handle($project->fresh());
$this->assertArrayHasKey('ticket_types', $state['effectiveConfiguration']);
}
public function test_private_baseline_tracks_column_reordering(): void
{
$project = app(CreateProject::class)->handle(['name' => 'Private', 'key' => 'PRIV'], User::factory()->create());
$first = BoardColumn::factory()->for($project)->create(['name' => 'First', 'position' => 1]);
$second = BoardColumn::factory()->for($project)->create(['name' => 'Second', 'position' => 2]);
app(UpdateBoardColumn::class)->handle($project, $second, ['name' => 'Second', 'position' => 1]);
$actual = app(DuplicateProjectConfiguration::class)->export($project->fresh(), ['board_columns']);
$snapshot = $project->fresh()->projectTemplate->configuration_snapshot;
$this->assertSame($actual['board_columns'], $snapshot['board_columns']);
}
public function test_used_local_status_in_earlier_column_requires_explicit_reset_replacement(): void
{
$source = Project::factory()->create();
$first = BoardColumn::factory()->for($source)->create(['name' => 'First', 'position' => 1]);
$second = BoardColumn::factory()->for($source)->create(['name' => 'Second', 'position' => 2]);
TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1, 'board_column_id' => $first->id]);
TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 1, 'board_column_id' => $second->id]);
$template = $this->template($source, ['board_columns', 'ticket_statuses']);
$project = $this->linkedProject($template);
$local = app(StoreTicketStatus::class)->handle($project, ['name' => 'Local', 'board_column_id' => $project->boardColumns()->where('name', 'First')->firstOrFail()->id]);
$this->ticket($project, $local);
$user = User::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)->post(route('projects.settings.general.reset-template', $project), [
'areas' => ['ticket_statuses'],
])->assertSessionHasErrors('status_replacements.'.$local->id);
}
public function test_template_remains_editable_after_source_project_deletion(): void
{
$source = Project::factory()->create();
$template = $this->template($source, ['project_settings']);
$source->delete();
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->actingAs($user)->patch(route('project-templates.update', $template), [
'name' => 'Renamed',
'copy_areas' => ['project_settings'],
])->assertRedirect(route('project-templates.index'));
}
public function test_status_rename_propagates_stable_slug_behavior(): void
{
$source = Project::factory()->create();
$status = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1]);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$status->update(['name' => 'Ready']);
$this->refreshTemplate($template);
$this->assertSame($status->fresh()->slug, $project->ticketStatuses()->firstOrFail()->slug);
}
public function test_local_column_reorder_does_not_remap_inherited_status_column(): void
{
$source = Project::factory()->create();
$first = BoardColumn::factory()->for($source)->create(['name' => 'First', 'position' => 1]);
BoardColumn::factory()->for($source)->create(['name' => 'Second', 'position' => 2]);
$status = TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1, 'board_column_id' => $first->id]);
$template = $this->template($source, ['board_columns', 'ticket_statuses']);
$project = $this->linkedProject($template);
$second = $project->boardColumns()->where('name', 'Second')->firstOrFail();
app(UpdateBoardColumn::class)->handle($project, $second, ['name' => 'Second', 'position' => 1]);
$status->update(['name' => 'Ready']);
$this->refreshTemplate($template);
$this->assertSame('First', $project->ticketStatuses()->firstOrFail()->boardColumn->name);
}
public function test_empty_template_and_project_creation_work_without_a_source_project(): void
{
$this->withoutVite();
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->actingAs($user)->get(route('project-templates.create'))->assertOk();
$this->post(route('project-templates.store'), [
'name' => 'Empty template', 'slug' => 'empty-template',
'copy_areas' => DuplicateProjectConfiguration::copyableAreas(),
])->assertSessionHasNoErrors()->assertRedirect();
$template = ProjectTemplate::query()->where('slug', 'empty-template')->firstOrFail();
$this->assertNull($template->source_project_id);
$project = app(CreateProject::class)->handle([
'name' => 'From empty template', 'key' => 'EMPTY',
'creation_mode' => 'template', 'source_template_id' => $template->id,
], $user);
$this->assertSame(['Task', 'Release'], $project->ticketTypes()->pluck('name')->all());
$this->assertSame(['Medium'], $project->ticketPriorities()->pluck('name')->all());
$this->assertSame(['Backlog'], $project->ticketStatuses()->pluck('name')->all());
$this->assertSame([], $project->template_overrides);
$this->assertSame(0, $project->tickets()->count());
}
public function test_publishing_private_template_keeps_its_identity_and_links_the_source(): void
{
$project = app(CreateProject::class)->handle(['name' => 'Original', 'key' => 'ORIG'], User::factory()->create());
$base = $project->projectTemplate;
$status = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $status);
$template = app(CreateProjectTemplate::class)->handle($project, [
'name' => 'Published', 'slug' => $base->slug, 'copy_areas' => DuplicateProjectConfiguration::copyableAreas(),
]);
$this->assertSame($base->id, $template->id);
$this->assertSame($base->slug, $template->slug);
$this->assertTrue($template->is_published);
$this->assertNull($template->project_id);
$this->assertSame($template->id, $project->fresh()->project_template_id);
$configuration = $template->configuration_snapshot;
$configuration['ticket_statuses'][0]['name'] = 'Ready';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
$this->assertSame('Ready', $ticket->fresh()->ticketStatus->name);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
}
public function test_read_only_project_users_cannot_publish_and_relink_the_project(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)->post(route('projects.templates.store', $project), [
'name' => 'Forbidden', 'slug' => 'forbidden', 'copy_areas' => ['project_settings'],
])->assertForbidden();
$this->assertNull($project->fresh()->project_template_id);
}
public function test_name_only_update_keeps_the_snapshot_even_when_the_source_changed(): void
{
$source = Project::factory()->create();
$status = TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
$snapshot = $template->configuration_snapshot;
$status->update(['name' => 'Source change']);
app(UpdateProjectTemplate::class)->handle($template, ['name' => 'Only renamed']);
$this->assertSame($snapshot, $template->fresh()->configuration_snapshot);
$this->assertSame('Todo', $ticket->fresh()->ticketStatus->name);
}
public function test_referenced_template_status_removal_keeps_the_project_area_until_explicit_reset(): void
{
$this->withoutVite();
$user = User::factory()->create();
$configuration = app(ProjectConfigurationIdentity::class)->defaults();
$configuration['ticket_statuses'][] = ['configuration_key' => (string) Str::uuid(), 'name' => 'Done', 'position' => 2, 'board_column_key' => null];
$template = app(CreateProjectTemplate::class)->empty(['name' => 'Protected', 'slug' => 'protected', 'copy_areas' => array_keys($configuration), 'configuration_snapshot' => $configuration]);
$project = $this->linkedProject($template);
$todo = $project->ticketStatuses()->where('name', 'Backlog')->firstOrFail();
$ticket = Ticket::factory()->create(['project_id' => $project->id, 'ticket_status_id' => $todo->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id]);
$next = $configuration;
$next['ticket_statuses'] = [$configuration['ticket_statuses'][1]];
$next['ticket_statuses'][0]['position'] = 1;
$next['status_transitions'] = [];
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('Backlog', $ticket->fresh()->ticketStatus->name);
$this->assertContains('ticket_statuses', $project->fresh()->template_overrides);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)->post(route('projects.settings.general.reset-template', $project), ['areas' => ['ticket_statuses']])->assertSessionHasErrors('status_replacements.'.$todo->id);
$this->post(route('projects.settings.general.reset-template', $project), ['areas' => ['ticket_statuses'], 'status_replacements' => [$todo->id => $next['ticket_statuses'][0]['configuration_key']]])->assertSessionHasNoErrors();
$this->assertSame('Done', $ticket->fresh()->ticketStatus->name);
$this->assertNotContains('ticket_statuses', $project->fresh()->template_overrides);
}
public function test_reset_can_create_a_new_template_status_and_use_it_as_replacement(): void
{
$this->withoutVite();
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$status = $project->ticketStatuses()->firstOrFail();
$status->update(['name' => 'Local']);
$ticket = $this->ticket($project, $status);
$newKey = (string) Str::uuid();
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => ['ticket_statuses' => [['configuration_key' => $newKey, 'name' => 'New template status', 'board_column_key' => null, 'position' => 1]]]]);
$user = User::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)->post(route('projects.settings.general.reset-template', $project), ['areas' => ['ticket_statuses'], 'status_replacements' => [$status->id => $newKey]])->assertSessionHasNoErrors();
$this->assertSame('New template status', $ticket->fresh()->ticketStatus->name);
$this->assertSame($newKey, $ticket->fresh()->ticketStatus->configuration_key);
}
public function test_field_reordering_preserves_values_and_type_changes_with_values_stay_local(): void
{
$source = Project::factory()->create();
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Estimate', 'field_type' => 'number', 'position' => 1]);
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Cost', 'field_type' => 'number', 'position' => 2]);
$template = $this->template($source, ['ticket_fields']);
$project = $this->linkedProject($template);
$field = $project->ticketFieldDefinitions()->where('label', 'Estimate')->firstOrFail();
$status = TicketStatus::factory()->for($project)->create();
$ticket = $this->ticket($project, $status);
$value = TicketFieldValue::factory()->create(['ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8]);
$next = $template->configuration_snapshot;
$next['ticket_fields'] = array_reverse($next['ticket_fields']);
$next['ticket_fields'][0]['position'] = 1;
$next['ticket_fields'][1]['position'] = 2;
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('Estimate', $value->fresh()->ticketFieldDefinition->label);
$this->assertSame($field->id, $value->fresh()->ticket_field_definition_id);
$next['ticket_fields'][1]['field_type'] = 'user';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('number', $field->fresh()->field_type);
$this->assertEquals(8, $value->fresh()->value_number);
$this->assertContains('ticket_fields', $project->fresh()->template_overrides);
}
public function test_template_payload_rejects_duplicate_keys_foreign_references_and_unknown_fields(): void
{
$this->withoutVite();
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$configuration = app(ProjectConfigurationIdentity::class)->defaults();
$configuration['ticket_types'][1]['configuration_key'] = $configuration['ticket_types'][0]['configuration_key'];
$configuration['ticket_statuses'][0]['board_column_key'] = (string) Str::uuid();
$configuration['ticket_priorities'][0]['project_id'] = 999;
$this->actingAs($user)->post(route('project-templates.store'), ['name' => 'Invalid', 'slug' => 'invalid', 'copy_areas' => array_keys($configuration), 'configuration_snapshot' => $configuration])->assertSessionHasErrors([
'configuration_snapshot.ticket_types.0.configuration_key', 'configuration_snapshot.ticket_priorities.0',
]);
unset($configuration['ticket_priorities'][0]['project_id']);
$configuration['ticket_types'][1]['configuration_key'] = (string) Str::uuid();
$this->post(route('project-templates.store'), ['name' => 'Invalid', 'slug' => 'invalid', 'copy_areas' => array_keys($configuration), 'configuration_snapshot' => $configuration])->assertSessionHasErrors('configuration_snapshot.ticket_statuses.0.board_column_key');
$this->assertSame(0, ProjectTemplate::query()->where('slug', 'invalid')->count());
}
public function test_identity_migration_keeps_existing_tickets_values_links_and_overrides(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Estimate', 'field_type' => 'number']);
$template = $this->template($source, ['ticket_statuses', 'ticket_fields']);
$project = $this->linkedProject($template);
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
$field = $project->ticketFieldDefinitions()->firstOrFail();
$value = TicketFieldValue::factory()->create(['ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8]);
$statusId = $ticket->ticket_status_id;
$overrides = $project->fresh()->template_overrides;
$migration = require database_path('migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php');
$migration->down();
$legacy = $template->fresh()->configuration_snapshot;
$this->assertArrayNotHasKey('configuration_key', $legacy['ticket_fields'][0]);
$migration->up();
$this->assertSame($template->id, $project->fresh()->project_template_id);
$this->assertSame($statusId, $ticket->fresh()->ticket_status_id);
$this->assertSame($field->id, $value->fresh()->ticket_field_definition_id);
$this->assertEquals(8, $value->fresh()->value_number);
$this->assertSame($overrides, $project->fresh()->template_overrides);
$snapshot = $template->fresh()->configuration_snapshot;
$this->assertSame($snapshot['ticket_statuses'][0]['configuration_key'], $ticket->fresh()->ticketStatus->configuration_key);
$this->assertSame($snapshot['ticket_fields'][0]['configuration_key'], $field->fresh()->configuration_key);
}
public function test_status_reset_transfers_local_transitions_and_preserves_their_override(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo', 'position' => 1]);
TicketStatus::factory()->for($source)->create(['name' => 'Done', 'position' => 2]);
$template = $this->template($source, ['ticket_statuses', 'status_transitions']);
$project = $this->linkedProject($template);
$todo = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail();
$done = $project->ticketStatuses()->where('name', 'Done')->firstOrFail();
$local = TicketStatus::factory()->for($project)->create(['name' => 'Local', 'position' => 3]);
$todo->allowedTargetStatuses()->sync([$local->id]);
$local->allowedTargetStatuses()->sync([$done->id]);
$project->fresh()->forceFill(['template_overrides' => ['ticket_statuses', 'status_transitions']])->saveQuietly();
$ticket = $this->ticket($project, $local);
$expectedOverrides = array_values(array_diff($project->fresh()->template_overrides, ['ticket_statuses']));
app(UpdateProjectTemplateLink::class)->reset($project->fresh(), ['ticket_statuses'], [$local->id => $todo->configuration_key]);
$this->assertSame($todo->id, $ticket->fresh()->ticket_status_id);
$this->assertSame([$done->id], $todo->allowedTargetStatuses()->pluck('ticket_statuses.id')->all());
$this->assertSame($expectedOverrides, $project->fresh()->template_overrides);
}
public function test_deleting_a_template_creates_private_baselines_without_recreating_configuration(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$template = app(CreateProjectTemplate::class)->empty(['name' => 'Shared', 'slug' => 'shared', 'copy_areas' => DuplicateProjectConfiguration::copyableAreas()]);
$project = $this->linkedProject($template);
$otherProject = $this->linkedProject($template);
$status = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $status);
$snapshot = app(DuplicateProjectConfiguration::class)->export($project, DuplicateProjectConfiguration::copyableAreas());
$this->actingAs($user)->delete(route('project-templates.destroy', $template))->assertRedirect();
$base = $project->fresh()->projectTemplate;
$this->assertNotNull($base);
$this->assertFalse($base->is_published);
$this->assertSame($project->id, $base->project_id);
$this->assertSame($snapshot, $base->configuration_snapshot);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
$this->assertFalse($otherProject->fresh()->projectTemplate->is_published);
$this->assertNotSame($base->id, $otherProject->fresh()->project_template_id);
}
public function test_private_base_slug_does_not_collide_with_published_template_slugs(): void
{
ProjectTemplate::factory()->create(['slug' => 'orig-base-template']);
$project = app(CreateProject::class)->handle(['name' => 'Original', 'key' => 'ORIG'], User::factory()->create());
$this->assertSame('orig-base-template-2', $project->projectTemplate->slug);
}
public function test_reordering_types_priorities_and_links_preserves_ticket_references(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
foreach (['Task', 'Bug'] as $index => $name) {
TicketType::factory()->for($source)->create(['name' => $name, 'position' => $index + 1]);
}
foreach (['High', 'Low'] as $index => $name) {
TicketPriority::factory()->for($source)->create(['name' => $name, 'position' => $index + 1]);
}
foreach (['Blocks', 'Relates'] as $index => $label) {
TicketLinkType::factory()->for($source)->create(['label' => $label, 'position' => $index + 1]);
}
$template = $this->template($source, ['ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_link_types']);
$project = $this->linkedProject($template);
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
$link = TicketLink::factory()->create(['source_ticket_id' => $ticket->id, 'ticket_link_type_id' => $project->ticketLinkTypes()->firstOrFail()->id]);
$next = $template->configuration_snapshot;
foreach (['ticket_types', 'ticket_priorities', 'ticket_link_types'] as $area) {
$next[$area] = array_reverse($next[$area]);
foreach ($next[$area] as $index => &$item) {
$item['position'] = $index + 1;
}
unset($item);
}
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('Task', $ticket->fresh()->ticketType->name);
$this->assertSame('High', $ticket->fresh()->ticketPriority->name);
$this->assertSame('Blocks', $link->fresh()->ticketLinkType->label);
foreach (['ticket_types', 'ticket_priorities', 'ticket_link_types'] as $area) {
array_pop($next[$area]);
}
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('Task', $ticket->fresh()->ticketType->name);
$this->assertSame('High', $ticket->fresh()->ticketPriority->name);
$this->assertSame('Blocks', $link->fresh()->ticketLinkType->label);
foreach (['ticket_types', 'ticket_priorities', 'ticket_link_types'] as $area) {
$this->assertContains($area, $project->fresh()->template_overrides);
}
}
public function test_notification_status_filters_use_the_linked_project_status_ids(): void
{
$source = Project::factory()->create();
$status = TicketStatus::factory()->for($source)->create(['name' => 'Ready']);
NotificationAction::factory()->for($source)->create(['filters' => [['attribute' => 'status.to.id', 'operator' => 'equals', 'value' => (string) $status->id]]]);
$template = $this->template($source, ['ticket_statuses', 'notification_actions']);
$this->assertSame($status->configuration_key, $template->configuration_snapshot['notification_actions'][0]['filters'][0]['value']);
$project = $this->linkedProject($template);
$target = $project->ticketStatuses()->firstOrFail();
$notification = $project->notificationActions()->firstOrFail();
$this->assertSame((string) $target->id, $notification->filters[0]['value']);
$payload = new TicketStatusChangedPayload($project->id, $project->key, $project->name, 1, $project->key.'-1', 'Example', 'https://example.test/ticket', null, null, $target->id, $target->name, $target->id, $target->name, now());
$this->assertTrue(app(MatchNotificationFilters::class)->handle($notification->filters, $payload));
$exported = app(DuplicateProjectConfiguration::class)->export($project, ['notification_actions']);
$this->assertSame($status->configuration_key, $exported['notification_actions'][0]['filters'][0]['value']);
}
public function test_column_replacement_moves_inherited_statuses_without_losing_tickets(): void
{
$source = Project::factory()->create();
$column = BoardColumn::factory()->for($source)->create(['name' => 'Old column', 'position' => 1]);
TicketStatus::factory()->for($source)->create(['name' => 'Ready', 'position' => 1, 'board_column_id' => $column->id]);
$template = $this->template($source, ['board_columns', 'ticket_statuses']);
$project = $this->linkedProject($template);
$status = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $status);
$next = $template->configuration_snapshot;
$key = (string) Str::uuid();
$next['board_columns'] = [['configuration_key' => $key, 'name' => 'New column', 'position' => 1]];
$next['ticket_statuses'][0]['board_column_key'] = $key;
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $next]);
$this->assertSame('New column', $ticket->fresh()->ticketStatus->boardColumn->name);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
$this->assertNotContains('board_columns', $project->fresh()->template_overrides);
$this->assertSame(['New column'], $project->boardColumns()->pluck('name')->all());
}
public function test_publishing_private_template_via_http_preserves_its_slug(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$project = app(CreateProject::class)->handle(['name' => 'Original', 'key' => 'ORIG'], $user);
$base = $project->projectTemplate;
$url = route('projects.templates.store', $project);
$this->actingAs($user)->post($url, ['name' => 'Published', 'slug' => 'different', 'copy_areas' => ['project_settings']])->assertSessionHasErrors('slug');
$this->post($url, ['name' => 'Published', 'slug' => $base->slug, 'copy_areas' => ['project_settings']])->assertSessionHasNoErrors()->assertRedirect();
$this->assertSame($base->id, $project->fresh()->project_template_id);
$this->assertTrue($base->fresh()->is_published);
$this->assertSame($base->slug, $base->fresh()->slug);
}
public function test_identity_migration_keeps_duplicate_field_labels_distinct(): void
{
$source = Project::factory()->create();
foreach ([1, 2] as $position) {
TicketFieldDefinition::factory()->for($source)->create(['label' => 'Estimate', 'field_type' => 'number', 'position' => $position]);
}
$template = $this->template($source, ['ticket_fields']);
$project = $this->linkedProject($template);
$field = $project->ticketFieldDefinitions()->firstOrFail();
$ticket = $this->ticket($project, TicketStatus::factory()->for($project)->create());
$value = TicketFieldValue::factory()->create(['ticket_id' => $ticket->id, 'ticket_field_definition_id' => $field->id, 'value_number' => 8]);
$migration = require database_path('migrations/2026_10_15_072816_add_configuration_keys_to_project_configuration.php');
$migration->down();
$legacy = $template->fresh()->configuration_snapshot;
$this->assertArrayNotHasKey('configuration_key', $legacy['ticket_fields'][0]);
$migration->up();
$configuration = $template->fresh()->configuration_snapshot;
$this->assertNotSame($configuration['ticket_fields'][0]['configuration_key'], $configuration['ticket_fields'][1]['configuration_key']);
$this->assertSame($configuration['ticket_fields'][0]['configuration_key'], $field->fresh()->configuration_key);
$configuration['ticket_fields'][0]['label'] = 'First estimate';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
$this->assertSame($field->id, $value->fresh()->ticket_field_definition_id);
$this->assertSame('First estimate', $value->fresh()->ticketFieldDefinition->label);
$this->assertEquals(8, $value->fresh()->value_number);
}
public function test_stale_template_ownership_does_not_restore_the_old_link(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$other = app(CreateProjectTemplate::class)->empty(['name' => 'Other', 'slug' => 'other', 'copy_areas' => ['project_settings']]);
app(UpdateProjectTemplateLink::class)->switch($project, $other);
$status = TicketStatus::factory()->for($project)->create(['project_template_id' => $template->id, 'position' => 2]);
$this->assertSame($other->id, $status->project_template_id);
$this->assertSame($project->id, $status->project_id);
}
public function test_template_update_skips_a_project_that_changed_templates_before_its_lock(): void
{
$source = Project::factory()->create();
TicketStatus::factory()->for($source)->create(['name' => 'Todo']);
$template = $this->template($source, ['ticket_statuses']);
$project = $this->linkedProject($template);
$other = app(CreateProjectTemplate::class)->empty(['name' => 'Other', 'slug' => 'other', 'copy_areas' => ['project_settings']]);
$changed = false;
Project::retrieved(function (Project $selected) use ($project, $other, &$changed): void {
if (! $changed && $selected->id === $project->id) {
$changed = true;
DB::table('projects')->where('id', $project->id)->update(['project_template_id' => $other->id]);
}
});
try {
$configuration = $template->configuration_snapshot;
$configuration['ticket_statuses'][0]['name'] = 'Changed on old template';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
} finally {
Event::forget('eloquent.retrieved: '.Project::class);
}
$this->assertTrue($changed);
$this->assertSame($other->id, $project->fresh()->project_template_id);
$this->assertSame(['Todo'], $project->ticketStatuses()->pluck('name')->all());
}
public function test_repository_replacement_and_swapping_preserve_valid_connections(): void
{
$source = Project::factory()->create();
RepositoryConnection::factory()->for($source)->create(['provider_repository_id' => '1', 'base_url' => 'https://git.example.test']);
RepositoryConnection::factory()->for($source)->create(['provider_repository_id' => '2', 'base_url' => 'https://git.example.test']);
$template = $this->template($source, ['repository_connections']);
$project = $this->linkedProject($template);
$original = $project->repositoryConnections()->get()->keyBy('configuration_key');
$configuration = $template->configuration_snapshot;
$configuration['repository_connections'][0]['provider_repository_id'] = '2';
$configuration['repository_connections'][1]['provider_repository_id'] = '1';
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
foreach ($configuration['repository_connections'] as $entry) {
$connection = $project->repositoryConnections()->where('configuration_key', $entry['configuration_key'])->firstOrFail();
$this->assertSame($original[$entry['configuration_key']]->id, $connection->id);
$this->assertSame($entry['provider_repository_id'], $connection->provider_repository_id);
}
$configuration['repository_connections'][0]['configuration_key'] = (string) Str::uuid();
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'configuration_snapshot' => $configuration]);
$this->assertSame(2, $project->repositoryConnections()->count());
$this->assertEqualsCanonicalizing(['1', '2'], $project->repositoryConnections()->pluck('provider_repository_id')->all());
}
public function test_template_configuration_rejects_duplicate_repository_connections(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$source = Project::factory()->create();
RepositoryConnection::factory()->for($source)->create();
$configuration = app(DuplicateProjectConfiguration::class)->export($source, ['repository_connections']);
$configuration['repository_connections'][] = [
...$configuration['repository_connections'][0], 'configuration_key' => (string) Str::uuid(),
];
$this->actingAs($user)->post(route('project-templates.store'), [
'name' => 'Duplicate repositories', 'slug' => 'duplicate-repositories',
'copy_areas' => ['repository_connections'], 'configuration_snapshot' => $configuration,
])->assertSessionHasErrors('configuration_snapshot.repository_connections.1.provider_repository_id');
$this->assertDatabaseMissing('project_templates', ['slug' => 'duplicate-repositories']);
}
private function template(Project $source, array $areas): ProjectTemplate
{
return ProjectTemplate::factory()->create([
'source_project_id' => $source->id,
'is_published' => true,
'copy_areas' => $areas,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($source, $areas),
]);
}
private function linkedProject(ProjectTemplate $template): Project
{
$project = Project::factory()->create(['project_template_id' => $template->id]);
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot);
return $project->fresh();
}
private function refreshTemplate(ProjectTemplate $template): void
{
app(UpdateProjectTemplate::class)->handle($template, ['name' => $template->name, 'copy_areas' => $template->copy_areas, 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, $template->copy_areas)]);
}
private function ticket(Project $project, TicketStatus $status): Ticket
{
return Ticket::factory()->create([
'project_id' => $project->id,
'ticket_type_id' => ($project->ticketTypes()->first() ?? TicketType::factory()->for($project)->create())->id,
'ticket_priority_id' => ($project->ticketPriorities()->first() ?? TicketPriority::factory()->for($project)->create())->id,
'ticket_status_id' => $status->id,
'ticket_key' => $project->key.'-1',
]);
}
}
@@ -31,11 +31,13 @@ class ProjectTemplateOverrideTest extends TestCase
$project = Project::factory()->create();
$status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
$ticket = $this->createTicket($project, $status);
$sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
@@ -62,6 +64,7 @@ class ProjectTemplateOverrideTest extends TestCase
$sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
@@ -76,6 +79,7 @@ class ProjectTemplateOverrideTest extends TestCase
$project = Project::factory()->create();
$status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
$status->update(['name' => 'Project only']);
$status->update(['name' => 'Todo']);
@@ -86,6 +90,7 @@ class ProjectTemplateOverrideTest extends TestCase
$sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
@@ -106,7 +111,7 @@ class ProjectTemplateOverrideTest extends TestCase
'project_template_id' => $template->id,
'template_overrides' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
$replacementStatus = TicketStatus::factory()->for($project)->create(['name' => 'Old template status', 'position' => 1]);
$replacementStatus = TicketStatus::factory()->for($project)->create(['name' => 'Old template status', 'position' => 1, 'configuration_key' => $template->configuration_snapshot['ticket_statuses'][0]['configuration_key']]);
$localStatus = TicketStatus::factory()->for($project)->create(['name' => 'Local status', 'position' => 2]);
$ticket = $this->createTicket($project, $localStatus);
$this->syncProjectPermissions($project, $user, ['admin']);
@@ -171,9 +176,11 @@ class ProjectTemplateOverrideTest extends TestCase
$project = Project::factory()->create();
TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
@@ -199,6 +206,7 @@ class ProjectTemplateOverrideTest extends TestCase
'field_type' => 'number', 'label' => 'Estimate', 'position' => 1,
]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(DuplicateProjectConfiguration::class)->apply($project, $template->configuration_snapshot, adoptExisting: true);
$status = TicketStatus::factory()->for($project)->create();
$ticket = $this->createTicket($project, $status);
$fieldValue = TicketFieldValue::factory()->create([
@@ -210,6 +218,7 @@ class ProjectTemplateOverrideTest extends TestCase
$sourceField->update(['label' => 'Story points']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($template->sourceProject, DuplicateProjectConfiguration::copyableAreas()),
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_FIELDS],
]);
+4
View File
@@ -222,6 +222,10 @@ if (serve) {
assert.match(admin, /Board columns · inherited/);
assert.match(admin, /Ticket types · overridden/);
assert.match(
await render('/projects/7', { create: true, manage: true }),
/href="\/projects\/7\/templates\/create"/,
);
assert.doesNotMatch(
await render('/projects/7', { create: true }),
/href="\/projects\/7\/templates\/create"/,
);
+90 -3
View File
@@ -46,6 +46,7 @@ try {
'/project-templates?reset',
'/projects/1/templates/create?reset',
'/project-templates/2/edit?reset',
'/project-templates/create?reset',
]) {
await visit(path);
await noOverflow();
@@ -73,6 +74,7 @@ try {
});
}
}
await visit('/project-templates/2/edit?reset');
assert.equal(await page.locator('#slug').isDisabled(), true);
assert.equal(
await button('Save template changes').isDisabled(),
@@ -193,6 +195,14 @@ try {
name: 'Updated foundation',
copy_areas: ['project_settings', 'ticket_types'],
confirm_linked_projects_update: true,
configuration_snapshot: await page.evaluate(
() =>
JSON.parse(
document
.querySelector('[data-page]')
.getAttribute('data-page'),
).props.template.configuration_snapshot,
),
});
await page
.getByRole('heading', { name: 'Project templates', exact: true })
@@ -207,15 +217,92 @@ try {
assert.deepEqual((await unlinked).postDataJSON(), {
name: 'Foundation',
copy_areas: ['project_settings', 'ticket_types'],
configuration_snapshot: await page.evaluate(
() =>
JSON.parse(
document
.querySelector('[data-page]')
.getAttribute('data-page'),
).props.template.configuration_snapshot,
),
});
await page
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/2/edit?reset&missing');
await visit('/project-templates/2/edit?reset&missing&unlinked');
assert.equal(
await page
.getByText('This template cannot be refreshed', { exact: true })
.count(),
0,
);
assert.equal(await button('Save template changes').isEnabled(), true);
await button('Save template changes').click();
await page
.getByText('This template cannot be refreshed', { exact: true })
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/create?reset');
await page.locator('#name').fill('New independent template');
await page.locator('#slug').fill('independent-template');
const columns = page
.locator('details')
.filter({ has: page.locator('summary', { hasText: 'Board columns' }) });
await columns
.getByRole('button', { name: 'Add entry', exact: true })
.click();
await columns.getByLabel('Name', { exact: true }).fill('Todo');
const statuses = page.locator('details').filter({
has: page.locator('summary', { hasText: 'Ticket statuses' }),
});
await statuses
.getByRole('button', { name: 'Add entry', exact: true })
.click();
await statuses.getByLabel('Name', { exact: true }).last().fill('Review');
await statuses
.getByLabel('Board column', { exact: true })
.last()
.selectOption({ label: 'Todo' });
const types = page
.locator('details')
.filter({ has: page.locator('summary', { hasText: 'Ticket types' }) });
await types
.getByRole('button', { name: 'Move down', exact: true })
.first()
.click();
const standaloneCreated = page.waitForRequest(
(request) => request.method() === 'POST',
);
await button('Save new template').click();
const body = (await standaloneCreated).postDataJSON();
assert.equal(
new URL((await standaloneCreated).url()).pathname,
'/project-templates',
);
assert.equal(body.configuration_snapshot.ticket_statuses[1].name, 'Review');
assert.equal(
body.configuration_snapshot.ticket_statuses[1].board_column_key,
body.configuration_snapshot.board_columns[0].configuration_key,
);
assert.deepEqual(
body.configuration_snapshot.ticket_types.map((item) => item.name),
['Release', 'Task'],
);
assert.deepEqual(
body.configuration_snapshot.ticket_types.map((item) => item.position),
[1, 2],
);
assert.equal(
new Set(
body.configuration_snapshot.ticket_types.map(
(item) => item.configuration_key,
),
).size,
2,
);
await page
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
assert.equal(await button('Save template changes').isDisabled(), true);
await visit('/projects/1/templates/create?reset');
await page.locator('#name').fill('Unsaved work');
+72 -6
View File
@@ -43,10 +43,54 @@ const template = {
is_published: true,
source_project: project,
copy_areas: ['project_settings', 'ticket_types'],
configuration_snapshot: {
project_settings: { show_author: true, show_story_points: true },
board_columns: [],
ticket_statuses: [
{
configuration_key: '10000000-0000-4000-8000-000000000001',
name: 'Backlog',
position: 1,
board_column_key: null,
},
],
status_transitions: [
{
status_key: '10000000-0000-4000-8000-000000000001',
target_keys: [],
},
],
ticket_types: [
{
configuration_key: '20000000-0000-4000-8000-000000000001',
name: 'Task',
position: 1,
is_parent: false,
},
{
configuration_key: '20000000-0000-4000-8000-000000000002',
name: 'Release',
position: 2,
is_parent: true,
},
],
ticket_priorities: [
{
configuration_key: '30000000-0000-4000-8000-000000000001',
name: 'Medium',
position: 1,
},
],
ticket_fields: [],
ticket_link_types: [],
repository_connections: [],
notification_actions: [],
},
linked_project_count: 2,
};
const initial = () => ({
project,
initialConfiguration: structuredClone(template.configuration_snapshot),
copyAreaOptions: areas.map((value) => ({
value,
label: value,
@@ -100,7 +144,8 @@ if (serve) {
const url = new URL(request.url, 'http://localhost');
let component = url.pathname.endsWith('/edit')
? 'Edit'
: url.pathname.includes('/templates/create')
: url.pathname.includes('/templates/create') ||
url.pathname === '/project-templates/create'
? 'Create'
: url.pathname === '/project-templates'
? 'Index'
@@ -117,6 +162,9 @@ if (serve) {
if (url.searchParams.has('empty')) {
data.templates = [];
}
if (url.pathname === '/project-templates/create') {
data.project = null;
}
let errors = {};
if (request.method !== 'GET') {
let body = '';
@@ -259,12 +307,30 @@ if (serve) {
unlinked.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
assert.match(
await render('Edit', {
template: { ...template, source_project: null },
}),
/This template cannot be refreshed/,
const independent = await render('Edit', {
template: {
...template,
source_project: null,
linked_project_count: 0,
},
});
assert.match(independent, /Template configuration/);
assert.doesNotMatch(independent, /This template cannot be refreshed/);
assert.doesNotMatch(
independent.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
const empty = await render('Create', {
project: null,
template: undefined,
});
assert.match(empty, /New empty template/);
assert.match(
empty.match(/<input[^>]*id="name"[^>]*>/)?.[0] ?? '',
/\bvalue(?:="")?(?:\s|>)/,
);
assert.match(empty, /Template configuration/);
assert.match(list, /href="\/project-templates\/create"/);
assert.match(
await render(
'Index',