fix: make project template inheritance granular and safe #107

Merged
Twyco merged 1 commits from fix/kan-14 into epic/kan-3 2026-09-08 10:24:17 +00:00
50 changed files with 1504 additions and 624 deletions
Showing only changes of commit 3122e00ed8 - Show all commits
@@ -2,11 +2,19 @@
namespace App\Actions\ProjectBoard;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\TicketStatus;
use Illuminate\Support\Arr;
class UpdateTicketStatusTransitions
{
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
) {}
/**
* @param array{allowed_ticket_status_ids?: array<int, int|string>|null} $validated
*/
@@ -19,6 +27,14 @@ class UpdateTicketStatusTransitions
->all();
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
$this->projectTemplateOverrideManager->mark(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
$this->refreshProjectBaseTemplate->handle(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
return $ticketStatus->fresh();
}
@@ -2,11 +2,19 @@
namespace App\Actions\ProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use Illuminate\Support\Arr;
class UpdateProjectTicketMetadataSettings
{
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
) {}
/**
* @param array{show_author?: bool|null, show_story_points?: bool|null} $validated
*/
@@ -16,6 +24,14 @@ class UpdateProjectTicketMetadataSettings
'show_author' => Arr::get($validated, 'show_author', false),
'show_story_points' => Arr::get($validated, 'show_story_points', false),
]);
$this->projectTemplateOverrideManager->mark(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
$this->refreshProjectBaseTemplate->handle(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
return $project->fresh();
}
+8 -3
View File
@@ -18,6 +18,7 @@ class CreateProject
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly PermissionService $permissionService,
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
) {}
/**
@@ -39,18 +40,22 @@ class CreateProject
$project->forceFill([
'project_template_id' => $sourceTemplate->id,
])->save();
'template_overrides' => [],
])->saveQuietly();
} else {
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
])->save();
'template_overrides' => [],
])->saveQuietly();
}
$project = $project->fresh() ?? $project;
$this->initializeProjectTicketMetadata->handle($project);
$this->projectTemplateOverrideManager->withoutRecording(
fn () => $this->initializeProjectTicketMetadata->handle($project),
);
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) {
@@ -3,12 +3,18 @@
namespace App\Actions\Projects;
use App\Models\Project;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
class DuplicateProjectConfiguration
{
public const COPY_PROJECT_SETTINGS = 'project_settings';
public const COPY_BOARD_WORKFLOW = 'board_workflow';
public const COPY_BOARD_COLUMNS = 'board_columns';
public const COPY_TICKET_STATUSES = 'ticket_statuses';
public const COPY_STATUS_TRANSITIONS = 'status_transitions';
public const COPY_TICKET_TYPES = 'ticket_types';
@@ -22,14 +28,20 @@ class DuplicateProjectConfiguration
public const COPY_NOTIFICATION_ACTIONS = 'notification_actions';
/**
* @return list<string>
*/
public const COPY_BOARD_WORKFLOW = 'board_workflow';
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
) {}
/** @return list<string> */
public static function copyableAreas(): array
{
return [
self::COPY_PROJECT_SETTINGS,
self::COPY_BOARD_WORKFLOW,
self::COPY_BOARD_COLUMNS,
self::COPY_TICKET_STATUSES,
self::COPY_STATUS_TRANSITIONS,
self::COPY_TICKET_TYPES,
self::COPY_TICKET_PRIORITIES,
self::COPY_TICKET_FIELDS,
@@ -39,68 +51,67 @@ class DuplicateProjectConfiguration
];
}
/**
* @return list<array{value: string, label: string, description: string}>
*/
/** @return list<string> */
public static function acceptedCopyAreas(): array
{
return [...self::copyableAreas(), self::COPY_BOARD_WORKFLOW];
}
/** @return list<array{value: string, label: string, description: string}> */
public static function copyAreaOptions(): array
{
return [
[
'value' => self::COPY_PROJECT_SETTINGS,
'label' => 'Project settings',
'description' => 'Copy general project preferences like story points and author visibility.',
],
[
'value' => self::COPY_BOARD_WORKFLOW,
'label' => 'Board workflow',
'description' => 'Copy board columns, ticket statuses, and allowed status transitions.',
],
[
'value' => self::COPY_TICKET_TYPES,
'label' => 'Ticket types',
'description' => 'Copy available ticket types, including parent-ticket types.',
],
[
'value' => self::COPY_TICKET_PRIORITIES,
'label' => 'Ticket priorities',
'description' => 'Copy the project-specific ticket priorities.',
],
[
'value' => self::COPY_TICKET_FIELDS,
'label' => 'Custom ticket fields',
'description' => 'Copy custom ticket field definitions without any ticket values.',
],
[
'value' => self::COPY_TICKET_LINK_TYPES,
'label' => 'Ticket link types',
'description' => 'Copy available relationship labels between tickets.',
],
[
'value' => self::COPY_REPOSITORY_CONNECTIONS,
'label' => 'Repository connections',
'description' => 'Copy linked repositories without changing the new project identity.',
],
[
'value' => self::COPY_NOTIFICATION_ACTIONS,
'label' => 'Notification actions',
'description' => 'Copy notification rules and delivery settings.',
],
['value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'General project preferences like story points and author visibility.'],
['value' => self::COPY_BOARD_COLUMNS, 'label' => 'Board columns', 'description' => 'Columns and their order on the project board.'],
['value' => self::COPY_TICKET_STATUSES, 'label' => 'Ticket statuses', 'description' => 'Statuses and their assignment to board columns.'],
['value' => self::COPY_STATUS_TRANSITIONS, 'label' => 'Status transitions', 'description' => 'Allowed moves between ticket statuses.'],
['value' => self::COPY_TICKET_TYPES, 'label' => 'Ticket types', 'description' => 'Available ticket types, including parent types.'],
['value' => self::COPY_TICKET_PRIORITIES, 'label' => 'Ticket priorities', 'description' => 'Available ticket priorities.'],
['value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Custom field definitions without ticket values.'],
['value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Relationship labels between tickets.'],
['value' => self::COPY_REPOSITORY_CONNECTIONS, 'label' => 'Repository connections', 'description' => 'Repositories linked to the project.'],
['value' => self::COPY_NOTIFICATION_ACTIONS, 'label' => 'Notification actions', 'description' => 'Notification rules and delivery settings.'],
];
}
/**
* @param list<string> $copyAreas
* @return list<string>
*/
/** @param list<string> $copyAreas @return list<string> */
public function normalizeCopyAreas(array $copyAreas): array
{
if (in_array(self::COPY_BOARD_WORKFLOW, $copyAreas, true)) {
$copyAreas = [
...array_diff($copyAreas, [self::COPY_BOARD_WORKFLOW]),
self::COPY_BOARD_COLUMNS,
self::COPY_TICKET_STATUSES,
self::COPY_STATUS_TRANSITIONS,
];
}
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
}
/**
* @param list<string> $copyAreas
* @return array<string, mixed>
*/
/** @param array<string, mixed> $configuration @return array<string, mixed> */
public function normalizeConfiguration(array $configuration): array
{
$workflow = $configuration[self::COPY_BOARD_WORKFLOW] ?? null;
if (is_array($workflow)) {
$statuses = $workflow['ticket_statuses'] ?? [];
$configuration[self::COPY_BOARD_COLUMNS] ??= $workflow['board_columns'] ?? [];
$configuration[self::COPY_TICKET_STATUSES] ??= array_map(
static fn (array $status): array => array_diff_key($status, ['allowed_target_indexes' => true]),
$statuses,
);
$configuration[self::COPY_STATUS_TRANSITIONS] ??= array_map(
static fn (array $status): array => $status['allowed_target_indexes'] ?? [],
$statuses,
);
unset($configuration[self::COPY_BOARD_WORKFLOW]);
}
return $configuration;
}
/** @param list<string> $copyAreas @return array<string, mixed> */
public function export(Project $sourceProject, array $copyAreas): array
{
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
@@ -109,17 +120,10 @@ class DuplicateProjectConfiguration
return [];
}
$sourceProject->loadMissing([
'boardColumns',
'notificationActions',
'repositoryConnections',
'ticketFieldDefinitions',
'ticketLinkTypes',
'ticketPriorities',
'ticketStatuses.allowedTargetStatuses',
'ticketTypes',
$sourceProject->load([
'boardColumns', 'notificationActions', 'repositoryConnections', 'ticketFieldDefinitions',
'ticketLinkTypes', 'ticketPriorities', 'ticketStatuses.allowedTargetStatuses', 'ticketTypes',
]);
$configuration = [];
if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) {
@@ -129,120 +133,65 @@ class DuplicateProjectConfiguration
];
}
if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) {
$boardColumnIndexes = [];
$boardColumns = [];
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column, int $index): array => [$column->id => $index],
);
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status, int $index): array => [$status->id => $index],
);
foreach ($sourceProject->boardColumns as $index => $boardColumn) {
$boardColumnIndexes[$boardColumn->id] = $index;
$boardColumns[] = [
'name' => $boardColumn->name,
'position' => $boardColumn->position,
];
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
)->all();
}
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => [
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
'name' => $status->name,
'position' => $status->position,
],
)->all();
}
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?int => $statusIndexes->get($id),
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
)->all();
}
$simpleAreas = [
self::COPY_TICKET_TYPES => fn ($item): array => ['name' => $item->name, 'position' => $item->position, 'is_parent' => $item->is_parent],
self::COPY_TICKET_PRIORITIES => fn ($item): array => ['name' => $item->name, 'position' => $item->position],
self::COPY_TICKET_FIELDS => fn ($item): array => ['field_type' => $item->field_type, 'label' => $item->label, 'position' => $item->position],
self::COPY_TICKET_LINK_TYPES => fn ($item): array => ['label' => $item->label, 'counterpart_label' => $item->counterpart_label, 'position' => $item->position],
self::COPY_REPOSITORY_CONNECTIONS => fn ($item): array => [
'provider' => $item->provider->value, 'base_url' => $item->base_url,
'provider_repository_id' => $item->provider_repository_id, 'owner_name' => $item->owner_name,
'repository_name' => $item->repository_name, 'repository_full_name' => $item->repository_full_name,
'repository_url' => $item->repository_url,
],
self::COPY_NOTIFICATION_ACTIONS => fn ($item): array => [
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
'filters' => $item->filters ?? [],
],
];
$relations = [
self::COPY_TICKET_TYPES => 'ticketTypes', self::COPY_TICKET_PRIORITIES => 'ticketPriorities',
self::COPY_TICKET_FIELDS => 'ticketFieldDefinitions', self::COPY_TICKET_LINK_TYPES => 'ticketLinkTypes',
self::COPY_REPOSITORY_CONNECTIONS => 'repositoryConnections', self::COPY_NOTIFICATION_ACTIONS => 'notificationActions',
];
foreach ($simpleAreas as $area => $mapper) {
if (in_array($area, $selectedAreas, true)) {
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
}
$ticketStatusIndexes = [];
$ticketStatuses = [];
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
$ticketStatusIndexes[$ticketStatus->id] = $index;
$ticketStatuses[] = [
'board_column_index' => $ticketStatus->board_column_id === null
? null
: ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null),
'name' => $ticketStatus->name,
'position' => $ticketStatus->position,
'allowed_target_indexes' => [],
];
}
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
$ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses
->pluck('id')
->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null)
->filter(static fn (?int $statusIndex): bool => $statusIndex !== null)
->values()
->all();
}
$configuration[self::COPY_BOARD_WORKFLOW] = [
'board_columns' => $boardColumns,
'ticket_statuses' => $ticketStatuses,
];
}
if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes
->map(fn ($ticketType): array => [
'name' => $ticketType->name,
'position' => $ticketType->position,
'is_parent' => $ticketType->is_parent,
])
->values()
->all();
}
if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities
->map(fn ($ticketPriority): array => [
'name' => $ticketPriority->name,
'position' => $ticketPriority->position,
])
->values()
->all();
}
if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions
->map(fn ($ticketFieldDefinition): array => [
'field_type' => $ticketFieldDefinition->field_type,
'label' => $ticketFieldDefinition->label,
'position' => $ticketFieldDefinition->position,
])
->values()
->all();
}
if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes
->map(fn ($ticketLinkType): array => [
'label' => $ticketLinkType->label,
'counterpart_label' => $ticketLinkType->counterpart_label,
'position' => $ticketLinkType->position,
])
->values()
->all();
}
if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) {
$configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections
->map(fn ($repositoryConnection): array => [
'provider' => $repositoryConnection->provider->value,
'base_url' => $repositoryConnection->base_url,
'provider_repository_id' => $repositoryConnection->provider_repository_id,
'owner_name' => $repositoryConnection->owner_name,
'repository_name' => $repositoryConnection->repository_name,
'repository_full_name' => $repositoryConnection->repository_full_name,
'repository_url' => $repositoryConnection->repository_url,
])
->values()
->all();
}
if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) {
$configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions
->map(fn ($notificationAction): array => [
'name' => $notificationAction->name,
'enabled' => $notificationAction->enabled,
'event_type' => $notificationAction->event_type->value,
'channel' => $notificationAction->channel->value,
'channel_configuration' => $notificationAction->channel_configuration ?? [],
'message_template' => $notificationAction->message_template,
'delivery_url' => $notificationAction->delivery_url,
'filters' => $notificationAction->filters ?? [],
])
->values()
->all();
}
return $configuration;
@@ -250,151 +199,141 @@ class DuplicateProjectConfiguration
/**
* @param array<string, mixed> $configuration
* @param array<int|string, int|string> $statusReplacements
* @return list<string> areas which retained referenced local values
*/
public function apply(Project $targetProject, array $configuration): void
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
{
if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) {
$projectSettings = $configuration[self::COPY_PROJECT_SETTINGS];
$configuration = $this->normalizeConfiguration($configuration);
$targetProject->forceFill([
'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author),
'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points),
])->save();
}
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
$blockedAreas = [];
if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) {
$targetProject->ticketStatuses()->delete();
$targetProject->boardColumns()->delete();
$boardColumnIds = [];
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) {
$newBoardColumn = $targetProject->boardColumns()->create([
'name' => $boardColumn['name'],
'position' => $boardColumn['position'],
]);
$boardColumnIds[] = $newBoardColumn->id;
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
}
$ticketStatusIds = [];
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) {
$boardColumnIndex = $ticketStatus['board_column_index'] ?? null;
$newTicketStatus = $targetProject->ticketStatuses()->create([
'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null),
'name' => $ticketStatus['name'],
'position' => $ticketStatus['position'],
]);
$ticketStatusIds[] = $newTicketStatus->id;
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
$blocked = $this->syncModels(
$targetProject->boardColumns()->get(),
$configuration[self::COPY_BOARD_COLUMNS],
fn (array $item) => $targetProject->boardColumns()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
);
if ($blocked) {
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
}
}
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) {
$newTicketStatusId = $ticketStatusIds[$index] ?? null;
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
$columns = $targetProject->boardColumns()->get()->values();
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
$columnIndex = $item['board_column_index'] ?? null;
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
unset($item['board_column_index']);
if ($newTicketStatusId === null) {
return $item;
})->all();
$blocked = $this->syncModels(
$targetProject->ticketStatuses()->get(),
$items,
fn (array $item) => $targetProject->ticketStatuses()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
function (Model $status) use ($statusReplacements): bool {
if (! $status->tickets()->exists()) {
return true;
}
$replacementId = $statusReplacements[$status->id] ?? null;
if ($replacementId === null) {
return false;
}
$status->tickets()->update(['ticket_status_id' => $replacementId]);
return true;
},
);
if ($blocked) {
$blockedAreas[] = self::COPY_TICKET_STATUSES;
}
}
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
$statuses = $targetProject->ticketStatuses()->get()->values();
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
$status = $statuses->get($index);
if ($status !== null) {
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
}
}
}
$relations = [
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
];
foreach ($relations as $area => [$relation, $canDelete]) {
if (! isset($configuration[$area])) {
continue;
}
$targetProject->ticketStatuses()->findOrFail($newTicketStatusId)
->allowedTargetStatuses()
->sync(
collect($ticketStatus['allowed_target_indexes'] ?? [])
->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null)
->filter(static fn (?int $statusId): bool => $statusId !== null)
->values()
->all(),
);
$blocked = $this->syncModels(
$targetProject->{$relation}()->get(),
$configuration[$area],
fn (array $item) => $targetProject->{$relation}()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
$canDelete,
);
if ($blocked) {
$blockedAreas[] = $area;
}
}
foreach ($targetProject->configurationRelations() as $relation) {
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
}
return array_values(array_unique($blockedAreas));
});
foreach ($blockedAreas as $area) {
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
}
if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) {
$targetProject->ticketTypes()->delete();
foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) {
$targetProject->ticketTypes()->create([
'name' => $ticketType['name'],
'position' => $ticketType['position'],
'is_parent' => $ticketType['is_parent'],
]);
}
}
if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) {
$targetProject->ticketPriorities()->delete();
foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) {
$targetProject->ticketPriorities()->create([
'name' => $ticketPriority['name'],
'position' => $ticketPriority['position'],
]);
}
}
if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) {
$targetProject->ticketFieldDefinitions()->delete();
foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) {
$targetProject->ticketFieldDefinitions()->create([
'field_type' => $ticketFieldDefinition['field_type'],
'label' => $ticketFieldDefinition['label'],
'position' => $ticketFieldDefinition['position'],
]);
}
}
if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) {
$targetProject->ticketLinkTypes()->delete();
foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) {
$targetProject->ticketLinkTypes()->create([
'label' => $ticketLinkType['label'],
'counterpart_label' => $ticketLinkType['counterpart_label'],
'position' => $ticketLinkType['position'],
]);
}
}
if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) {
$targetProject->repositoryConnections()->delete();
foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) {
$targetProject->repositoryConnections()->create([
'provider' => $repositoryConnection['provider'],
'base_url' => $repositoryConnection['base_url'],
'provider_repository_id' => $repositoryConnection['provider_repository_id'],
'owner_name' => $repositoryConnection['owner_name'],
'repository_name' => $repositoryConnection['repository_name'],
'repository_full_name' => $repositoryConnection['repository_full_name'],
'repository_url' => $repositoryConnection['repository_url'],
]);
}
}
if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) {
$targetProject->notificationActions()->delete();
foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) {
$targetProject->notificationActions()->create([
'name' => $notificationAction['name'],
'enabled' => $notificationAction['enabled'],
'event_type' => $notificationAction['event_type'],
'channel' => $notificationAction['channel'],
'channel_configuration' => $notificationAction['channel_configuration'] ?? [],
'message_template' => $notificationAction['message_template'] ?? null,
'delivery_url' => $notificationAction['delivery_url'],
'filters' => $notificationAction['filters'] ?? [],
]);
}
}
return $blockedAreas;
}
/**
* @param list<string> $copyAreas
*/
/** @param list<string> $copyAreas */
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
{
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
}
/**
* @param EloquentCollection<int, Model> $models
* @param array<int, array<string, mixed>> $items
*/
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
{
foreach ($items as $index => $item) {
$model = $models->get($index);
$model === null ? $create($item) : $update($model, $item);
}
$blocked = false;
foreach ($models->slice(count($items)) as $model) {
if ($canDelete !== null && ! $canDelete($model)) {
$blocked = true;
continue;
}
$model->deleteQuietly();
}
return $blocked;
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
class ProjectTemplateOverrideManager
{
private static int $suppressionDepth = 0;
public function mark(Project $project, string $area): void
{
if (self::$suppressionDepth > 0) {
return;
}
$project->loadMissing('projectTemplate');
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
return;
}
$overrides = array_values(array_unique([
...($project->template_overrides ?? []),
$area,
]));
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
}
/**
* @param list<string> $areas
*/
public function clear(Project $project, array $areas): void
{
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
}
public function withoutRecording(callable $callback): mixed
{
self::$suppressionDepth++;
try {
return $callback();
} finally {
self::$suppressionDepth--;
}
}
public function isRecording(): bool
{
return self::$suppressionDepth === 0;
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
class RefreshProjectBaseTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function handle(Project $project, string $area): void
{
$project->loadMissing('projectTemplate');
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
return;
}
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
$project->projectTemplate->configuration_snapshot ?? [],
);
$project->projectTemplate->forceFill([
'configuration_snapshot' => array_replace(
$snapshot,
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
),
])->saveQuietly();
}
}
@@ -30,10 +30,16 @@ class ResolveProjectTemplateState
? DuplicateProjectConfiguration::copyableAreas()
: $this->duplicateProjectConfiguration->normalizeCopyAreas($areas);
$templateConfiguration = collect($projectTemplate->configuration_snapshot ?? [])
$normalizedTemplateConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$projectTemplate->configuration_snapshot ?? [],
);
$templateConfiguration = collect($normalizedTemplateConfiguration)
->only($selectedAreas)
->all();
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
$persistedOverrides = $projectTemplate->is_published
? array_values(array_intersect($project->template_overrides ?? [], $selectedAreas))
: [];
$overriddenAreas = [];
$inheritedAreas = [];
@@ -41,13 +47,12 @@ class ResolveProjectTemplateState
foreach ($selectedAreas as $area) {
$label = $this->labelForArea($area);
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
$hasCurrentValue = array_key_exists($area, $currentConfiguration);
if (! $hasTemplateValue && ! $hasCurrentValue) {
if (! $hasTemplateValue) {
continue;
}
if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) {
if (! in_array($area, $persistedOverrides, true)) {
$inheritedAreas[] = [
'label' => $label,
'value' => $area,
@@ -72,6 +77,7 @@ class ResolveProjectTemplateState
'inheritsFromTemplate' => $projectTemplate->is_published,
'overriddenAreas' => $overriddenAreas,
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
'resetRequirements' => $this->resetRequirements($project, $templateConfiguration, $persistedOverrides),
'templateConfiguration' => $templateConfiguration,
'templateId' => $projectTemplate->id,
'templateName' => $projectTemplate->name,
@@ -79,6 +85,44 @@ class ResolveProjectTemplateState
];
}
/**
* @param array<string, mixed> $templateConfiguration
* @param list<string> $persistedOverrides
* @return array<string, mixed>
*/
private function resetRequirements(Project $project, array $templateConfiguration, array $persistedOverrides): array
{
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $persistedOverrides, true)) {
return [];
}
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
if (! is_array($templateStatuses)) {
return [];
}
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$replacementOptions = $statuses->take(count($templateStatuses))->map(
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
)->all();
$removedStatuses = $statuses->slice(count($templateStatuses))
->filter(static fn ($status): bool => $status->tickets_count > 0)
->map(static fn ($status): array => [
'id' => $status->id,
'name' => $status->name,
'ticketCount' => $status->tickets_count,
])
->values()
->all();
return $removedStatuses === [] ? [] : [
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
'removedStatuses' => $removedStatuses,
'replacementOptions' => $replacementOptions,
],
];
}
private function labelForArea(string $area): string
{
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
+46 -24
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
@@ -10,6 +11,7 @@ class UpdateProjectTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
) {}
/**
@@ -17,48 +19,68 @@ class UpdateProjectTemplate
*/
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
{
$projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate');
$projectTemplate->loadMissing('sourceProject');
$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 ?? [];
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$projectTemplate->configuration_snapshot ?? [],
);
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
$affectedAreas = array_values(array_unique([
...array_keys($previousConfiguration),
...array_keys($nextConfiguration),
]));
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
->values()
->all();
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
$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 = [];
Project::query()
->where('project_template_id', $projectTemplate->id)
->orderBy('id')
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
foreach ($linkedProjects as $linkedProject) {
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
$overrides = $linkedProject->template_overrides ?? [];
$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];
foreach ($affectedAreas as $area) {
if (in_array($area, $overrides, true)) {
continue;
}
if ($isNewTemplateArea || $matchesPreviousTemplate) {
$replacementConfiguration[$area] = $configuration;
if (! array_key_exists($area, $nextConfiguration)) {
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
continue;
}
if (! array_key_exists($area, $previousConfiguration)
&& array_key_exists($area, $currentConfiguration)
&& $currentConfiguration[$area] !== []) {
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
continue;
}
$replacementConfiguration[$area] = $nextConfiguration[$area];
}
if ($replacementConfiguration !== []) {
$this->duplicateProjectConfiguration->apply($linkedProject, $replacementConfiguration);
}
}
}
if ($replacementConfiguration === []) {
continue;
}
$this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration);
}
});
});
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
@@ -10,23 +10,39 @@ class UpdateProjectTemplateLink
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
) {}
public function switch(Project $project, ProjectTemplate $template): void
{
DB::transaction(function () use ($project, $template): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
);
$localAreas = array_values(array_diff(
DuplicateProjectConfiguration::copyableAreas(),
array_keys($configuration),
));
$project->forceFill([
'project_template_id' => $template->id,
])->save();
'template_overrides' => $localAreas,
])->saveQuietly();
$this->duplicateProjectConfiguration->apply(
$blockedAreas = $this->duplicateProjectConfiguration->apply($project, $configuration);
$this->projectTemplateOverrideManager->clear(
$project->fresh(),
$template->configuration_snapshot ?? [],
array_values(array_diff(array_keys($configuration), $blockedAreas)),
);
});
}
public function reset(Project $project): void
/**
* @param list<string> $areas
* @param array<int|string, int|string> $statusReplacements
*/
public function reset(Project $project, array $areas, array $statusReplacements = []): void
{
$template = $project->projectTemplate;
@@ -34,10 +50,19 @@ class UpdateProjectTemplateLink
return;
}
DB::transaction(function () use ($project, $template): void {
$this->duplicateProjectConfiguration->apply(
$project->fresh(),
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
))->only($areas)->all();
$blockedAreas = $this->duplicateProjectConfiguration->apply(
$project,
$configuration,
$statusReplacements,
);
$this->projectTemplateOverrideManager->clear(
$project->fresh(),
array_values(array_diff(array_keys($configuration), $blockedAreas)),
);
});
}
+1 -5
View File
@@ -47,11 +47,7 @@ class CreateProjectTicket
$nextSequenceNumber = (int) Ticket::query()
->where('project_id', $project->id)
->max('sequence_number') + 1;
$ticketStatusQuery = TicketStatus::query()
->where(
$project->project_template_id === null ? 'project_id' : 'project_template_id',
$project->project_template_id ?? $project->id,
);
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
+20 -5
View File
@@ -37,6 +37,7 @@ class ProjectController extends Controller
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
private readonly CreateProject $createProject,
private readonly DeleteProject $deleteProject,
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ListVisibleProjects $listVisibleProjects,
private readonly PermissionService $permissionService,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
@@ -135,9 +136,10 @@ class ProjectController extends Controller
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
{
$validated = $request->validated();
$template = ProjectTemplate::query()
->published()
->findOrFail($request->integer('source_template_id'));
->findOrFail((int) Arr::get($validated, 'source_template_id'));
$this->updateProjectTemplateLink->switch($project, $template);
@@ -147,16 +149,29 @@ class ProjectController extends Controller
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
{
$request->validated();
$validated = $request->validated();
$template = $project->projectTemplate;
abort_if($template === null, 422, 'This project has no linked template to reset.');
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
);
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
$statusReplacements = Arr::get($validated, 'status_replacements', []);
return to_route('projects.settings.general', $project)
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
$this->updateProjectTemplateLink->reset(
$project->load('projectTemplate'),
$areas,
$statusReplacements,
);
$status = Arr::has($validated, 'areas')
? "Selected settings were reset to the {$template->name} template."
: "{$project->name} was reset to the {$template->name} template baseline.";
return back()->with('status', $status);
}
public function access(Request $request, Project $project): Response
@@ -24,16 +24,13 @@ class MoveProjectTicketRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -29,16 +29,13 @@ class DestroyProjectTicketTypeRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'replacement_ticket_type_id' => [
'nullable',
'integer',
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -29,16 +29,13 @@ class MoveProjectTicketRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
@@ -2,10 +2,13 @@
namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class ResetProjectTemplateRequest extends FormRequest
{
@@ -23,11 +26,71 @@ class ResetProjectTemplateRequest extends FormRequest
$project = $this->route('project');
return [
'areas' => ['sometimes', 'array', 'min:1'],
'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
'confirmation_key' => [
'required',
'required_without:areas',
'string',
Rule::in([$project->key]),
],
'status_replacements' => ['sometimes', 'array'],
'status_replacements.*' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
],
];
}
/** @return array<int, callable(Validator): void> */
public function after(): array
{
return [
function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
/** @var Project $project */
$project = $this->route('project');
$validated = $validator->validated();
$areas = array_values(Arr::get(
$validated,
'areas',
DuplicateProjectConfiguration::copyableAreas(),
));
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
return;
}
$project->loadMissing('projectTemplate');
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
$project->projectTemplate?->configuration_snapshot ?? [],
);
$templateStatuses = Arr::get(
$configuration,
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
[],
);
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
$replacements = Arr::get($validated, 'status_replacements', []);
foreach ($statuses->slice(count($templateStatuses)) as $status) {
if ($status->tickets_count === 0) {
continue;
}
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
if (! $allowedReplacementIds->contains($replacementId)) {
$validator->errors()->add(
"status_replacements.{$status->id}",
"Choose a replacement status for {$status->name}.",
);
}
}
},
];
}
@@ -38,6 +101,7 @@ class ResetProjectTemplateRequest extends FormRequest
{
return [
'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.',
'areas.*.in' => 'Choose a valid template area to reset.',
];
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ class StoreProjectRequest extends FormRequest
'copy_areas' => ['nullable', 'array'],
'copy_areas.*' => [
'string',
Rule::in(DuplicateProjectConfiguration::copyableAreas()),
Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()),
],
];
}
@@ -33,7 +33,7 @@ class StoreProjectTemplateRequest extends FormRequest
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
];
}
}
@@ -26,9 +26,6 @@ class StoreProjectTicketBranchRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'branch_name' => ['required', 'string', 'max:255'],
@@ -36,7 +33,7 @@ class StoreProjectTicketBranchRequest extends FormRequest
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -48,9 +48,6 @@ class StoreProjectTicketRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'acceptance_criteria' => ['nullable', 'array'],
@@ -70,21 +67,21 @@ class StoreProjectTicketRequest extends FormRequest
'required',
'integer',
Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'ticket_type_id' => [
'required',
'integer',
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'story_points' => ['nullable', 'integer', 'min:0'],
+1 -4
View File
@@ -28,9 +28,6 @@ class StoreTicketLinkRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'linked_ticket_id' => [
@@ -42,7 +39,7 @@ class StoreTicketLinkRequest extends FormRequest
'required',
'integer',
Rule::exists('ticket_link_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -26,16 +26,13 @@ class StoreTicketStatusRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'board_column_id' => [
'nullable',
'integer',
Rule::exists('board_columns', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'name' => ['required', 'string', 'max:255'],
@@ -27,7 +27,7 @@ class UpdateProjectTemplateRequest extends FormRequest
return [
'name' => ['required', 'string', 'max:255'],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [
Rule::requiredIf($requiresImpactConfirmation),
'accepted',
@@ -51,9 +51,6 @@ class UpdateProjectTicketRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'acceptance_criteria' => ['nullable', 'array'],
@@ -72,7 +69,7 @@ class UpdateProjectTicketRequest extends FormRequest
'required',
'integer',
Rule::exists('ticket_field_definitions', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'field_values.*.value_number' => ['nullable', 'numeric'],
@@ -87,14 +84,14 @@ class UpdateProjectTicketRequest extends FormRequest
'required',
'integer',
Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'ticket_type_id' => [
'required',
'integer',
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
'title' => ['required', 'string', 'max:255'],
@@ -150,7 +147,7 @@ class UpdateProjectTicketRequest extends FormRequest
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
$requestedTicketType = TicketType::query()
->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id)
->where('project_id', $project->id)
->find($requestedTicketTypeId);
if (
@@ -29,16 +29,13 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'allowed_ticket_status_ids' => ['nullable', 'array'],
'allowed_ticket_status_ids.*' => [
'integer',
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
@@ -26,9 +26,6 @@ class ValidateProjectTicketBranchRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'branch_name' => ['required', 'string', 'max:255'],
@@ -36,7 +33,7 @@ class ValidateProjectTicketBranchRequest extends FormRequest
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
fn ($query) => $query->where('project_id', $project->id),
),
],
];
+1
View File
@@ -37,6 +37,7 @@ class ProjectResource extends JsonResource
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
'reset_requirements' => Arr::get($templateState, 'resetRequirements', []),
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
'template_id' => Arr::get($templateState, 'templateId'),
'template_name' => Arr::get($templateState, 'templateName'),
@@ -2,6 +2,7 @@
namespace App\Http\Resources;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\ProjectTemplate;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,6 +18,8 @@ class ProjectTemplateResource extends JsonResource
public function toArray(Request $request): array
{
if ($this->resource instanceof ProjectTemplate) {
$configuration = app(DuplicateProjectConfiguration::class);
return [
'id' => $this->id,
'project_id' => $this->project_id,
@@ -27,8 +30,10 @@ class ProjectTemplateResource extends JsonResource
'name' => $this->name,
'slug' => $this->slug,
'is_published' => $this->is_published,
'copy_areas' => $this->copy_areas ?? [],
'configuration_snapshot' => $this->configuration_snapshot ?? [],
'copy_areas' => $configuration->normalizeCopyAreas($this->copy_areas ?? []),
'configuration_snapshot' => $configuration->normalizeConfiguration(
$this->configuration_snapshot ?? [],
),
'linked_project_count' => $this->whenCounted('linkedProjects'),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
+17 -1
View File
@@ -13,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points'])]
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points', 'template_overrides'])]
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
@@ -24,6 +24,7 @@ class Project extends Model
return [
'show_author' => 'boolean',
'show_story_points' => 'boolean',
'template_overrides' => 'array',
];
}
@@ -108,6 +109,21 @@ class Project extends Model
->orderBy('id');
}
/** @return list<string> */
public function configurationRelations(): array
{
return [
'boardColumns',
'notificationActions',
'repositoryConnections',
'ticketFieldDefinitions',
'ticketLinkTypes',
'ticketPriorities',
'ticketStatuses',
'ticketTypes',
];
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class)
+7
View File
@@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class TicketPriority extends Model
{
/** @use HasFactory<TicketPriorityFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
@@ -27,4 +29,9 @@ class TicketPriority extends Model
{
return $this->belongsTo(Project::class);
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class);
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Observers;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\BoardColumn;
use App\Models\NotificationAction;
use App\Models\RepositoryConnection;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use Illuminate\Database\Eloquent\Model;
class ProjectConfigurationObserver
{
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
) {}
public function saved(Model $model): void
{
$this->markOverride($model);
}
public function deleted(Model $model): void
{
$this->markOverride($model);
}
private function markOverride(Model $model): void
{
if (! $this->projectTemplateOverrideManager->isRecording()) {
return;
}
$project = $model->project()->first();
if ($project === null) {
return;
}
$area = match (true) {
$model instanceof BoardColumn => DuplicateProjectConfiguration::COPY_BOARD_COLUMNS,
$model instanceof TicketStatus => DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
$model instanceof TicketType => DuplicateProjectConfiguration::COPY_TICKET_TYPES,
$model instanceof TicketPriority => DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
$model instanceof TicketFieldDefinition => DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
$model instanceof TicketLinkType => DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
$model instanceof RepositoryConnection => DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
$model instanceof NotificationAction => DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
default => null,
};
if ($area === null) {
return;
}
$this->refreshProjectBaseTemplate->handle($project, $area);
$this->projectTemplateOverrideManager->mark($project, $area);
}
}
+21
View File
@@ -8,9 +8,18 @@ use App\Actions\Notifications\TicketCreated;
use App\Actions\Notifications\TicketStatusChanged;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\BoardColumn;
use App\Models\NotificationAction;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\Team;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User;
use App\Observers\ProjectConfigurationObserver;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Date;
@@ -36,6 +45,18 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
foreach ([
BoardColumn::class,
NotificationAction::class,
RepositoryConnection::class,
TicketFieldDefinition::class,
TicketLinkType::class,
TicketPriority::class,
TicketStatus::class,
TicketType::class,
] as $model) {
$model::observe(ProjectConfigurationObserver::class);
}
if ($this->app->environment() !== 'local') {
URL::forceScheme('https');
+1
View File
@@ -23,6 +23,7 @@ class ProjectFactory extends Factory
'key' => Str::upper(fake()->unique()->lexify('?????')),
'show_author' => true,
'show_story_points' => true,
'template_overrides' => [],
];
}
}
@@ -44,13 +44,17 @@ return new class extends Migration
'projects.name',
'projects.project_template_id',
'project_templates.project_id as template_project_id',
'project_templates.is_published as template_is_published',
])
->get();
foreach ($projects as $project) {
$projectTemplateId = $project->project_template_id;
if ($projectTemplateId === null || (int) ($project->template_project_id ?? 0) !== (int) $project->id) {
if ($projectTemplateId === null || (
! (bool) $project->template_is_published
&& (int) ($project->template_project_id ?? 0) !== (int) $project->id
)) {
$projectTemplateId = DB::table('project_templates')->insertGetId([
'project_id' => $project->id,
'source_project_id' => $project->id,
@@ -88,7 +92,7 @@ return new class extends Migration
$projectModel = Project::query()->find($project->id);
if ($projectModel !== null) {
if ($projectModel !== null && ! (bool) $project->template_is_published) {
DB::table('project_templates')
->where('id', $projectTemplateId)
->update([
@@ -0,0 +1,63 @@
<?php
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->json('template_overrides')->default('[]')->after('project_template_id');
});
$configuration = app(DuplicateProjectConfiguration::class);
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
$template->forceFill([
'copy_areas' => $configuration->normalizeCopyAreas($template->copy_areas ?? []),
'configuration_snapshot' => $configuration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
),
])->saveQuietly();
});
Project::query()
->whereHas('projectTemplate', fn ($query) => $query->where('is_published', true))
->with('projectTemplate')
->eachById(function (Project $project) use ($configuration): void {
$templateConfiguration = $configuration->normalizeConfiguration(
$project->projectTemplate?->configuration_snapshot ?? [],
);
$areas = array_values(array_unique([
...array_keys($templateConfiguration),
...DuplicateProjectConfiguration::copyableAreas(),
]));
$currentConfiguration = $configuration->export($project, $areas);
$overrides = collect($areas)
->filter(fn (string $area): bool => ! array_key_exists($area, $templateConfiguration)
|| ($currentConfiguration[$area] ?? null) !== $templateConfiguration[$area])
->values()
->all();
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->dropColumn('template_overrides');
});
}
};
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
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 { Label } from '@/components/ui/label';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
import { computed } from 'vue';
const props = defineProps<{
areas: string[];
projectId: number;
templateState?: ProjectTemplateState | null;
}>();
const form = useForm<{
areas: string[];
status_replacements: Record<string, number | undefined>;
}>({
areas: [],
status_replacements: {},
});
const activeArea = computed(() => form.areas[0]);
const inheritedAreas = computed(
() =>
props.templateState?.inheritedAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const overriddenAreas = computed(
() =>
props.templateState?.overriddenAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const statusRequirement = computed(
() => props.templateState?.resetRequirements?.ticket_statuses,
);
const canReset = (area: ProjectTemplateStateArea): boolean => {
if (area.value !== 'ticket_statuses') {
return true;
}
return (
statusRequirement.value?.removedStatuses.every(
(status) =>
form.status_replacements[String(status.id)] !== undefined,
) ?? true
);
};
const resetArea = (area: ProjectTemplateStateArea): void => {
if (!canReset(area)) {
return;
}
form.areas = [area.value];
form.post(ProjectController.resetTemplate.url(props.projectId), {
preserveScroll: true,
onSuccess: () => form.reset(),
});
};
</script>
<template>
<Card
v-if="
templateState?.inheritsFromTemplate &&
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
"
>
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Every setting area inherits independently. Local changes stay
unchanged when the template is updated until that area is reset.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="area in inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
</div>
<div
v-for="area in overriddenAreas"
:key="`overridden-${area.value}`"
class="space-y-3 rounded-lg border p-4"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<Badge>{{ area.label }} · template overridden</Badge>
<Button
type="button"
size="sm"
variant="outline"
:disabled="form.processing || !canReset(area)"
@click="resetArea(area)"
>
{{
activeArea === area.value && form.processing
? 'Resetting…'
: 'Reset to template'
}}
</Button>
</div>
<div
v-if="area.value === 'ticket_statuses' && statusRequirement"
class="space-y-3"
>
<p class="text-muted-foreground text-sm">
Choose where tickets in local statuses should move
before those statuses are removed.
</p>
<div
v-for="status in statusRequirement.removedStatuses"
:key="status.id"
class="grid gap-2 sm:grid-cols-2 sm:items-center"
>
<Label :for="`replacement-${status.id}`">
{{ status.name }} ({{ status.ticketCount }} tickets)
</Label>
<div class="grid gap-1">
<select
:id="`replacement-${status.id}`"
v-model="
form.status_replacements[String(status.id)]
"
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 focus-visible:ring-[3px]"
>
<option :value="undefined" disabled>
Select replacement status
</option>
<option
v-for="option in statusRequirement.replacementOptions"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<InputError
:message="
form.errors[
`status_replacements.${status.id}`
]
"
/>
</div>
</div>
</div>
<InputError :message="form.errors.areas" />
</div>
</CardContent>
</Card>
</template>
+10 -25
View File
@@ -3,6 +3,7 @@ import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -138,31 +139,15 @@ const statusAllowsTarget = (
{{ status }}
</div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project stays linked to its template. Sections below show whether the current configuration is still inherited or has local overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<TemplateInheritancePanel
:areas="[
'board_columns',
'ticket_statuses',
'status_transitions',
]"
:project-id="project.id"
:template-state="templateState"
/>
<Card>
<CardHeader>
@@ -50,10 +50,6 @@ const switchTemplateForm = useForm({
)?.id,
});
const resetTemplateForm = useForm({
confirmation_key: '',
});
const deleteForm = useForm({
confirmation_key: '',
});
@@ -76,10 +72,6 @@ const canSwitchTemplate = computed(
switchTemplateForm.source_template_id !== undefined,
);
const isResetConfirmationValid = computed(
() => resetTemplateForm.confirmation_key === props.project.key,
);
const isDeleteConfirmationValid = computed(
() => deleteForm.confirmation_key === props.project.key,
);
@@ -89,19 +81,12 @@ const submitSwitchTemplate = (): void => {
return;
}
switchTemplateForm.post(ProjectController.switchTemplate.url(props.project.id), {
preserveScroll: true,
});
};
const submitResetTemplate = (): void => {
if (!isResetConfirmationValid.value) {
return;
}
resetTemplateForm.post(ProjectController.resetTemplate.url(props.project.id), {
preserveScroll: true,
});
switchTemplateForm.post(
ProjectController.switchTemplate.url(props.project.id),
{
preserveScroll: true,
},
);
};
const submitDelete = (): void => {
@@ -209,56 +194,84 @@ const submitDelete = (): void => {
<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.
Switch this project to another published template. Use
the inheritance panels to reset individual settings to
the currently linked template.
</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]">
<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
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.'
? 'Inherited and overridden areas stay visible after switching.'
: '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]">
<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.
Switching templates can replace inherited board
workflow, ticket metadata, repositories, and
notifications.
</p>
<p class="text-muted-foreground mt-2 text-sm">
Confirm each action with the exact project key to avoid accidental overwrites.
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="grid gap-6">
<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>
<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.
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>
<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"
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>
@@ -272,68 +285,54 @@ const submitDelete = (): void => {
{{ template.name }}
</option>
</select>
<InputError :message="switchTemplateForm.errors.source_template_id" />
<InputError
:message="
switchTemplateForm.errors
.source_template_id
"
/>
</div>
<div class="grid gap-2">
<Label for="switch_confirmation_key">Confirmation key</Label>
<Label for="switch_confirmation_key"
>Confirmation key</Label
>
<Input
id="switch_confirmation_key"
v-model="switchTemplateForm.confirmation_key"
v-model="
switchTemplateForm.confirmation_key
"
:disabled="switchTemplateForm.processing"
placeholder="Type the project key exactly"
/>
<InputError :message="switchTemplateForm.errors.confirmation_key" />
<InputError
:message="
switchTemplateForm.errors
.confirmation_key
"
/>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<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.
Existing project-specific configuration in
copied areas may be replaced or removed.
</p>
<Button
type="submit"
variant="outline"
:disabled="switchTemplateForm.processing || !canSwitchTemplate"
: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>
@@ -3,6 +3,7 @@ import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -115,31 +116,17 @@ const replacementTicketTypes = (ticketTypeId: number) =>
{{ status }}
</div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Inherited sections stay synced with the template baseline. Local edits in these settings are treated as project-specific overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<TemplateInheritancePanel
:areas="[
'project_settings',
'ticket_types',
'ticket_priorities',
'ticket_fields',
'ticket_link_types',
]"
:project-id="project.id"
:template-state="templateState"
/>
<div class="grid gap-6 xl:grid-cols-2">
<Card class="gap-0 overflow-hidden">
@@ -699,7 +686,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
required
placeholder="Blocked by"
/>
<InputError :message="errors.counterpart_name" />
<InputError
:message="errors.counterpart_name"
/>
</div>
<Button type="submit" :disabled="processing">
@@ -745,7 +734,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
<Input
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
name="counterpart_name"
:default-value="ticketLinkType.counterpart_label"
:default-value="
ticketLinkType.counterpart_label
"
required
/>
<InputError
@@ -4,6 +4,7 @@ import { computed, reactive } from 'vue';
import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -89,7 +90,8 @@ const breadcrumbs: BreadcrumbItem[] = [
let filterStateCounter = 0;
const nextFilterStateId = (): string => `notification-filter-${filterStateCounter++}`;
const nextFilterStateId = (): string =>
`notification-filter-${filterStateCounter++}`;
const parseInFilterValues = (value: string): string[] =>
value
@@ -100,7 +102,8 @@ const parseInFilterValues = (value: string): string[] =>
const supportsFilterValue = (operator: string): boolean =>
!['exists', 'not_exists'].includes(operator);
const supportsArrayFilterValue = (operator: string): boolean => operator === 'in';
const supportsArrayFilterValue = (operator: string): boolean =>
operator === 'in';
const createFilter = (): NotificationFilterFormState => ({
attribute: defaultFilterAttribute,
@@ -159,8 +162,9 @@ const resetCreateFilters = (): void => {
createFilterState.filters = [];
};
const editFilters = (notificationActionId: number): NotificationFilterFormState[] =>
editFilterState[notificationActionId] ?? [];
const editFilters = (
notificationActionId: number,
): NotificationFilterFormState[] => editFilterState[notificationActionId] ?? [];
const addEditFilter = (notificationActionId: number): void => {
editFilterState[notificationActionId] = [
@@ -169,10 +173,13 @@ const addEditFilter = (notificationActionId: number): void => {
];
};
const removeEditFilter = (notificationActionId: number, index: number): void => {
editFilterState[notificationActionId] = editFilters(notificationActionId).filter(
(_, candidateIndex) => candidateIndex !== index,
);
const removeEditFilter = (
notificationActionId: number,
index: number,
): void => {
editFilterState[notificationActionId] = editFilters(
notificationActionId,
).filter((_, candidateIndex) => candidateIndex !== index);
};
const createSerializedFilters = computed<string>(() =>
@@ -305,31 +312,11 @@ const notificationActionTimestamp = (
{{ status }}
</div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Notification rules can remain inherited from the template or diverge here as project-level overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<TemplateInheritancePanel
:areas="['notification_actions']"
:project-id="project.id"
:template-state="templateState"
/>
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
<div class="space-y-6">
@@ -543,7 +530,10 @@ const notificationActionTimestamp = (
/>
<div
v-if="createFilterState.filters.length === 0"
v-if="
createFilterState.filters.length ===
0
"
class="text-muted-foreground text-sm"
>
No filters configured. This action runs
@@ -552,8 +542,7 @@ const notificationActionTimestamp = (
<div
v-for="(
filter,
filterIndex
filter, filterIndex
) in createFilterState.filters"
:key="filter.id"
class="grid gap-3 rounded-md border p-3"
@@ -572,8 +561,12 @@ const notificationActionTimestamp = (
>
<option
v-for="filterAttributeOption in filterAttributeOptions"
:key="filterAttributeOption.value"
:value="filterAttributeOption.value"
:key="
filterAttributeOption.value
"
:value="
filterAttributeOption.value
"
>
{{
filterAttributeOption.label
@@ -595,8 +588,12 @@ const notificationActionTimestamp = (
>
<option
v-for="filterOperatorOption in filterOperatorOptions"
:key="filterOperatorOption.value"
:value="filterOperatorOption.value"
:key="
filterOperatorOption.value
"
:value="
filterOperatorOption.value
"
>
{{
filterOperatorOption.label
@@ -607,7 +604,11 @@ const notificationActionTimestamp = (
</div>
<div
v-if="supportsFilterValue(filter.operator)"
v-if="
supportsFilterValue(
filter.operator,
)
"
class="grid gap-2"
>
<Label
@@ -662,7 +663,9 @@ const notificationActionTimestamp = (
</div>
<InputError
:message="notificationFilterError(errors)"
:message="
notificationFilterError(errors)
"
/>
</div>
@@ -1029,8 +1032,7 @@ const notificationActionTimestamp = (
<div
v-for="(
filter,
filterIndex
filter, filterIndex
) in editFilters(notificationAction.id)"
:key="filter.id"
class="grid gap-3 rounded-md border p-3"
@@ -1049,8 +1051,12 @@ const notificationActionTimestamp = (
>
<option
v-for="filterAttributeOption in filterAttributeOptions"
:key="filterAttributeOption.value"
:value="filterAttributeOption.value"
:key="
filterAttributeOption.value
"
:value="
filterAttributeOption.value
"
>
{{
filterAttributeOption.label
@@ -1072,8 +1078,12 @@ const notificationActionTimestamp = (
>
<option
v-for="filterOperatorOption in filterOperatorOptions"
:key="filterOperatorOption.value"
:value="filterOperatorOption.value"
:key="
filterOperatorOption.value
"
:value="
filterOperatorOption.value
"
>
{{
filterOperatorOption.label
@@ -1084,7 +1094,11 @@ const notificationActionTimestamp = (
</div>
<div
v-if="supportsFilterValue(filter.operator)"
v-if="
supportsFilterValue(
filter.operator,
)
"
class="grid gap-2"
>
<Label
@@ -1144,7 +1158,9 @@ const notificationActionTimestamp = (
</div>
<InputError
:message="notificationFilterError(errors)"
:message="
notificationFilterError(errors)
"
/>
</div>
@@ -4,6 +4,7 @@ import { computed, watch } from 'vue';
import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -277,31 +278,11 @@ const submitUpdate = (): void => {
{{ status }}
</div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Repository links can stay inherited from the template or be replaced with project-specific overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<TemplateInheritancePanel
:areas="['repository_connections']"
:project-id="project.id"
:template-state="templateState"
/>
<Card>
<CardHeader>
@@ -320,9 +301,9 @@ const submitUpdate = (): void => {
</Link>
</Button>
<p class="text-muted-foreground text-sm">
Without a matching personal connection, {{ appName }} only
shows the repository name and links you to your user
settings.
Without a matching personal connection,
{{ appName }} only shows the repository name and links
you to your user settings.
</p>
</CardContent>
</Card>
@@ -647,8 +628,8 @@ const submitUpdate = (): void => {
>
This project link is visible, but you need a
matching Git connection in your user
settings before {{ appName }} can open or query
this repository for you.
settings before {{ appName }} can open or
query this repository for you.
</p>
<div class="mt-4 flex justify-end">
@@ -683,9 +664,9 @@ const submitUpdate = (): void => {
<CardHeader class="border-b">
<CardTitle>Detected personal sources</CardTitle>
<CardDescription>
{{ appName }} loads repositories from your personal
Git connections and offers them for project
linking.
{{ appName }} loads repositories from your
personal Git connections and offers them for
project linking.
</CardDescription>
</CardHeader>
+11
View File
@@ -78,6 +78,17 @@ export type ProjectTemplateState = {
inheritsFromTemplate: boolean;
overriddenAreas: ProjectTemplateStateArea[];
overriddenConfiguration?: Record<string, unknown>;
resetRequirements?: Record<
string,
{
removedStatuses: Array<{
id: number;
name: string;
ticketCount: number;
}>;
replacementOptions: Array<{ id: number; name: string }>;
}
>;
templateConfiguration?: Record<string, unknown>;
templateId: number;
templateName: string;
+3
View File
@@ -169,6 +169,9 @@ class ProjectApiTest extends TestCase
'position' => 2,
'is_parent' => false,
]);
$project->forceFill([
'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
])->saveQuietly();
$this->syncProjectPermissions($project, $user, ['view']);
@@ -72,7 +72,7 @@ class ProjectTemplateBaselineMigrationTest extends TestCase
$this->assertSame('lgcy-base-template', $template->slug);
}
public function test_metadata_migration_moves_existing_configuration_to_the_project_template_and_preserves_snapshot(): void
public function test_metadata_migration_moves_existing_configuration_and_preserves_published_template_links(): void
{
Artisan::call('migrate:fresh', [
'--path' => [
@@ -131,6 +131,42 @@ class ProjectTemplateBaselineMigrationTest extends TestCase
'updated_at' => now(),
]);
DB::table('projects')->insert([
'name' => 'Linked Project',
'key' => 'LINK',
'show_author' => 1,
'show_story_points' => 0,
'project_template_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$linkedProjectId = (int) DB::table('projects')->where('key', 'LINK')->value('id');
$publishedSnapshot = [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => false,
'show_story_points' => true,
],
];
$publishedTemplateId = DB::table('project_templates')->insertGetId([
'project_id' => null,
'source_project_id' => $projectId,
'name' => 'Published Template',
'slug' => 'published-template',
'is_published' => true,
'copy_areas' => json_encode(
[DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
JSON_THROW_ON_ERROR,
),
'configuration_snapshot' => json_encode($publishedSnapshot, JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('projects')->where('id', $linkedProjectId)->update([
'project_template_id' => $publishedTemplateId,
]);
Artisan::call('migrate', [
'--path' => 'database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php',
]);
@@ -142,5 +178,18 @@ class ProjectTemplateBaselineMigrationTest extends TestCase
$this->assertSame($project->project_template_id, $ticketType->project_template_id);
$this->assertSame(['Bug'], array_column($snapshot[DuplicateProjectConfiguration::COPY_TICKET_TYPES], 'name'));
$this->assertSame(
$publishedTemplateId,
DB::table('projects')->where('id', $linkedProjectId)->value('project_template_id'),
);
$this->assertSame(
$publishedSnapshot,
json_decode(
(string) DB::table('project_templates')->where('id', $publishedTemplateId)->value('configuration_snapshot'),
true,
512,
JSON_THROW_ON_ERROR,
),
);
}
}
+4 -1
View File
@@ -3,8 +3,8 @@
namespace Tests\Feature\Mcp;
use App\Actions\Notifications\DeliverNotificationAction;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Enums\NotificationDeliveryStatus;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetTicket;
@@ -247,6 +247,9 @@ class KanbanGitMcpTest extends TestCase
'position' => 1,
'is_parent' => false,
]);
$project->forceFill([
'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
])->saveQuietly();
$status = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $status, [
'ticket_key' => 'CORE-1',
@@ -5,6 +5,7 @@ namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
@@ -272,6 +273,9 @@ class ProjectBoardControllerTest extends TestCase
$user = User::factory()->create();
$project = Project::factory()->create();
$otherProject = Project::factory()->create();
$sharedTemplate = ProjectTemplate::factory()->create();
$project->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly();
$otherProject->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly();
app(InitializeProjectTicketMetadata::class)->handle($project);
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
@@ -2,8 +2,8 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\ProjectTemplate;
@@ -54,7 +54,9 @@ class ProjectBoardSettingsControllerTest extends TestCase
->where('boardColumns.0.name', 'Todo')
->where('ticketStatuses.0.name', 'Backlog')
->where('templateState.templateName', 'Starter Workflow')
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW),
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_COLUMNS)
->where('templateState.inheritedAreas.1.value', DuplicateProjectConfiguration::COPY_TICKET_STATUSES)
->where('templateState.inheritedAreas.2.value', DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS),
);
}
@@ -308,6 +310,9 @@ class ProjectBoardSettingsControllerTest extends TestCase
$otherProject = Project::factory()->create([
'key' => 'EXT',
]);
$sharedTemplate = ProjectTemplate::factory()->create();
$project->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly();
$otherProject->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly();
$this->syncProjectPermissions($project, $user, ['edit']);
$sourceStatus = TicketStatus::factory()->for($project)->create([
@@ -410,6 +410,8 @@ class ProjectControllerTest extends TestCase
],
]);
$response->assertRedirect()->assertSessionHasNoErrors();
$project = Project::query()->where('key', 'DUPE')->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
@@ -243,6 +243,13 @@ class ProjectTemplateControllerTest extends TestCase
'position' => 1,
'is_parent' => false,
]);
$inheritingProject->refresh()->forceFill(['template_overrides' => []])->saveQuietly();
$overriddenProject->refresh()->forceFill([
'template_overrides' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
])->saveQuietly();
$sourceProject->forceFill([
'show_author' => true,
@@ -0,0 +1,263 @@
<?php
namespace Tests\Feature\Projects;
use App\Actions\Projects\CreateProject;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\UpdateProjectTemplate;
use App\Actions\Projects\UpdateProjectTemplateLink;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProjectTemplateOverrideTest extends TestCase
{
use RefreshDatabase;
public function test_template_updates_preserve_status_ids_and_existing_tickets(): void
{
$sourceProject = Project::factory()->create();
$sourceStatus = TicketStatus::factory()->for($sourceProject)->create(['name' => 'Todo', 'position' => 1]);
$template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]);
$project = Project::factory()->create();
$status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
$ticket = $this->createTicket($project, $status);
$sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
$this->assertSame('Ready', $status->fresh()->name);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
$this->assertDatabaseHas('tickets', ['id' => $ticket->id]);
}
public function test_projects_created_from_a_template_start_with_inherited_areas(): void
{
$sourceProject = Project::factory()->create();
$sourceStatus = TicketStatus::factory()->for($sourceProject)->create(['name' => 'Todo', 'position' => 1]);
$template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]);
$project = app(CreateProject::class)->handle([
'name' => 'Inherited project',
'key' => 'INHR',
'creation_mode' => 'template',
'source_template_id' => $template->id,
], User::factory()->create());
$this->assertSame([], $project->template_overrides);
$sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
$this->assertSame(['Ready'], $project->ticketStatuses()->pluck('name')->all());
}
public function test_local_override_remains_detached_even_when_it_temporarily_matches_the_template(): void
{
$sourceProject = Project::factory()->create();
$sourceStatus = TicketStatus::factory()->for($sourceProject)->create(['name' => 'Todo', 'position' => 1]);
$template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]);
$project = Project::factory()->create();
$status = TicketStatus::factory()->for($project)->create(['name' => 'Todo', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
$status->update(['name' => 'Project only']);
$status->update(['name' => 'Todo']);
$this->assertContains(
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
$project->fresh()->template_overrides,
);
$sourceStatus->update(['name' => 'Ready']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
$this->assertSame('Todo', $status->fresh()->name);
$this->assertContains(
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
$project->fresh()->template_overrides,
);
}
public function test_reset_requires_and_applies_a_replacement_for_used_local_statuses(): void
{
$user = User::factory()->create();
$sourceProject = Project::factory()->create();
TicketStatus::factory()->for($sourceProject)->create(['name' => 'Template status', 'position' => 1]);
$template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_STATUSES]);
$project = Project::factory()->create([
'project_template_id' => $template->id,
'template_overrides' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
]);
$replacementStatus = TicketStatus::factory()->for($project)->create(['name' => 'Old template status', 'position' => 1]);
$localStatus = TicketStatus::factory()->for($project)->create(['name' => 'Local status', 'position' => 2]);
$ticket = $this->createTicket($project, $localStatus);
$this->syncProjectPermissions($project, $user, ['admin']);
$url = route('projects.settings.general.reset-template', $project);
$this->actingAs($user)->from($url)->post($url, [
'areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
])->assertSessionHasErrors("status_replacements.{$localStatus->id}");
$this->actingAs($user)->from($url)->post($url, [
'areas' => [DuplicateProjectConfiguration::COPY_TICKET_STATUSES],
'status_replacements' => [$localStatus->id => $replacementStatus->id],
])->assertSessionHasNoErrors();
$this->assertDatabaseMissing('ticket_statuses', ['id' => $localStatus->id]);
$this->assertSame($replacementStatus->id, $ticket->fresh()->ticket_status_id);
$this->assertNotContains(
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
$project->fresh()->template_overrides,
);
}
public function test_reset_clears_only_the_selected_override_area(): void
{
$sourceProject = Project::factory()->create(['show_author' => true, 'show_story_points' => true]);
TicketType::factory()->for($sourceProject)->create(['name' => 'Template type', 'position' => 1]);
$template = $this->createTemplate($sourceProject, [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
]);
$project = Project::factory()->create([
'project_template_id' => $template->id,
'show_author' => false,
'show_story_points' => false,
'template_overrides' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
]);
TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]);
app(UpdateProjectTemplateLink::class)->reset(
$project->load('projectTemplate'),
[DuplicateProjectConfiguration::COPY_TICKET_TYPES],
);
$this->assertFalse($project->fresh()->show_author);
$this->assertSame(['Template type'], $project->ticketTypes()->pluck('name')->all());
$this->assertSame(
[DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
$project->fresh()->template_overrides,
);
}
public function test_new_template_areas_do_not_replace_existing_local_configuration(): void
{
$sourceProject = Project::factory()->create();
TicketType::factory()->for($sourceProject)->create(['name' => 'Template type', 'position' => 1]);
$template = $this->createTemplate($sourceProject, [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
]);
$project = Project::factory()->create();
TicketType::factory()->for($project)->create(['name' => 'Local type', 'position' => 1]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
]);
$this->assertSame(['Local type'], $project->ticketTypes()->pluck('name')->all());
$this->assertContains(
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
$project->fresh()->template_overrides,
);
}
public function test_template_updates_preserve_custom_field_values(): void
{
$sourceProject = Project::factory()->create();
$sourceField = TicketFieldDefinition::factory()->for($sourceProject)->create([
'field_type' => 'number', 'label' => 'Estimate', 'position' => 1,
]);
$template = $this->createTemplate($sourceProject, [DuplicateProjectConfiguration::COPY_TICKET_FIELDS]);
$project = Project::factory()->create();
$field = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number', 'label' => 'Estimate', 'position' => 1,
]);
$project->forceFill(['project_template_id' => $template->id])->saveQuietly();
$status = TicketStatus::factory()->for($project)->create();
$ticket = $this->createTicket($project, $status);
$fieldValue = TicketFieldValue::factory()->create([
'ticket_id' => $ticket->id,
'ticket_field_definition_id' => $field->id,
'value_number' => 8,
]);
$sourceField->update(['label' => 'Story points']);
app(UpdateProjectTemplate::class)->handle($template, [
'name' => $template->name,
'copy_areas' => [DuplicateProjectConfiguration::COPY_TICKET_FIELDS],
]);
$this->assertSame('Story points', $field->fresh()->label);
$this->assertSame($field->id, $fieldValue->fresh()->ticket_field_definition_id);
$this->assertEquals(8, $fieldValue->fresh()->value_number);
}
public function test_removing_a_board_column_never_deletes_its_statuses_or_tickets(): void
{
$project = Project::factory()->create();
$column = BoardColumn::factory()->for($project)->create();
$status = TicketStatus::factory()->for($project)->create([
'board_column_id' => $column->id,
]);
$ticket = $this->createTicket($project, $status);
$blockedAreas = app(DuplicateProjectConfiguration::class)->apply($project, [
DuplicateProjectConfiguration::COPY_BOARD_COLUMNS => [],
]);
$this->assertSame([DuplicateProjectConfiguration::COPY_BOARD_COLUMNS], $blockedAreas);
$this->assertDatabaseHas('board_columns', ['id' => $column->id]);
$this->assertDatabaseHas('ticket_statuses', ['id' => $status->id]);
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
}
/** @param list<string> $areas */
private function createTemplate(Project $sourceProject, array $areas): ProjectTemplate
{
return ProjectTemplate::factory()->create([
'source_project_id' => $sourceProject->id,
'copy_areas' => $areas,
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($sourceProject, $areas),
]);
}
private function createTicket(Project $project, TicketStatus $status): Ticket
{
$type = TicketType::factory()->for($project)->create();
$priority = TicketPriority::factory()->for($project)->create();
return Ticket::factory()->create([
'project_id' => $project->id,
'ticket_status_id' => $status->id,
'ticket_type_id' => $type->id,
'ticket_priority_id' => $priority->id,
'ticket_key' => $project->key.'-1',
]);
}
}
@@ -2,8 +2,8 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket;
@@ -55,6 +55,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$project->update([
'project_template_id' => $template->id,
'show_story_points' => false,
'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
]);
$this->syncProjectPermissions($project, $user, ['edit']);