fix: make project template inheritance granular and safe
This commit is contained in:
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Actions\ProjectBoard;
|
namespace App\Actions\ProjectBoard;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||||
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class UpdateTicketStatusTransitions
|
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
|
* @param array{allowed_ticket_status_ids?: array<int, int|string>|null} $validated
|
||||||
*/
|
*/
|
||||||
@@ -19,6 +27,14 @@ class UpdateTicketStatusTransitions
|
|||||||
->all();
|
->all();
|
||||||
|
|
||||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
$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();
|
return $ticketStatus->fresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Actions\ProjectTicketMetadata;
|
namespace App\Actions\ProjectTicketMetadata;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||||
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class UpdateProjectTicketMetadataSettings
|
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
|
* @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_author' => Arr::get($validated, 'show_author', false),
|
||||||
'show_story_points' => Arr::get($validated, 'show_story_points', 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();
|
return $project->fresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class CreateProject
|
|||||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,18 +40,22 @@ class CreateProject
|
|||||||
|
|
||||||
$project->forceFill([
|
$project->forceFill([
|
||||||
'project_template_id' => $sourceTemplate->id,
|
'project_template_id' => $sourceTemplate->id,
|
||||||
])->save();
|
'template_overrides' => [],
|
||||||
|
])->saveQuietly();
|
||||||
} else {
|
} else {
|
||||||
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
|
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
|
||||||
|
|
||||||
$project->forceFill([
|
$project->forceFill([
|
||||||
'project_template_id' => $baseTemplate->id,
|
'project_template_id' => $baseTemplate->id,
|
||||||
])->save();
|
'template_overrides' => [],
|
||||||
|
])->saveQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
$project = $project->fresh() ?? $project;
|
$project = $project->fresh() ?? $project;
|
||||||
|
|
||||||
$this->initializeProjectTicketMetadata->handle($project);
|
$this->projectTemplateOverrideManager->withoutRecording(
|
||||||
|
fn () => $this->initializeProjectTicketMetadata->handle($project),
|
||||||
|
);
|
||||||
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
||||||
|
|
||||||
if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) {
|
if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) {
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class DuplicateProjectConfiguration
|
class DuplicateProjectConfiguration
|
||||||
{
|
{
|
||||||
public const COPY_PROJECT_SETTINGS = 'project_settings';
|
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';
|
public const COPY_TICKET_TYPES = 'ticket_types';
|
||||||
|
|
||||||
@@ -22,14 +28,20 @@ class DuplicateProjectConfiguration
|
|||||||
|
|
||||||
public const COPY_NOTIFICATION_ACTIONS = 'notification_actions';
|
public const COPY_NOTIFICATION_ACTIONS = 'notification_actions';
|
||||||
|
|
||||||
/**
|
public const COPY_BOARD_WORKFLOW = 'board_workflow';
|
||||||
* @return list<string>
|
|
||||||
*/
|
public function __construct(
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
public static function copyableAreas(): array
|
public static function copyableAreas(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
self::COPY_PROJECT_SETTINGS,
|
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_TYPES,
|
||||||
self::COPY_TICKET_PRIORITIES,
|
self::COPY_TICKET_PRIORITIES,
|
||||||
self::COPY_TICKET_FIELDS,
|
self::COPY_TICKET_FIELDS,
|
||||||
@@ -39,68 +51,67 @@ class DuplicateProjectConfiguration
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return list<string> */
|
||||||
* @return list<array{value: string, label: string, description: 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
|
public static function copyAreaOptions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
[
|
['value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'General project preferences like story points and author visibility.'],
|
||||||
'value' => self::COPY_PROJECT_SETTINGS,
|
['value' => self::COPY_BOARD_COLUMNS, 'label' => 'Board columns', 'description' => 'Columns and their order on the project board.'],
|
||||||
'label' => 'Project settings',
|
['value' => self::COPY_TICKET_STATUSES, 'label' => 'Ticket statuses', 'description' => 'Statuses and their assignment to board columns.'],
|
||||||
'description' => 'Copy general project preferences like story points and author visibility.',
|
['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_BOARD_WORKFLOW,
|
['value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Custom field definitions without ticket values.'],
|
||||||
'label' => 'Board workflow',
|
['value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Relationship labels between tickets.'],
|
||||||
'description' => 'Copy board columns, ticket statuses, and allowed status transitions.',
|
['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.'],
|
||||||
[
|
|
||||||
'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.',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @param list<string> $copyAreas @return list<string> */
|
||||||
* @param list<string> $copyAreas
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function normalizeCopyAreas(array $copyAreas): array
|
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)));
|
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||||
* @param list<string> $copyAreas
|
public function normalizeConfiguration(array $configuration): array
|
||||||
* @return array<string, mixed>
|
{
|
||||||
*/
|
$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
|
public function export(Project $sourceProject, array $copyAreas): array
|
||||||
{
|
{
|
||||||
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
|
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
|
||||||
@@ -109,17 +120,10 @@ class DuplicateProjectConfiguration
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourceProject->loadMissing([
|
$sourceProject->load([
|
||||||
'boardColumns',
|
'boardColumns', 'notificationActions', 'repositoryConnections', 'ticketFieldDefinitions',
|
||||||
'notificationActions',
|
'ticketLinkTypes', 'ticketPriorities', 'ticketStatuses.allowedTargetStatuses', 'ticketTypes',
|
||||||
'repositoryConnections',
|
|
||||||
'ticketFieldDefinitions',
|
|
||||||
'ticketLinkTypes',
|
|
||||||
'ticketPriorities',
|
|
||||||
'ticketStatuses.allowedTargetStatuses',
|
|
||||||
'ticketTypes',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$configuration = [];
|
$configuration = [];
|
||||||
|
|
||||||
if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) {
|
if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) {
|
||||||
@@ -129,120 +133,65 @@ class DuplicateProjectConfiguration
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) {
|
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||||
$boardColumnIndexes = [];
|
static fn ($column, int $index): array => [$column->id => $index],
|
||||||
$boardColumns = [];
|
);
|
||||||
|
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||||
|
static fn ($status, int $index): array => [$status->id => $index],
|
||||||
|
);
|
||||||
|
|
||||||
foreach ($sourceProject->boardColumns as $index => $boardColumn) {
|
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
||||||
$boardColumnIndexes[$boardColumn->id] = $index;
|
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
||||||
$boardColumns[] = [
|
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
|
||||||
'name' => $boardColumn->name,
|
)->all();
|
||||||
'position' => $boardColumn->position,
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
$ticketStatusIndexes = [];
|
self::COPY_TICKET_FIELDS => 'ticketFieldDefinitions', self::COPY_TICKET_LINK_TYPES => 'ticketLinkTypes',
|
||||||
$ticketStatuses = [];
|
self::COPY_REPOSITORY_CONNECTIONS => 'repositoryConnections', self::COPY_NOTIFICATION_ACTIONS => 'notificationActions',
|
||||||
|
|
||||||
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) {
|
foreach ($simpleAreas as $area => $mapper) {
|
||||||
$ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses
|
if (in_array($area, $selectedAreas, true)) {
|
||||||
->pluck('id')
|
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
|
||||||
->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;
|
return $configuration;
|
||||||
@@ -250,151 +199,141 @@ class DuplicateProjectConfiguration
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $configuration
|
* @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)) {
|
$configuration = $this->normalizeConfiguration($configuration);
|
||||||
$projectSettings = $configuration[self::COPY_PROJECT_SETTINGS];
|
|
||||||
|
|
||||||
$targetProject->forceFill([
|
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
||||||
'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author),
|
$blockedAreas = [];
|
||||||
'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points),
|
|
||||||
])->save();
|
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
||||||
|
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) {
|
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
||||||
$targetProject->ticketStatuses()->delete();
|
$blocked = $this->syncModels(
|
||||||
$targetProject->boardColumns()->delete();
|
$targetProject->boardColumns()->get(),
|
||||||
|
$configuration[self::COPY_BOARD_COLUMNS],
|
||||||
$boardColumnIds = [];
|
fn (array $item) => $targetProject->boardColumns()->create($item),
|
||||||
|
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||||
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) {
|
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
|
||||||
$newBoardColumn = $targetProject->boardColumns()->create([
|
);
|
||||||
'name' => $boardColumn['name'],
|
if ($blocked) {
|
||||||
'position' => $boardColumn['position'],
|
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
||||||
]);
|
}
|
||||||
|
|
||||||
$boardColumnIds[] = $newBoardColumn->id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketStatusIds = [];
|
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']);
|
||||||
|
|
||||||
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) {
|
return $item;
|
||||||
$boardColumnIndex = $ticketStatus['board_column_index'] ?? null;
|
})->all();
|
||||||
$newTicketStatus = $targetProject->ticketStatuses()->create([
|
$blocked = $this->syncModels(
|
||||||
'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null),
|
$targetProject->ticketStatuses()->get(),
|
||||||
'name' => $ticketStatus['name'],
|
$items,
|
||||||
'position' => $ticketStatus['position'],
|
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]);
|
||||||
|
|
||||||
$ticketStatusIds[] = $newTicketStatus->id;
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if ($blocked) {
|
||||||
|
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) {
|
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
||||||
$newTicketStatusId = $ticketStatusIds[$index] ?? null;
|
$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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($newTicketStatusId === null) {
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
|
$blocked = $this->syncModels(
|
||||||
$targetProject->ticketStatuses()->findOrFail($newTicketStatusId)
|
$targetProject->{$relation}()->get(),
|
||||||
->allowedTargetStatuses()
|
$configuration[$area],
|
||||||
->sync(
|
fn (array $item) => $targetProject->{$relation}()->create($item),
|
||||||
collect($ticketStatus['allowed_target_indexes'] ?? [])
|
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||||
->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null)
|
$canDelete,
|
||||||
->filter(static fn (?int $statusId): bool => $statusId !== null)
|
|
||||||
->values()
|
|
||||||
->all(),
|
|
||||||
);
|
);
|
||||||
|
if ($blocked) {
|
||||||
|
$blockedAreas[] = $area;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) {
|
foreach ($targetProject->configurationRelations() as $relation) {
|
||||||
$targetProject->ticketTypes()->delete();
|
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||||
|
|
||||||
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)) {
|
return array_values(array_unique($blockedAreas));
|
||||||
$targetProject->ticketPriorities()->delete();
|
});
|
||||||
|
|
||||||
foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) {
|
foreach ($blockedAreas as $area) {
|
||||||
$targetProject->ticketPriorities()->create([
|
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||||
'name' => $ticketPriority['name'],
|
|
||||||
'position' => $ticketPriority['position'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) {
|
return $blockedAreas;
|
||||||
$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)) {
|
/** @param list<string> $copyAreas */
|
||||||
$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'] ?? [],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $copyAreas
|
|
||||||
*/
|
|
||||||
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||||
{
|
{
|
||||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
$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()
|
? DuplicateProjectConfiguration::copyableAreas()
|
||||||
: $this->duplicateProjectConfiguration->normalizeCopyAreas($areas);
|
: $this->duplicateProjectConfiguration->normalizeCopyAreas($areas);
|
||||||
|
|
||||||
$templateConfiguration = collect($projectTemplate->configuration_snapshot ?? [])
|
$normalizedTemplateConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
|
$projectTemplate->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
$templateConfiguration = collect($normalizedTemplateConfiguration)
|
||||||
->only($selectedAreas)
|
->only($selectedAreas)
|
||||||
->all();
|
->all();
|
||||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
|
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
|
||||||
|
$persistedOverrides = $projectTemplate->is_published
|
||||||
|
? array_values(array_intersect($project->template_overrides ?? [], $selectedAreas))
|
||||||
|
: [];
|
||||||
|
|
||||||
$overriddenAreas = [];
|
$overriddenAreas = [];
|
||||||
$inheritedAreas = [];
|
$inheritedAreas = [];
|
||||||
@@ -41,13 +47,12 @@ class ResolveProjectTemplateState
|
|||||||
foreach ($selectedAreas as $area) {
|
foreach ($selectedAreas as $area) {
|
||||||
$label = $this->labelForArea($area);
|
$label = $this->labelForArea($area);
|
||||||
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
|
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
|
||||||
$hasCurrentValue = array_key_exists($area, $currentConfiguration);
|
|
||||||
|
|
||||||
if (! $hasTemplateValue && ! $hasCurrentValue) {
|
if (! $hasTemplateValue) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) {
|
if (! in_array($area, $persistedOverrides, true)) {
|
||||||
$inheritedAreas[] = [
|
$inheritedAreas[] = [
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'value' => $area,
|
'value' => $area,
|
||||||
@@ -72,6 +77,7 @@ class ResolveProjectTemplateState
|
|||||||
'inheritsFromTemplate' => $projectTemplate->is_published,
|
'inheritsFromTemplate' => $projectTemplate->is_published,
|
||||||
'overriddenAreas' => $overriddenAreas,
|
'overriddenAreas' => $overriddenAreas,
|
||||||
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
|
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
|
||||||
|
'resetRequirements' => $this->resetRequirements($project, $templateConfiguration, $persistedOverrides),
|
||||||
'templateConfiguration' => $templateConfiguration,
|
'templateConfiguration' => $templateConfiguration,
|
||||||
'templateId' => $projectTemplate->id,
|
'templateId' => $projectTemplate->id,
|
||||||
'templateName' => $projectTemplate->name,
|
'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
|
private function labelForArea(string $area): string
|
||||||
{
|
{
|
||||||
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
|
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -10,6 +11,7 @@ class UpdateProjectTemplate
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,48 +19,68 @@ class UpdateProjectTemplate
|
|||||||
*/
|
*/
|
||||||
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||||
{
|
{
|
||||||
$projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate');
|
$projectTemplate->loadMissing('sourceProject');
|
||||||
|
|
||||||
$sourceProject = $projectTemplate->sourceProject;
|
$sourceProject = $projectTemplate->sourceProject;
|
||||||
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
|
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
|
||||||
|
|
||||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||||
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
|
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
|
||||||
$previousConfiguration = $projectTemplate->configuration_snapshot ?? [];
|
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
|
$projectTemplate->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
||||||
$affectedAreas = array_values(array_unique([
|
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||||
...array_keys($previousConfiguration),
|
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||||
...array_keys($nextConfiguration),
|
->values()
|
||||||
]));
|
->all();
|
||||||
|
|
||||||
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
||||||
|
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||||
|
|
||||||
$projectTemplate->forceFill([
|
$projectTemplate->forceFill([
|
||||||
'name' => $attributes['name'],
|
'name' => $attributes['name'],
|
||||||
'copy_areas' => $normalizedCopyAreas,
|
'copy_areas' => $normalizedCopyAreas,
|
||||||
'configuration_snapshot' => $nextConfiguration,
|
'configuration_snapshot' => $nextConfiguration,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
foreach ($projectTemplate->linkedProjects as $linkedProject) {
|
Project::query()
|
||||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject->fresh(), $affectedAreas);
|
->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 = [];
|
$replacementConfiguration = [];
|
||||||
|
|
||||||
foreach ($nextConfiguration as $area => $configuration) {
|
foreach ($affectedAreas as $area) {
|
||||||
$isNewTemplateArea = ! array_key_exists($area, $previousConfiguration);
|
if (in_array($area, $overrides, true)) {
|
||||||
$matchesPreviousTemplate = array_key_exists($area, $previousConfiguration)
|
|
||||||
&& array_key_exists($area, $currentConfiguration)
|
|
||||||
&& $currentConfiguration[$area] === $previousConfiguration[$area];
|
|
||||||
|
|
||||||
if ($isNewTemplateArea || $matchesPreviousTemplate) {
|
|
||||||
$replacementConfiguration[$area] = $configuration;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($replacementConfiguration === []) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||||
|
|||||||
@@ -10,23 +10,39 @@ class UpdateProjectTemplateLink
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function switch(Project $project, ProjectTemplate $template): void
|
public function switch(Project $project, ProjectTemplate $template): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($project, $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->forceFill([
|
||||||
'project_template_id' => $template->id,
|
'project_template_id' => $template->id,
|
||||||
])->save();
|
'template_overrides' => $localAreas,
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
$this->duplicateProjectConfiguration->apply(
|
$blockedAreas = $this->duplicateProjectConfiguration->apply($project, $configuration);
|
||||||
|
$this->projectTemplateOverrideManager->clear(
|
||||||
$project->fresh(),
|
$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;
|
$template = $project->projectTemplate;
|
||||||
|
|
||||||
@@ -34,10 +50,19 @@ class UpdateProjectTemplateLink
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($project, $template): void {
|
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
|
||||||
$this->duplicateProjectConfiguration->apply(
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
$project->fresh(),
|
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
$template->configuration_snapshot ?? [],
|
$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)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,7 @@ class CreateProjectTicket
|
|||||||
$nextSequenceNumber = (int) Ticket::query()
|
$nextSequenceNumber = (int) Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->max('sequence_number') + 1;
|
->max('sequence_number') + 1;
|
||||||
$ticketStatusQuery = TicketStatus::query()
|
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
|
||||||
->where(
|
|
||||||
$project->project_template_id === null ? 'project_id' : 'project_template_id',
|
|
||||||
$project->project_template_id ?? $project->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ProjectController extends Controller
|
|||||||
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
|
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
|
||||||
private readonly CreateProject $createProject,
|
private readonly CreateProject $createProject,
|
||||||
private readonly DeleteProject $deleteProject,
|
private readonly DeleteProject $deleteProject,
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
private readonly ListVisibleProjects $listVisibleProjects,
|
private readonly ListVisibleProjects $listVisibleProjects,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
@@ -135,9 +136,10 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
|
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||||
{
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
$template = ProjectTemplate::query()
|
$template = ProjectTemplate::query()
|
||||||
->published()
|
->published()
|
||||||
->findOrFail($request->integer('source_template_id'));
|
->findOrFail((int) Arr::get($validated, 'source_template_id'));
|
||||||
|
|
||||||
$this->updateProjectTemplateLink->switch($project, $template);
|
$this->updateProjectTemplateLink->switch($project, $template);
|
||||||
|
|
||||||
@@ -147,16 +149,29 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
|
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validated();
|
$validated = $request->validated();
|
||||||
|
|
||||||
$template = $project->projectTemplate;
|
$template = $project->projectTemplate;
|
||||||
|
|
||||||
abort_if($template === null, 422, 'This project has no linked template to reset.');
|
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)
|
$this->updateProjectTemplateLink->reset(
|
||||||
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
|
$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
|
public function access(Request $request, Project $project): Response
|
||||||
|
|||||||
@@ -24,16 +24,13 @@ class MoveProjectTicketRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where(
|
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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'replacement_ticket_type_id' => [
|
'replacement_ticket_type_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_types', 'id')->where(
|
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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where(
|
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'])],
|
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
class ResetProjectTemplateRequest extends FormRequest
|
class ResetProjectTemplateRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -23,11 +26,71 @@ class ResetProjectTemplateRequest extends FormRequest
|
|||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'areas' => ['sometimes', 'array', 'min:1'],
|
||||||
|
'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
|
||||||
'confirmation_key' => [
|
'confirmation_key' => [
|
||||||
'required',
|
'required_without:areas',
|
||||||
'string',
|
'string',
|
||||||
Rule::in([$project->key]),
|
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 [
|
return [
|
||||||
'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.',
|
'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.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class StoreProjectRequest extends FormRequest
|
|||||||
'copy_areas' => ['nullable', 'array'],
|
'copy_areas' => ['nullable', 'array'],
|
||||||
'copy_areas.*' => [
|
'copy_areas.*' => [
|
||||||
'string',
|
'string',
|
||||||
Rule::in(DuplicateProjectConfiguration::copyableAreas()),
|
Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class StoreProjectTemplateRequest extends FormRequest
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
||||||
'copy_areas' => ['required', 'array', 'min:1'],
|
'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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'branch_name' => ['required', 'string', 'max:255'],
|
'branch_name' => ['required', 'string', 'max:255'],
|
||||||
@@ -36,7 +33,7 @@ class StoreProjectTicketBranchRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('repository_connections', 'id')->where(
|
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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => ['nullable', 'array'],
|
'acceptance_criteria' => ['nullable', 'array'],
|
||||||
@@ -70,21 +67,21 @@ class StoreProjectTicketRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_priorities', 'id')->where(
|
Rule::exists('ticket_priorities', 'id')->where(
|
||||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where(
|
Rule::exists('ticket_statuses', 'id')->where(
|
||||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
'ticket_type_id' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_types', 'id')->where(
|
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'],
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'linked_ticket_id' => [
|
'linked_ticket_id' => [
|
||||||
@@ -42,7 +39,7 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_link_types', 'id')->where(
|
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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'board_column_id' => [
|
'board_column_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('board_columns', 'id')->where(
|
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'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class UpdateProjectTemplateRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'copy_areas' => ['required', 'array', 'min:1'],
|
'copy_areas' => ['required', 'array', 'min:1'],
|
||||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
|
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||||
'confirm_linked_projects_update' => [
|
'confirm_linked_projects_update' => [
|
||||||
Rule::requiredIf($requiresImpactConfirmation),
|
Rule::requiredIf($requiresImpactConfirmation),
|
||||||
'accepted',
|
'accepted',
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => ['nullable', 'array'],
|
'acceptance_criteria' => ['nullable', 'array'],
|
||||||
@@ -72,7 +69,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_field_definitions', 'id')->where(
|
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'],
|
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||||
@@ -87,14 +84,14 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_priorities', 'id')->where(
|
Rule::exists('ticket_priorities', 'id')->where(
|
||||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
'ticket_type_id' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_types', 'id')->where(
|
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'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
@@ -150,7 +147,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
|
|
||||||
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
|
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
|
||||||
$requestedTicketType = TicketType::query()
|
$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);
|
->find($requestedTicketTypeId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -29,16 +29,13 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
||||||
'allowed_ticket_status_ids.*' => [
|
'allowed_ticket_status_ids.*' => [
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where(
|
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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$projectTemplateId = $project->project_template_id;
|
|
||||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
|
||||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'branch_name' => ['required', 'string', 'max:255'],
|
'branch_name' => ['required', 'string', 'max:255'],
|
||||||
@@ -36,7 +33,7 @@ class ValidateProjectTicketBranchRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('repository_connections', 'id')->where(
|
Rule::exists('repository_connections', 'id')->where(
|
||||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ProjectResource extends JsonResource
|
|||||||
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||||
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
|
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
|
||||||
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
|
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
|
||||||
|
'reset_requirements' => Arr::get($templateState, 'resetRequirements', []),
|
||||||
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
|
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
|
||||||
'template_id' => Arr::get($templateState, 'templateId'),
|
'template_id' => Arr::get($templateState, 'templateId'),
|
||||||
'template_name' => Arr::get($templateState, 'templateName'),
|
'template_name' => Arr::get($templateState, 'templateName'),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources;
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
@@ -17,6 +18,8 @@ class ProjectTemplateResource extends JsonResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
if ($this->resource instanceof ProjectTemplate) {
|
if ($this->resource instanceof ProjectTemplate) {
|
||||||
|
$configuration = app(DuplicateProjectConfiguration::class);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'project_id' => $this->project_id,
|
'project_id' => $this->project_id,
|
||||||
@@ -27,8 +30,10 @@ class ProjectTemplateResource extends JsonResource
|
|||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'slug' => $this->slug,
|
'slug' => $this->slug,
|
||||||
'is_published' => $this->is_published,
|
'is_published' => $this->is_published,
|
||||||
'copy_areas' => $this->copy_areas ?? [],
|
'copy_areas' => $configuration->normalizeCopyAreas($this->copy_areas ?? []),
|
||||||
'configuration_snapshot' => $this->configuration_snapshot ?? [],
|
'configuration_snapshot' => $configuration->normalizeConfiguration(
|
||||||
|
$this->configuration_snapshot ?? [],
|
||||||
|
),
|
||||||
'linked_project_count' => $this->whenCounted('linkedProjects'),
|
'linked_project_count' => $this->whenCounted('linkedProjects'),
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
'updated_at' => $this->updated_at?->toISOString(),
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
|
|||||||
+17
-1
@@ -13,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
|||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Support\Str;
|
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
|
class Project extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<ProjectFactory> */
|
/** @use HasFactory<ProjectFactory> */
|
||||||
@@ -24,6 +24,7 @@ class Project extends Model
|
|||||||
return [
|
return [
|
||||||
'show_author' => 'boolean',
|
'show_author' => 'boolean',
|
||||||
'show_story_points' => 'boolean',
|
'show_story_points' => 'boolean',
|
||||||
|
'template_overrides' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +109,21 @@ class Project extends Model
|
|||||||
->orderBy('id');
|
->orderBy('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function configurationRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'boardColumns',
|
||||||
|
'notificationActions',
|
||||||
|
'repositoryConnections',
|
||||||
|
'ticketFieldDefinitions',
|
||||||
|
'ticketLinkTypes',
|
||||||
|
'ticketPriorities',
|
||||||
|
'ticketStatuses',
|
||||||
|
'ticketTypes',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function tickets(): HasMany
|
public function tickets(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class)
|
return $this->hasMany(Ticket::class)
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||||
class TicketPriority extends Model
|
class TicketPriority extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketPriorityFactory> */
|
/** @use HasFactory<TicketPriorityFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
@@ -27,4 +29,9 @@ class TicketPriority extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,18 @@ use App\Actions\Notifications\TicketCreated;
|
|||||||
use App\Actions\Notifications\TicketStatusChanged;
|
use App\Actions\Notifications\TicketStatusChanged;
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Models\BoardColumn;
|
||||||
|
use App\Models\NotificationAction;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\RepositoryConnection;
|
||||||
use App\Models\Team;
|
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\Models\User;
|
||||||
|
use App\Observers\ProjectConfigurationObserver;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Support\Facades\Date;
|
use Illuminate\Support\Facades\Date;
|
||||||
@@ -36,6 +45,18 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
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') {
|
if ($this->app->environment() !== 'local') {
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class ProjectFactory extends Factory
|
|||||||
'key' => Str::upper(fake()->unique()->lexify('?????')),
|
'key' => Str::upper(fake()->unique()->lexify('?????')),
|
||||||
'show_author' => true,
|
'show_author' => true,
|
||||||
'show_story_points' => true,
|
'show_story_points' => true,
|
||||||
|
'template_overrides' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -44,13 +44,17 @@ return new class extends Migration
|
|||||||
'projects.name',
|
'projects.name',
|
||||||
'projects.project_template_id',
|
'projects.project_template_id',
|
||||||
'project_templates.project_id as template_project_id',
|
'project_templates.project_id as template_project_id',
|
||||||
|
'project_templates.is_published as template_is_published',
|
||||||
])
|
])
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach ($projects as $project) {
|
foreach ($projects as $project) {
|
||||||
$projectTemplateId = $project->project_template_id;
|
$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([
|
$projectTemplateId = DB::table('project_templates')->insertGetId([
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'source_project_id' => $project->id,
|
'source_project_id' => $project->id,
|
||||||
@@ -88,7 +92,7 @@ return new class extends Migration
|
|||||||
|
|
||||||
$projectModel = Project::query()->find($project->id);
|
$projectModel = Project::query()->find($project->id);
|
||||||
|
|
||||||
if ($projectModel !== null) {
|
if ($projectModel !== null && ! (bool) $project->template_is_published) {
|
||||||
DB::table('project_templates')
|
DB::table('project_templates')
|
||||||
->where('id', $projectTemplateId)
|
->where('id', $projectTemplateId)
|
||||||
->update([
|
->update([
|
||||||
|
|||||||
+63
@@ -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>
|
||||||
@@ -3,6 +3,7 @@ import { Form, Head, Link } from '@inertiajs/vue3';
|
|||||||
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -138,31 +139,15 @@ const statusAllowsTarget = (
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card v-if="templateState?.inheritsFromTemplate">
|
<TemplateInheritancePanel
|
||||||
<CardHeader>
|
:areas="[
|
||||||
<CardTitle>
|
'board_columns',
|
||||||
Template inheritance: {{ templateState.templateName }}
|
'ticket_statuses',
|
||||||
</CardTitle>
|
'status_transitions',
|
||||||
<CardDescription>
|
]"
|
||||||
This project stays linked to its template. Sections below show whether the current configuration is still inherited or has local overrides.
|
:project-id="project.id"
|
||||||
</CardDescription>
|
:template-state="templateState"
|
||||||
</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>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -50,10 +50,6 @@ const switchTemplateForm = useForm({
|
|||||||
)?.id,
|
)?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetTemplateForm = useForm({
|
|
||||||
confirmation_key: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteForm = useForm({
|
const deleteForm = useForm({
|
||||||
confirmation_key: '',
|
confirmation_key: '',
|
||||||
});
|
});
|
||||||
@@ -76,10 +72,6 @@ const canSwitchTemplate = computed(
|
|||||||
switchTemplateForm.source_template_id !== undefined,
|
switchTemplateForm.source_template_id !== undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isResetConfirmationValid = computed(
|
|
||||||
() => resetTemplateForm.confirmation_key === props.project.key,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isDeleteConfirmationValid = computed(
|
const isDeleteConfirmationValid = computed(
|
||||||
() => deleteForm.confirmation_key === props.project.key,
|
() => deleteForm.confirmation_key === props.project.key,
|
||||||
);
|
);
|
||||||
@@ -89,19 +81,12 @@ const submitSwitchTemplate = (): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switchTemplateForm.post(ProjectController.switchTemplate.url(props.project.id), {
|
switchTemplateForm.post(
|
||||||
|
ProjectController.switchTemplate.url(props.project.id),
|
||||||
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
});
|
},
|
||||||
};
|
);
|
||||||
|
|
||||||
const submitResetTemplate = (): void => {
|
|
||||||
if (!isResetConfirmationValid.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetTemplateForm.post(ProjectController.resetTemplate.url(props.project.id), {
|
|
||||||
preserveScroll: true,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitDelete = (): void => {
|
const submitDelete = (): void => {
|
||||||
@@ -209,56 +194,84 @@ const submitDelete = (): void => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Template baseline</CardTitle>
|
<CardTitle>Template baseline</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent class="space-y-6">
|
<CardContent class="space-y-6">
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
<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
|
Current linked template
|
||||||
</p>
|
</p>
|
||||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
<p
|
||||||
{{ currentTemplate?.name ?? 'No linked template' }}
|
class="text-foreground mt-2 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
currentTemplate?.name ??
|
||||||
|
'No linked template'
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
<p class="text-muted-foreground mt-2 text-sm">
|
||||||
{{
|
{{
|
||||||
templateState?.inheritsFromTemplate
|
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.'
|
: 'This project currently behaves like a standalone baseline.'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
<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
|
Warning
|
||||||
</p>
|
</p>
|
||||||
<p class="text-foreground mt-2 text-sm font-medium">
|
<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>
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-2">
|
<div class="grid gap-6">
|
||||||
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitSwitchTemplate">
|
<form
|
||||||
|
class="space-y-4 rounded-xl border p-4"
|
||||||
|
@submit.prevent="submitSwitchTemplate"
|
||||||
|
>
|
||||||
<div class="space-y-1">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="source_template_id">Published template</Label>
|
<Label for="source_template_id"
|
||||||
|
>Published template</Label
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
id="source_template_id"
|
id="source_template_id"
|
||||||
v-model="switchTemplateForm.source_template_id"
|
v-model="
|
||||||
:disabled="switchTemplateForm.processing || availableSwitchTemplates.length === 0"
|
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]"
|
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>
|
<option :value="undefined" disabled>
|
||||||
@@ -272,68 +285,54 @@ const submitDelete = (): void => {
|
|||||||
{{ template.name }}
|
{{ template.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError :message="switchTemplateForm.errors.source_template_id" />
|
<InputError
|
||||||
|
:message="
|
||||||
|
switchTemplateForm.errors
|
||||||
|
.source_template_id
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="switch_confirmation_key">Confirmation key</Label>
|
<Label for="switch_confirmation_key"
|
||||||
|
>Confirmation key</Label
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="switch_confirmation_key"
|
id="switch_confirmation_key"
|
||||||
v-model="switchTemplateForm.confirmation_key"
|
v-model="
|
||||||
|
switchTemplateForm.confirmation_key
|
||||||
|
"
|
||||||
:disabled="switchTemplateForm.processing"
|
:disabled="switchTemplateForm.processing"
|
||||||
placeholder="Type the project key exactly"
|
placeholder="Type the project key exactly"
|
||||||
/>
|
/>
|
||||||
<InputError :message="switchTemplateForm.errors.confirmation_key" />
|
<InputError
|
||||||
|
:message="
|
||||||
|
switchTemplateForm.errors
|
||||||
|
.confirmation_key
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="switchTemplateForm.processing || !canSwitchTemplate"
|
:disabled="
|
||||||
|
switchTemplateForm.processing ||
|
||||||
|
!canSwitchTemplate
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Switch template
|
Switch template
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Form, Head, Link } from '@inertiajs/vue3';
|
|||||||
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -115,31 +116,17 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card v-if="templateState?.inheritsFromTemplate">
|
<TemplateInheritancePanel
|
||||||
<CardHeader>
|
:areas="[
|
||||||
<CardTitle>
|
'project_settings',
|
||||||
Template inheritance: {{ templateState.templateName }}
|
'ticket_types',
|
||||||
</CardTitle>
|
'ticket_priorities',
|
||||||
<CardDescription>
|
'ticket_fields',
|
||||||
Inherited sections stay synced with the template baseline. Local edits in these settings are treated as project-specific overrides.
|
'ticket_link_types',
|
||||||
</CardDescription>
|
]"
|
||||||
</CardHeader>
|
:project-id="project.id"
|
||||||
<CardContent class="flex flex-wrap gap-2">
|
:template-state="templateState"
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-2">
|
<div class="grid gap-6 xl:grid-cols-2">
|
||||||
<Card class="gap-0 overflow-hidden">
|
<Card class="gap-0 overflow-hidden">
|
||||||
@@ -699,7 +686,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
|||||||
required
|
required
|
||||||
placeholder="Blocked by"
|
placeholder="Blocked by"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.counterpart_name" />
|
<InputError
|
||||||
|
:message="errors.counterpart_name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
<Button type="submit" :disabled="processing">
|
||||||
@@ -745,7 +734,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
|||||||
<Input
|
<Input
|
||||||
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
|
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
|
||||||
name="counterpart_name"
|
name="counterpart_name"
|
||||||
:default-value="ticketLinkType.counterpart_label"
|
:default-value="
|
||||||
|
ticketLinkType.counterpart_label
|
||||||
|
"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, reactive } from 'vue';
|
|||||||
import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -89,7 +90,8 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
|
|
||||||
let filterStateCounter = 0;
|
let filterStateCounter = 0;
|
||||||
|
|
||||||
const nextFilterStateId = (): string => `notification-filter-${filterStateCounter++}`;
|
const nextFilterStateId = (): string =>
|
||||||
|
`notification-filter-${filterStateCounter++}`;
|
||||||
|
|
||||||
const parseInFilterValues = (value: string): string[] =>
|
const parseInFilterValues = (value: string): string[] =>
|
||||||
value
|
value
|
||||||
@@ -100,7 +102,8 @@ const parseInFilterValues = (value: string): string[] =>
|
|||||||
const supportsFilterValue = (operator: string): boolean =>
|
const supportsFilterValue = (operator: string): boolean =>
|
||||||
!['exists', 'not_exists'].includes(operator);
|
!['exists', 'not_exists'].includes(operator);
|
||||||
|
|
||||||
const supportsArrayFilterValue = (operator: string): boolean => operator === 'in';
|
const supportsArrayFilterValue = (operator: string): boolean =>
|
||||||
|
operator === 'in';
|
||||||
|
|
||||||
const createFilter = (): NotificationFilterFormState => ({
|
const createFilter = (): NotificationFilterFormState => ({
|
||||||
attribute: defaultFilterAttribute,
|
attribute: defaultFilterAttribute,
|
||||||
@@ -159,8 +162,9 @@ const resetCreateFilters = (): void => {
|
|||||||
createFilterState.filters = [];
|
createFilterState.filters = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const editFilters = (notificationActionId: number): NotificationFilterFormState[] =>
|
const editFilters = (
|
||||||
editFilterState[notificationActionId] ?? [];
|
notificationActionId: number,
|
||||||
|
): NotificationFilterFormState[] => editFilterState[notificationActionId] ?? [];
|
||||||
|
|
||||||
const addEditFilter = (notificationActionId: number): void => {
|
const addEditFilter = (notificationActionId: number): void => {
|
||||||
editFilterState[notificationActionId] = [
|
editFilterState[notificationActionId] = [
|
||||||
@@ -169,10 +173,13 @@ const addEditFilter = (notificationActionId: number): void => {
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEditFilter = (notificationActionId: number, index: number): void => {
|
const removeEditFilter = (
|
||||||
editFilterState[notificationActionId] = editFilters(notificationActionId).filter(
|
notificationActionId: number,
|
||||||
(_, candidateIndex) => candidateIndex !== index,
|
index: number,
|
||||||
);
|
): void => {
|
||||||
|
editFilterState[notificationActionId] = editFilters(
|
||||||
|
notificationActionId,
|
||||||
|
).filter((_, candidateIndex) => candidateIndex !== index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createSerializedFilters = computed<string>(() =>
|
const createSerializedFilters = computed<string>(() =>
|
||||||
@@ -305,31 +312,11 @@ const notificationActionTimestamp = (
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card v-if="templateState?.inheritsFromTemplate">
|
<TemplateInheritancePanel
|
||||||
<CardHeader>
|
:areas="['notification_actions']"
|
||||||
<CardTitle>
|
:project-id="project.id"
|
||||||
Template inheritance: {{ templateState.templateName }}
|
:template-state="templateState"
|
||||||
</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>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -543,7 +530,10 @@ const notificationActionTimestamp = (
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="createFilterState.filters.length === 0"
|
v-if="
|
||||||
|
createFilterState.filters.length ===
|
||||||
|
0
|
||||||
|
"
|
||||||
class="text-muted-foreground text-sm"
|
class="text-muted-foreground text-sm"
|
||||||
>
|
>
|
||||||
No filters configured. This action runs
|
No filters configured. This action runs
|
||||||
@@ -552,8 +542,7 @@ const notificationActionTimestamp = (
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(
|
v-for="(
|
||||||
filter,
|
filter, filterIndex
|
||||||
filterIndex
|
|
||||||
) in createFilterState.filters"
|
) in createFilterState.filters"
|
||||||
:key="filter.id"
|
:key="filter.id"
|
||||||
class="grid gap-3 rounded-md border p-3"
|
class="grid gap-3 rounded-md border p-3"
|
||||||
@@ -572,8 +561,12 @@ const notificationActionTimestamp = (
|
|||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="filterAttributeOption in filterAttributeOptions"
|
v-for="filterAttributeOption in filterAttributeOptions"
|
||||||
:key="filterAttributeOption.value"
|
:key="
|
||||||
:value="filterAttributeOption.value"
|
filterAttributeOption.value
|
||||||
|
"
|
||||||
|
:value="
|
||||||
|
filterAttributeOption.value
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
filterAttributeOption.label
|
filterAttributeOption.label
|
||||||
@@ -595,8 +588,12 @@ const notificationActionTimestamp = (
|
|||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="filterOperatorOption in filterOperatorOptions"
|
v-for="filterOperatorOption in filterOperatorOptions"
|
||||||
:key="filterOperatorOption.value"
|
:key="
|
||||||
:value="filterOperatorOption.value"
|
filterOperatorOption.value
|
||||||
|
"
|
||||||
|
:value="
|
||||||
|
filterOperatorOption.value
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
filterOperatorOption.label
|
filterOperatorOption.label
|
||||||
@@ -607,7 +604,11 @@ const notificationActionTimestamp = (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="supportsFilterValue(filter.operator)"
|
v-if="
|
||||||
|
supportsFilterValue(
|
||||||
|
filter.operator,
|
||||||
|
)
|
||||||
|
"
|
||||||
class="grid gap-2"
|
class="grid gap-2"
|
||||||
>
|
>
|
||||||
<Label
|
<Label
|
||||||
@@ -662,7 +663,9 @@ const notificationActionTimestamp = (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InputError
|
<InputError
|
||||||
:message="notificationFilterError(errors)"
|
:message="
|
||||||
|
notificationFilterError(errors)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1029,8 +1032,7 @@ const notificationActionTimestamp = (
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(
|
v-for="(
|
||||||
filter,
|
filter, filterIndex
|
||||||
filterIndex
|
|
||||||
) in editFilters(notificationAction.id)"
|
) in editFilters(notificationAction.id)"
|
||||||
:key="filter.id"
|
:key="filter.id"
|
||||||
class="grid gap-3 rounded-md border p-3"
|
class="grid gap-3 rounded-md border p-3"
|
||||||
@@ -1049,8 +1051,12 @@ const notificationActionTimestamp = (
|
|||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="filterAttributeOption in filterAttributeOptions"
|
v-for="filterAttributeOption in filterAttributeOptions"
|
||||||
:key="filterAttributeOption.value"
|
:key="
|
||||||
:value="filterAttributeOption.value"
|
filterAttributeOption.value
|
||||||
|
"
|
||||||
|
:value="
|
||||||
|
filterAttributeOption.value
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
filterAttributeOption.label
|
filterAttributeOption.label
|
||||||
@@ -1072,8 +1078,12 @@ const notificationActionTimestamp = (
|
|||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="filterOperatorOption in filterOperatorOptions"
|
v-for="filterOperatorOption in filterOperatorOptions"
|
||||||
:key="filterOperatorOption.value"
|
:key="
|
||||||
:value="filterOperatorOption.value"
|
filterOperatorOption.value
|
||||||
|
"
|
||||||
|
:value="
|
||||||
|
filterOperatorOption.value
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
filterOperatorOption.label
|
filterOperatorOption.label
|
||||||
@@ -1084,7 +1094,11 @@ const notificationActionTimestamp = (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="supportsFilterValue(filter.operator)"
|
v-if="
|
||||||
|
supportsFilterValue(
|
||||||
|
filter.operator,
|
||||||
|
)
|
||||||
|
"
|
||||||
class="grid gap-2"
|
class="grid gap-2"
|
||||||
>
|
>
|
||||||
<Label
|
<Label
|
||||||
@@ -1144,7 +1158,9 @@ const notificationActionTimestamp = (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InputError
|
<InputError
|
||||||
:message="notificationFilterError(errors)"
|
:message="
|
||||||
|
notificationFilterError(errors)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, watch } from 'vue';
|
|||||||
import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
|
import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -277,31 +278,11 @@ const submitUpdate = (): void => {
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card v-if="templateState?.inheritsFromTemplate">
|
<TemplateInheritancePanel
|
||||||
<CardHeader>
|
:areas="['repository_connections']"
|
||||||
<CardTitle>
|
:project-id="project.id"
|
||||||
Template inheritance: {{ templateState.templateName }}
|
:template-state="templateState"
|
||||||
</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>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -320,9 +301,9 @@ const submitUpdate = (): void => {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
Without a matching personal connection, {{ appName }} only
|
Without a matching personal connection,
|
||||||
shows the repository name and links you to your user
|
{{ appName }} only shows the repository name and links
|
||||||
settings.
|
you to your user settings.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -647,8 +628,8 @@ const submitUpdate = (): void => {
|
|||||||
>
|
>
|
||||||
This project link is visible, but you need a
|
This project link is visible, but you need a
|
||||||
matching Git connection in your user
|
matching Git connection in your user
|
||||||
settings before {{ appName }} can open or query
|
settings before {{ appName }} can open or
|
||||||
this repository for you.
|
query this repository for you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end">
|
<div class="mt-4 flex justify-end">
|
||||||
@@ -683,9 +664,9 @@ const submitUpdate = (): void => {
|
|||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
<CardTitle>Detected personal sources</CardTitle>
|
<CardTitle>Detected personal sources</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{{ appName }} loads repositories from your personal
|
{{ appName }} loads repositories from your
|
||||||
Git connections and offers them for project
|
personal Git connections and offers them for
|
||||||
linking.
|
project linking.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ export type ProjectTemplateState = {
|
|||||||
inheritsFromTemplate: boolean;
|
inheritsFromTemplate: boolean;
|
||||||
overriddenAreas: ProjectTemplateStateArea[];
|
overriddenAreas: ProjectTemplateStateArea[];
|
||||||
overriddenConfiguration?: Record<string, unknown>;
|
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>;
|
templateConfiguration?: Record<string, unknown>;
|
||||||
templateId: number;
|
templateId: number;
|
||||||
templateName: string;
|
templateName: string;
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ class ProjectApiTest extends TestCase
|
|||||||
'position' => 2,
|
'position' => 2,
|
||||||
'is_parent' => false,
|
'is_parent' => false,
|
||||||
]);
|
]);
|
||||||
|
$project->forceFill([
|
||||||
|
'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['view']);
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class ProjectTemplateBaselineMigrationTest extends TestCase
|
|||||||
$this->assertSame('lgcy-base-template', $template->slug);
|
$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', [
|
Artisan::call('migrate:fresh', [
|
||||||
'--path' => [
|
'--path' => [
|
||||||
@@ -131,6 +131,42 @@ class ProjectTemplateBaselineMigrationTest extends TestCase
|
|||||||
'updated_at' => now(),
|
'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', [
|
Artisan::call('migrate', [
|
||||||
'--path' => 'database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php',
|
'--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($project->project_template_id, $ticketType->project_template_id);
|
||||||
$this->assertSame(['Bug'], array_column($snapshot[DuplicateProjectConfiguration::COPY_TICKET_TYPES], 'name'));
|
$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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace Tests\Feature\Mcp;
|
namespace Tests\Feature\Mcp;
|
||||||
|
|
||||||
use App\Actions\Notifications\DeliverNotificationAction;
|
use App\Actions\Notifications\DeliverNotificationAction;
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
|
||||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Enums\NotificationDeliveryStatus;
|
use App\Enums\NotificationDeliveryStatus;
|
||||||
use App\Mcp\Servers\KanbanGitServer;
|
use App\Mcp\Servers\KanbanGitServer;
|
||||||
use App\Mcp\Tools\GetTicket;
|
use App\Mcp\Tools\GetTicket;
|
||||||
@@ -247,6 +247,9 @@ class KanbanGitMcpTest extends TestCase
|
|||||||
'position' => 1,
|
'position' => 1,
|
||||||
'is_parent' => false,
|
'is_parent' => false,
|
||||||
]);
|
]);
|
||||||
|
$project->forceFill([
|
||||||
|
'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||||
|
])->saveQuietly();
|
||||||
$status = $project->ticketStatuses()->firstOrFail();
|
$status = $project->ticketStatuses()->firstOrFail();
|
||||||
$ticket = $this->ticket($project, $status, [
|
$ticket = $this->ticket($project, $status, [
|
||||||
'ticket_key' => 'CORE-1',
|
'ticket_key' => 'CORE-1',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Projects;
|
|||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketPriority;
|
use App\Models\TicketPriority;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
@@ -272,6 +273,9 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$project = Project::factory()->create();
|
$project = Project::factory()->create();
|
||||||
$otherProject = 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($project);
|
||||||
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Projects;
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
|
||||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
@@ -54,7 +54,9 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
->where('boardColumns.0.name', 'Todo')
|
->where('boardColumns.0.name', 'Todo')
|
||||||
->where('ticketStatuses.0.name', 'Backlog')
|
->where('ticketStatuses.0.name', 'Backlog')
|
||||||
->where('templateState.templateName', 'Starter Workflow')
|
->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([
|
$otherProject = Project::factory()->create([
|
||||||
'key' => 'EXT',
|
'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']);
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
$sourceStatus = TicketStatus::factory()->for($project)->create([
|
$sourceStatus = TicketStatus::factory()->for($project)->create([
|
||||||
|
|||||||
@@ -410,6 +410,8 @@ class ProjectControllerTest extends TestCase
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect()->assertSessionHasNoErrors();
|
||||||
|
|
||||||
$project = Project::query()->where('key', 'DUPE')->firstOrFail();
|
$project = Project::query()->where('key', 'DUPE')->firstOrFail();
|
||||||
|
|
||||||
$response->assertRedirect(route('projects.show', $project));
|
$response->assertRedirect(route('projects.show', $project));
|
||||||
|
|||||||
@@ -243,6 +243,13 @@ class ProjectTemplateControllerTest extends TestCase
|
|||||||
'position' => 1,
|
'position' => 1,
|
||||||
'is_parent' => false,
|
'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([
|
$sourceProject->forceFill([
|
||||||
'show_author' => true,
|
'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;
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
|
||||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
@@ -55,6 +55,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
$project->update([
|
$project->update([
|
||||||
'project_template_id' => $template->id,
|
'project_template_id' => $template->id,
|
||||||
'show_story_points' => false,
|
'show_story_points' => false,
|
||||||
|
'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|||||||
Reference in New Issue
Block a user