ticket-rework #27

Merged
Twyco merged 8 commits from ticket-rework into codex/permissions-refactoring 2026-04-14 16:42:26 +00:00
45 changed files with 1208 additions and 420 deletions
Showing only changes of commit 182ed018d1 - Show all commits
@@ -8,26 +8,24 @@ class InitializeProjectTicketMetadata
{ {
public function handle(Project $project): void public function handle(Project $project): void
{ {
$project->ticketTypes()->create( if (! $project->ticketTypes()->exists()) {
[ $project->ticketTypes()->create([
'name' => 'Task', 'name' => 'Task',
'position' => 1, 'position' => 1,
], ]);
);
$project->ticketTypes()->create( $project->ticketTypes()->create([
[
'name' => 'Release', 'name' => 'Release',
'is_parent' => true, 'is_parent' => true,
'position' => 2, 'position' => 2,
], ]);
); }
$project->ticketPriorities()->create( if (! $project->ticketPriorities()->exists()) {
[ $project->ticketPriorities()->create([
'name' => 'Medium', 'name' => 'Medium',
'position' => 1, 'position' => 1,
] ]);
); }
} }
} }
+10 -6
View File
@@ -5,7 +5,7 @@ namespace App\Actions\Tickets;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class CreateProjectTicket class CreateProjectTicket
@@ -17,7 +17,9 @@ class CreateProjectTicket
/** /**
* @param array{ * @param array{
* acceptance_criteria: string|null, * acceptance_criteria: string|null,
* assignee_user_id?: int|null,
* description: string|null, * description: string|null,
* story_points?: int|null,
* ticket_priority_id: int, * ticket_priority_id: int,
* ticket_type_id: int, * ticket_type_id: int,
* title: string * title: string
@@ -31,15 +33,17 @@ class CreateProjectTicket
->max('sequence_number') + 1; ->max('sequence_number') + 1;
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
'acceptance_criteria' => $attributes['acceptance_criteria'], 'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'),
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
'author_user_id' => $actor?->id,
'board_column_id' => null, 'board_column_id' => null,
'description' => $attributes['description'], 'description' => Arr::get($attributes, 'description'),
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => $nextSequenceNumber, 'sequence_number' => $nextSequenceNumber,
'ticket_key' => "{$project->key}-{$nextSequenceNumber}", 'ticket_key' => "{$project->key}-{$nextSequenceNumber}",
'ticket_priority_id' => $attributes['ticket_priority_id'], 'ticket_priority_id' => Arr::get($attributes, 'ticket_priority_id'),
'ticket_type_id' => $attributes['ticket_type_id'], 'ticket_type_id' => Arr::get($attributes, 'ticket_type_id'),
'title' => $attributes['title'], 'title' => Arr::get($attributes, 'title'),
'story_points' => Arr::get($attributes, 'story_points'), 'story_points' => Arr::get($attributes, 'story_points'),
]); ]);
@@ -18,9 +18,12 @@ class UpdateProjectTicket
/** /**
* @param array{ * @param array{
* acceptance_criteria: string|null, * acceptance_criteria: string|null,
* assignee_user_id: int|null,
* author_user_id: int|null,
* blocked_comment: string|null, * blocked_comment: string|null,
* description: string|null, * description: string|null,
* is_blocked: bool, * is_blocked: bool,
* story_points: int|null,
* ticket_priority_id: int, * ticket_priority_id: int,
* ticket_type_id: int, * ticket_type_id: int,
* parent_ticket_id: int|null, * parent_ticket_id: int|null,
@@ -44,6 +47,8 @@ class UpdateProjectTicket
} }
$ticket->loadMissing([ $ticket->loadMissing([
'assignee',
'author',
'fieldValues.ticketFieldDefinition', 'fieldValues.ticketFieldDefinition',
'fieldValues.userValue', 'fieldValues.userValue',
'parentTicket', 'parentTicket',
@@ -103,6 +108,14 @@ class UpdateProjectTicket
$ticket->blocked_comment, $ticket->blocked_comment,
$ticketAttributes['blocked_comment'], $ticketAttributes['blocked_comment'],
); );
$this->trackNumberChange(
$changes,
$summaryLabels,
'story_points',
'story points',
$ticket->story_points,
$ticketAttributes['story_points'],
);
if ($ticket->ticket_type_id !== $ticketAttributes['ticket_type_id']) { if ($ticket->ticket_type_id !== $ticketAttributes['ticket_type_id']) {
$changes['ticket_type'] = [ $changes['ticket_type'] = [
@@ -140,6 +153,26 @@ class UpdateProjectTicket
$summaryLabels[] = 'parent ticket'; $summaryLabels[] = 'parent ticket';
} }
if ($ticket->assignee_user_id !== $ticketAttributes['assignee_user_id']) {
$changes['assignee'] = [
'from' => $ticket->assignee?->name,
'to' => $ticketAttributes['assignee_user_id'] === null
? null
: User::query()->find($ticketAttributes['assignee_user_id'])?->name,
];
$summaryLabels[] = 'assignee';
}
if ($ticket->author_user_id !== $ticketAttributes['author_user_id']) {
$changes['author'] = [
'from' => $ticket->author?->name,
'to' => $ticketAttributes['author_user_id'] === null
? null
: User::query()->find($ticketAttributes['author_user_id'])?->name,
];
$summaryLabels[] = 'author';
}
$ticket->fill($ticketAttributes); $ticket->fill($ticketAttributes);
if ($ticket->isDirty()) { if ($ticket->isDirty()) {
@@ -217,6 +250,29 @@ class UpdateProjectTicket
$summaryLabels[] = $summaryLabel; $summaryLabels[] = $summaryLabel;
} }
/**
* @param array<string, mixed> $changes
* @param array<int, string> $summaryLabels
*/
private function trackNumberChange(
array &$changes,
array &$summaryLabels,
string $changeKey,
string $summaryLabel,
?int $currentValue,
?int $newValue,
): void {
if ($currentValue === $newValue) {
return;
}
$changes[$changeKey] = [
'from' => $currentValue,
'to' => $newValue,
];
$summaryLabels[] = $summaryLabel;
}
/** /**
* @param array<string, mixed> $changes * @param array<string, mixed> $changes
* @param array{value_user_id?: int|string|null} $fieldValueAttribute * @param array{value_user_id?: int|string|null} $fieldValueAttribute
@@ -8,6 +8,7 @@ use App\Enums\Permission;
use App\Http\Requests\StoreProjectTicketRequest; use App\Http\Requests\StoreProjectTicketRequest;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -43,6 +44,9 @@ class ProjectBacklogController extends Controller
->whereNull('board_column_id') ->whereNull('board_column_id')
->orderByDesc('sequence_number') ->orderByDesc('sequence_number')
->get(), ->get(),
'users' => User::query()
->orderBy('name')
->get(),
]); ]);
Twyco marked this conversation as resolved
Review

Alle Users getten klingt mir komisch? Angleichen auf user in dem Project?

Alle Users getten klingt mir komisch? Angleichen auf user in dem Project?
} }
@@ -38,10 +38,28 @@ class ProjectTicketController extends Controller
403, 403,
); );
$canEditTicket = $this->permissionService->allows($user, Permission::ProjectWrite, $project);
$canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project);
$excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)]; $excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)];
$displayTicket = $ticket->load([
'assignee',
'author',
'fieldValues.ticketFieldDefinition',
'fieldValues.userValue',
'parentTicket',
'ticketPriority',
'ticketType',
'worklogEntries.actor',
]);
if (! $project->show_author && ! $canManageProject) {
$displayTicket->setRelation('author', null);
$displayTicket->author_user_id = null;
}
return Inertia::render('projects/Ticket', [ return Inertia::render('projects/Ticket', [
'canEditTicket' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), 'canEditTicket' => $canEditTicket,
'canManageProject' => $canManageProject,
'childTicketTree' => $this->buildChildTicketTree($ticket), 'childTicketTree' => $this->buildChildTicketTree($ticket),
'linkedTickets' => $this->linkedTickets($ticket), 'linkedTickets' => $this->linkedTickets($ticket),
'parentTicketOptions' => Ticket::query() 'parentTicketOptions' => Ticket::query()
@@ -68,14 +86,7 @@ class ProjectTicketController extends Controller
), ),
'status' => session('status'), 'status' => session('status'),
'suggestedBranchName' => $this->buildSuggestedTicketBranchName->handle($ticket), 'suggestedBranchName' => $this->buildSuggestedTicketBranchName->handle($ticket),
'ticket' => $ticket->load([ 'ticket' => $displayTicket,
'fieldValues.ticketFieldDefinition',
'fieldValues.userValue',
'parentTicket',
'ticketPriority',
'ticketType',
'worklogEntries.actor',
]),
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(), 'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
'ticketLinkOptions' => Ticket::query() 'ticketLinkOptions' => Ticket::query()
->where('project_id', $project->id) ->where('project_id', $project->id)
@@ -109,17 +120,25 @@ class ProjectTicketController extends Controller
); );
$validated = $request->validated(); $validated = $request->validated();
$fieldValues = Arr::get($validated, 'field_values'); $fieldValues = Arr::get($validated, 'field_values', []);
$ticketAttributes = [ $ticketAttributes = [
'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'), 'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'),
'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
? Arr::get($validated, 'assignee_user_id')
: $ticket->assignee_user_id,
'author_user_id' => Arr::exists($validated, 'author_user_id')
? Arr::get($validated, 'author_user_id')
: $ticket->author_user_id,
'blocked_comment' => Arr::get($validated, 'blocked_comment'), 'blocked_comment' => Arr::get($validated, 'blocked_comment'),
'description' => Arr::get($validated, 'description'), 'description' => Arr::get($validated, 'description'),
'is_blocked' => Arr::get($validated, 'is_blocked', false), 'is_blocked' => Arr::get($validated, 'is_blocked', false),
'parent_ticket_id' => Arr::get($validated, 'parent_ticket_id'), 'parent_ticket_id' => Arr::get($validated, 'parent_ticket_id'),
'story_points' => Arr::exists($validated, 'story_points')
? Arr::get($validated, 'story_points')
: $ticket->story_points,
'ticket_priority_id' => Arr::get($validated, 'ticket_priority_id'), 'ticket_priority_id' => Arr::get($validated, 'ticket_priority_id'),
'ticket_type_id' => Arr::get($validated, 'ticket_type_id'), 'ticket_type_id' => Arr::get($validated, 'ticket_type_id'),
'title' => Arr::get($validated, 'title'), 'title' => Arr::get($validated, 'title'),
'story_points' => Arr::get($validated, 'story_points'),
]; ];
$wasUpdated = $this->updateProjectTicket->handle( $wasUpdated = $this->updateProjectTicket->handle(
@@ -3,14 +3,18 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateTicketWorklogEntry;
use App\Enums\Permission; use App\Enums\Permission;
use App\Http\Requests\DestroyProjectTicketTypeRequest;
use App\Http\Requests\StoreProjectNamedSettingRequest; use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreProjectTicketTypeRequest; use App\Http\Requests\StoreProjectTicketTypeRequest;
use App\Http\Requests\StoreTicketFieldDefinitionRequest; use App\Http\Requests\StoreTicketFieldDefinitionRequest;
use App\Http\Requests\UpdateProjectNamedSettingRequest; use App\Http\Requests\UpdateProjectNamedSettingRequest;
use App\Http\Requests\UpdateProjectTicketMetadataSettings; use App\Http\Requests\UpdateProjectTicketMetadataSettings;
use App\Http\Requests\UpdateProjectTicketTypeRequest;
use App\Http\Requests\UpdateTicketFieldDefinitionRequest; use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition; use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType; use App\Models\TicketLinkType;
use App\Models\TicketPriority; use App\Models\TicketPriority;
@@ -27,6 +31,7 @@ use Inertia\Response;
class ProjectTicketMetadataSettingsController extends Controller class ProjectTicketMetadataSettingsController extends Controller
{ {
public function __construct( public function __construct(
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly PermissionService $permissionService, private readonly PermissionService $permissionService,
) {} ) {}
@@ -47,15 +52,20 @@ class ProjectTicketMetadataSettingsController extends Controller
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(), 'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
'ticketLinkTypes' => $project->ticketLinkTypes()->get(), 'ticketLinkTypes' => $project->ticketLinkTypes()->get(),
'ticketPriorities' => $project->ticketPriorities()->get(), 'ticketPriorities' => $project->ticketPriorities()->get(),
'ticketTypes' => $project->ticketTypes()->get(), 'ticketTypes' => $project->ticketTypes()->withCount('tickets')->get(),
]); ]);
} }
public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse
{ {
$this->abortUnlessCanManage($request, $project);
$validated = $request->validated(); $validated = $request->validated();
$project->update(['show_story_points' => Arr::get($validated, 'show_story_points', false)]); $project->update([
'show_author' => Arr::get($validated, 'show_author', false),
'show_story_points' => Arr::get($validated, 'show_story_points', false),
]);
return to_route('projects.settings.metadata', $project) return to_route('projects.settings.metadata', $project)
->with('status', 'Settings have been updated.'); ->with('status', 'Settings have been updated.');
@@ -88,7 +98,7 @@ class ProjectTicketMetadataSettingsController extends Controller
} }
public function updateType( public function updateType(
UpdateProjectNamedSettingRequest $request, UpdateProjectTicketTypeRequest $request,
Project $project, Project $project,
TicketType $ticketType, TicketType $ticketType,
): RedirectResponse { ): RedirectResponse {
@@ -99,7 +109,6 @@ class ProjectTicketMetadataSettingsController extends Controller
DB::transaction(function () use ($project, $validated, $ticketType): void { DB::transaction(function () use ($project, $validated, $ticketType): void {
$ticketType->update([ $ticketType->update([
'name' => Arr::get($validated, 'name'), 'name' => Arr::get($validated, 'name'),
'is_parent' => Arr::get($validated, 'is_parent', false),
]); ]);
$this->reorderModels( $this->reorderModels(
@@ -113,8 +122,11 @@ class ProjectTicketMetadataSettingsController extends Controller
->with('status', "{$ticketType->fresh()->name} type was updated."); ->with('status', "{$ticketType->fresh()->name} type was updated.");
} }
public function destroyType(Request $request, Project $project, TicketType $ticketType): RedirectResponse public function destroyType(
{ DestroyProjectTicketTypeRequest $request,
Project $project,
TicketType $ticketType,
): RedirectResponse {
$this->abortUnlessCanManage($request, $project); $this->abortUnlessCanManage($request, $project);
if ($project->ticketTypes()->count() === 1) { if ($project->ticketTypes()->count() === 1) {
@@ -124,8 +136,40 @@ class ProjectTicketMetadataSettingsController extends Controller
} }
$deletedName = $ticketType->name; $deletedName = $ticketType->name;
$validated = $request->validated();
DB::transaction(function () use ($deletedName, $project, $request, $ticketType, $validated): void {
$replacementTicketTypeId = Arr::get($validated, 'replacement_ticket_type_id');
if ($replacementTicketTypeId !== null) {
$replacementTicketType = $project->ticketTypes()
->findOrFail($replacementTicketTypeId);
Ticket::query()
->where('project_id', $project->id)
->where('ticket_type_id', $ticketType->id)
->with(['ticketType'])
->get()
->each(function (Ticket $ticket) use ($deletedName, $replacementTicketType, $request): void {
$ticket->update([
'ticket_type_id' => $replacementTicketType->id,
]);
$this->createTicketWorklogEntry->handle(
$ticket->fresh(),
$request->user(),
'updated',
"Updated type because {$deletedName} type was deleted.",
[
'ticket_type' => [
'from' => $deletedName,
'to' => $replacementTicketType->name,
],
],
);
});
}
DB::transaction(function () use ($project, $ticketType): void {
$ticketType->delete(); $ticketType->delete();
$this->persistOrderedModels($project->ticketTypes()->get()->values()); $this->persistOrderedModels($project->ticketTypes()->get()->values());
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use App\Models\TicketType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class DestroyProjectTicketTypeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
return [
'replacement_ticket_type_id' => [
'nullable',
'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
],
];
}
/**
* @return array<int, callable(Validator): void>
*/
public function after(): array
{
return [
function (Validator $validator): void {
/** @var Project $project */
$project = $this->route('project');
/** @var TicketType $ticketType */
$ticketType = $this->route('ticketType');
$hasReplacementOptions = $project->ticketTypes()
->whereKeyNot($ticketType->id)
->exists();
if (! $hasReplacementOptions || ! $ticketType->tickets()->exists()) {
return;
}
/** @var array<string, mixed> $validated */
$validated = $this->safe()->all();
$replacementTicketTypeId = Arr::get($validated, 'replacement_ticket_type_id');
if ($replacementTicketTypeId === null) {
$validator->errors()->add(
'replacement_ticket_type_id',
'Choose a replacement type before deleting a type that is assigned to tickets.',
);
return;
}
if ((int) $replacementTicketTypeId === $ticketType->id) {
$validator->errors()->add(
'replacement_ticket_type_id',
'The replacement type must be different from the deleted type.',
);
return;
}
$replacementTicketType = $project->ticketTypes()
->find($replacementTicketTypeId);
if (
$replacementTicketType !== null
&& ! $replacementTicketType->is_parent
&& $ticketType->tickets()->has('childTickets')->exists()
) {
$validator->errors()->add(
'replacement_ticket_type_id',
'Choose a parent-enabled replacement type because affected tickets already have child tickets.',
);
}
},
];
}
}
@@ -29,6 +29,7 @@ class StoreProjectTicketRequest extends FormRequest
return [ return [
'acceptance_criteria' => ['nullable', 'string'], 'acceptance_criteria' => ['nullable', 'string'],
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'ticket_priority_id' => [ 'ticket_priority_id' => [
'required', 'required',
@@ -25,7 +25,6 @@ class UpdateProjectNamedSettingRequest extends FormRequest
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'position' => ['required', 'integer', 'min:1'], 'position' => ['required', 'integer', 'min:1'],
'is_parent' => ['nullable', 'boolean'],
]; ];
} }
} }
@@ -23,7 +23,8 @@ class UpdateProjectTicketMetadataSettings extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'show_story_points' => 'nullable|boolean', 'show_author' => ['nullable', 'boolean'],
'show_story_points' => ['nullable', 'boolean'],
]; ];
} }
} }
@@ -2,6 +2,8 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketType; use App\Models\TicketType;
@@ -33,6 +35,8 @@ class UpdateProjectTicketRequest extends FormRequest
return [ return [
'acceptance_criteria' => ['nullable', 'string'], 'acceptance_criteria' => ['nullable', 'string'],
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'blocked_comment' => ['nullable', 'string'], 'blocked_comment' => ['nullable', 'string'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'field_values' => ['array'], 'field_values' => ['array'],
@@ -61,7 +65,7 @@ class UpdateProjectTicketRequest extends FormRequest
Rule::exists('ticket_types', 'id')->where('project_id', $project->id), Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
], ],
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'story_points' => ['nullable', 'integer', 'gt:0'], 'story_points' => ['nullable', 'integer', 'min:0'],
]; ];
} }
@@ -73,7 +77,7 @@ class UpdateProjectTicketRequest extends FormRequest
return [ return [
function (Validator $validator): void { function (Validator $validator): void {
/** @var array<string, mixed> $validated */ /** @var array<string, mixed> $validated */
$validated = $this->validated(); $validated = $this->safe()->all();
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
@@ -81,6 +85,9 @@ class UpdateProjectTicketRequest extends FormRequest
/** @var Ticket $ticket */ /** @var Ticket $ticket */
$ticket = $this->route('ticket'); $ticket = $this->route('ticket');
$canManageProject = app(PermissionService::class)
->allows($this->user(), Permission::ProjectAdmin, $project);
$parentTicketId = Arr::get($validated, 'parent_ticket_id'); $parentTicketId = Arr::get($validated, 'parent_ticket_id');
if ($parentTicketId !== null) { if ($parentTicketId !== null) {
@@ -95,8 +102,8 @@ class UpdateProjectTicketRequest extends FormRequest
->with('ticketType') ->with('ticketType')
->find($parentTicketId); ->find($parentTicketId);
if ($parentTicket !== null && $parentTicket->ticketType?->system_key !== 'parent') { if ($parentTicket !== null && ! $parentTicket->ticketType?->is_parent) {
$validator->errors()->add('parent_ticket_id', 'Only tickets of type Parent can be selected as a parent.'); $validator->errors()->add('parent_ticket_id', 'Only tickets with a parent-enabled type can be selected as a parent.');
return; return;
} }
@@ -114,15 +121,23 @@ class UpdateProjectTicketRequest extends FormRequest
->find($requestedTicketTypeId); ->find($requestedTicketTypeId);
if ( if (
$requestedTicketType?->system_key !== 'parent' ! $requestedTicketType?->is_parent
&& $ticket->childTickets()->exists() && $ticket->childTickets()->exists()
) { ) {
$validator->errors()->add('ticket_type_id', 'Tickets with child tickets must keep a Parent type.'); $validator->errors()->add('ticket_type_id', 'Tickets with child tickets must keep a parent-enabled type.');
} }
if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) { if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) {
$validator->errors()->add('blocked_comment', 'Blocked tickets require a blocker comment.'); $validator->errors()->add('blocked_comment', 'Blocked tickets require a blocker comment.');
} }
if (
Arr::exists($validated, 'author_user_id')
&& (int) Arr::get($validated, 'author_user_id') !== (int) $ticket->author_user_id
&& ! $canManageProject
) {
$validator->errors()->add('author_user_id', 'Only project admins can change the author.');
}
}, },
]; ];
} }
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProjectTicketTypeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'position' => ['required', 'integer', 'min:1'],
];
}
}
+2 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Fillable(['name', 'key', 'show_story_points'])] #[Fillable(['name', 'key', 'show_author', 'show_story_points'])]
class Project extends Model class Project extends Model
{ {
/** @use HasFactory<ProjectFactory> */ /** @use HasFactory<ProjectFactory> */
@@ -20,6 +20,7 @@ class Project extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'show_author' => 'boolean',
'show_story_points' => 'boolean', 'show_story_points' => 'boolean',
]; ];
} }
+12
View File
@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([ #[Fillable([
'acceptance_criteria', 'acceptance_criteria',
'assignee_user_id',
'author_user_id',
'board_column_id', 'board_column_id',
'blocked_comment', 'blocked_comment',
'description', 'description',
@@ -44,6 +46,16 @@ class Ticket extends Model
return $this->belongsTo(BoardColumn::class); return $this->belongsTo(BoardColumn::class);
} }
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_user_id');
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_user_id');
}
public function childTickets(): HasMany public function childTickets(): HasMany
{ {
return $this->hasMany(Ticket::class, 'parent_ticket_id') return $this->hasMany(Ticket::class, 'parent_ticket_id')
+6
View File
@@ -7,6 +7,7 @@ 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', 'is_parent'])] #[Fillable(['name', 'position', 'project_id', 'is_parent'])]
class TicketType extends Model class TicketType extends Model
@@ -25,4 +26,9 @@ class TicketType extends Model
{ {
return $this->belongsTo(Project::class); return $this->belongsTo(Project::class);
} }
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class);
}
} }
+10
View File
@@ -59,4 +59,14 @@ class User extends Authenticatable
->orderBy('provider') ->orderBy('provider')
->orderBy('base_url'); ->orderBy('base_url');
} }
public function assignedTickets(): HasMany
{
return $this->hasMany(Ticket::class, 'assignee_user_id');
}
public function authoredTickets(): HasMany
{
return $this->hasMany(Ticket::class, 'author_user_id');
}
} }
+2
View File
@@ -21,6 +21,8 @@ class ProjectFactory extends Factory
return [ return [
'name' => Str::title(fake()->unique()->words(asText: true)), 'name' => Str::title(fake()->unique()->words(asText: true)),
'key' => Str::upper(fake()->unique()->lexify('?????')), 'key' => Str::upper(fake()->unique()->lexify('?????')),
'show_author' => true,
'show_story_points' => true,
]; ];
} }
} }
+5 -7
View File
@@ -5,7 +5,7 @@ namespace Database\Factories;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketPriority; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@@ -22,12 +22,15 @@ class TicketFactory extends Factory
{ {
return [ return [
'acceptance_criteria' => fake()->paragraph(), 'acceptance_criteria' => fake()->paragraph(),
'assignee_user_id' => null,
'author_user_id' => User::factory(),
'board_column_id' => null, 'board_column_id' => null,
'blocked_comment' => null, 'blocked_comment' => null,
'description' => fake()->paragraphs(2, true), 'description' => fake()->paragraphs(2, true),
'is_blocked' => false, 'is_blocked' => false,
'parent_ticket_id' => null, 'parent_ticket_id' => null,
'sequence_number' => 1, 'sequence_number' => 1,
'story_points' => null,
'ticket_key' => 'TEST-1', 'ticket_key' => 'TEST-1',
'title' => fake()->sentence(4), 'title' => fake()->sentence(4),
]; ];
@@ -46,14 +49,9 @@ class TicketFactory extends Factory
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$ticket->project_id = $project->id; $ticket->project_id = $project->id;
$ticket->ticket_type_id = $project->ticketTypes()->firstOrFail()->id; $ticket->ticket_type_id = $project->ticketTypes()->firstOrFail()->id;
$ticket->ticket_priority_id = $priority->id; $ticket->ticket_priority_id = $project->ticketPriorities()->firstOrFail()->id;
$ticket->sequence_number = 1; $ticket->sequence_number = 1;
$ticket->ticket_key = "{$project->key}-1"; $ticket->ticket_key = "{$project->key}-1";
}); });
@@ -39,7 +39,7 @@ class TicketFieldValueFactory extends Factory
$ticketFieldDefinition = $ticketFieldValue->ticket_field_definition_id === null $ticketFieldDefinition = $ticketFieldValue->ticket_field_definition_id === null
? TicketFieldDefinition::factory()->for($ticket->project)->create([ ? TicketFieldDefinition::factory()->for($ticket->project)->create([
'field_type' => 'number', 'field_type' => 'number',
'label' => 'Story Points', 'label' => 'Custom Number',
'position' => $ticket->project->ticketFieldDefinitions()->count() + 1, 'position' => $ticket->project->ticketFieldDefinitions()->count() + 1,
]) ])
: TicketFieldDefinition::query()->findOrFail($ticketFieldValue->ticket_field_definition_id); : TicketFieldDefinition::query()->findOrFail($ticketFieldValue->ticket_field_definition_id);
+1 -2
View File
@@ -19,11 +19,10 @@ class TicketTypeFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'is_builtin' => false, 'is_parent' => false,
'name' => fake()->unique()->jobTitle(), 'name' => fake()->unique()->jobTitle(),
'position' => 1, 'position' => 1,
'project_id' => Project::factory(), 'project_id' => Project::factory(),
'system_key' => null,
]; ];
} }
} }
@@ -15,6 +15,7 @@ return new class extends Migration
$table->id(); $table->id();
$table->string('name'); $table->string('name');
$table->string('key', 5)->unique(); $table->string('key', 5)->unique();
$table->boolean('show_author')->default(true);
$table->boolean('show_story_points')->default(true); $table->boolean('show_story_points')->default(true);
$table->timestamps(); $table->timestamps();
}); });
@@ -16,6 +16,8 @@ return new class extends Migration
$table->foreignId('project_id')->constrained()->cascadeOnDelete(); $table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('ticket_type_id')->constrained()->cascadeOnDelete(); $table->foreignId('ticket_type_id')->constrained()->cascadeOnDelete();
$table->foreignId('ticket_priority_id')->constrained()->cascadeOnDelete(); $table->foreignId('ticket_priority_id')->constrained()->cascadeOnDelete();
$table->foreignId('assignee_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('author_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('board_column_id')->nullable()->constrained()->nullOnDelete(); $table->foreignId('board_column_id')->nullable()->constrained()->nullOnDelete();
$table->unsignedInteger('sequence_number'); $table->unsignedInteger('sequence_number');
$table->string('ticket_key')->unique(); $table->string('ticket_key')->unique();
@@ -4,9 +4,6 @@ namespace Database\Seeders;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketFieldDefinition;
use App\Models\TicketPriority;
use App\Models\TicketType;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class ProjectTicketMetadataSeeder extends Seeder class ProjectTicketMetadataSeeder extends Seeder
@@ -24,32 +21,26 @@ class ProjectTicketMetadataSeeder extends Seeder
->each(function (Project $project) use ($initializeProjectTicketMetadata): void { ->each(function (Project $project) use ($initializeProjectTicketMetadata): void {
$initializeProjectTicketMetadata->handle($project); $initializeProjectTicketMetadata->handle($project);
TicketType::factory() $project->ticketTypes()->updateOrCreate(
->for($project) ['name' => 'Bug'],
->create([ [
'name' => 'Bug', 'is_parent' => false,
'position' => 3, 'position' => 3,
'system_key' => null, ],
]); );
TicketPriority::factory() $project->ticketPriorities()->updateOrCreate(
->count(3) ['name' => 'Medium'],
->sequence( ['position' => 2],
['name' => 'Low', 'position' => 1], );
['name' => 'Medium', 'position' => 2], $project->ticketPriorities()->updateOrCreate(
['name' => 'High', 'position' => 3], ['name' => 'Low'],
) ['position' => 1],
->for($project) );
->create(); $project->ticketPriorities()->updateOrCreate(
['name' => 'High'],
TicketFieldDefinition::factory() ['position' => 3],
->count(2) );
->sequence(
['label' => 'Assignee', 'field_type' => 'user', 'position' => 1],
['label' => 'Story Points', 'field_type' => 'number', 'position' => 2],
)
->for($project)
->create();
}); });
} }
} }
+10 -3
View File
@@ -6,6 +6,7 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\CreateProjectTicket; use App\Actions\Tickets\CreateProjectTicket;
use App\Models\Project; use App\Models\Project;
use App\Models\TicketPriority; use App\Models\TicketPriority;
use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class TicketSeeder extends Seeder class TicketSeeder extends Seeder
@@ -17,11 +18,13 @@ class TicketSeeder extends Seeder
{ {
$createProjectTicket = app(CreateProjectTicket::class); $createProjectTicket = app(CreateProjectTicket::class);
$initializeProjectTicketMetadata = app(InitializeProjectTicketMetadata::class); $initializeProjectTicketMetadata = app(InitializeProjectTicketMetadata::class);
$author = User::query()->orderBy('id')->first();
$assignee = User::query()->orderBy('id')->skip(1)->first() ?? $author;
Project::query() Project::query()
->orderBy('id') ->orderBy('id')
->get() ->get()
->each(function (Project $project) use ($createProjectTicket, $initializeProjectTicketMetadata): void { ->each(function (Project $project) use ($assignee, $author, $createProjectTicket, $initializeProjectTicketMetadata): void {
$initializeProjectTicketMetadata->handle($project); $initializeProjectTicketMetadata->handle($project);
$priority = $project->ticketPriorities()->first(); $priority = $project->ticketPriorities()->first();
@@ -37,19 +40,23 @@ class TicketSeeder extends Seeder
$createProjectTicket->handle($project, [ $createProjectTicket->handle($project, [
'acceptance_criteria' => 'The seeded ticket should be visible in the backlog list.', 'acceptance_criteria' => 'The seeded ticket should be visible in the backlog list.',
'assignee_user_id' => $assignee?->id,
'description' => 'Seeded backlog item for demonstrating the first ticket workflow.', 'description' => 'Seeded backlog item for demonstrating the first ticket workflow.',
'story_points' => 3,
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $defaultType->id, 'ticket_type_id' => $defaultType->id,
'title' => 'Seed backlog item', 'title' => 'Seed backlog item',
]); ], $author);
$createProjectTicket->handle($project, [ $createProjectTicket->handle($project, [
'acceptance_criteria' => 'Duplicate titles must be allowed for project-local ticket keys.', 'acceptance_criteria' => 'Duplicate titles must be allowed for project-local ticket keys.',
'assignee_user_id' => null,
'description' => 'Second seeded backlog item using the same title on purpose.', 'description' => 'Second seeded backlog item using the same title on purpose.',
'story_points' => 5,
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $defaultType->id, 'ticket_type_id' => $defaultType->id,
'title' => 'Seed backlog item', 'title' => 'Seed backlog item',
]); ], $author);
}); });
} }
} }
+23
View File
@@ -182,6 +182,29 @@ const hasTicketMetadata =
/> />
</div> </div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
name="assignee_user_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 transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="errors.assignee_user_id"
/>
</div>
<div <div
class="grid gap-2" class="grid gap-2"
v-if="project.show_story_points" v-if="project.show_story_points"
+60
View File
@@ -66,6 +66,12 @@ const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
const canCreateBranches = computed( const canCreateBranches = computed(
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0, () => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
); );
const canEditAuthor = computed(
() => props.canEditTicket && props.canManageProject,
);
const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject,
);
const defaultBranchRepositoryConnectionId = computed(() => const defaultBranchRepositoryConnectionId = computed(() =>
String( String(
@@ -97,6 +103,8 @@ const breadcrumbs: BreadcrumbItem[] = [
const form = useForm({ const form = useForm({
acceptance_criteria: props.ticket.acceptance_criteria ?? '', acceptance_criteria: props.ticket.acceptance_criteria ?? '',
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '', blocked_comment: props.ticket.blocked_comment ?? '',
description: props.ticket.description ?? '', description: props.ticket.description ?? '',
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => { field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
@@ -461,6 +469,58 @@ const formatDateTime = (value: string): string => {
</div> </div>
</div> </div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="
!canEditTicket || form.processing
"
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] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div v-if="canViewAuthor" class="grid gap-2">
<Label for="author_user_id">Author</Label>
<select
id="author_user_id"
v-model="form.author_user_id"
:disabled="
!canEditAuthor || form.processing
"
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] disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">No author</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.author_user_id"
/>
</div>
</div>
<div <div
class="grid gap-2" class="grid gap-2"
v-if="project.show_story_points" v-if="project.show_story_points"
@@ -44,6 +44,9 @@ const breadcrumbs: BreadcrumbItem[] = [
href: metadataSettings(props.project.id), href: metadataSettings(props.project.id),
}, },
]; ];
const replacementTicketTypes = (ticketTypeId: number) =>
props.ticketTypes.filter((ticketType) => ticketType.id !== ticketTypeId);
</script> </script>
<template> <template>
@@ -133,6 +136,18 @@ const breadcrumbs: BreadcrumbItem[] = [
<InputError :message="errors.show_story_points" /> <InputError :message="errors.show_story_points" />
<Label for="enable-author">
<Checkbox
id="enable-author"
name="show_author"
:default-value="project.show_author"
value="1"
/>
Show author on ticket details
</Label>
<InputError :message="errors.show_author" />
<Button type="submit" :disabled="processing"> <Button type="submit" :disabled="processing">
Save Save
</Button> </Button>
@@ -171,7 +186,7 @@ const breadcrumbs: BreadcrumbItem[] = [
name="is_parent" name="is_parent"
value="1" value="1"
/> />
Ticket type is can be a parent Ticket type can act as a parent
</Label> </Label>
<InputError :message="errors.is_parent" /> <InputError :message="errors.is_parent" />
</div> </div>
@@ -196,6 +211,20 @@ const breadcrumbs: BreadcrumbItem[] = [
v-slot="{ errors, processing }" v-slot="{ errors, processing }"
> >
<div class="grid gap-2"> <div class="grid gap-2">
<div
v-if="ticketType.is_parent"
class="flex flex-wrap items-center gap-2"
>
<Badge variant="secondary">
Parent type
</Badge>
<span
class="text-muted-foreground text-xs"
>
This flag is fixed after creation.
</span>
</div>
<Label :for="`type-name-${ticketType.id}`"> <Label :for="`type-name-${ticketType.id}`">
Name Name
</Label> </Label>
@@ -206,21 +235,6 @@ const breadcrumbs: BreadcrumbItem[] = [
required required
/> />
<InputError :message="errors.name" /> <InputError :message="errors.name" />
<Label
:for="`type-is-parent-${ticketType.id}}`"
>
<Checkbox
:id="`type-is-parent-${ticketType.id}}`"
name="is_parent"
:default-value="
ticketType.is_parent
"
value="1"
/>
Ticket type is can be a parent
</Label>
<InputError :message="errors.is_parent" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@@ -260,16 +274,71 @@ const breadcrumbs: BreadcrumbItem[] = [
[project.id, ticketType.id], [project.id, ticketType.id],
) )
" "
class="mt-3 flex justify-end" class="mt-3 grid gap-3"
v-slot="{ processing: deleting }" v-slot="{ errors, processing: deleting }"
> >
<Button <div
type="submit" v-if="
variant="destructive" (ticketType.tickets_count ?? 0) > 0 &&
:disabled="deleting" ticketTypes.length > 1
"
class="grid gap-2"
> >
Delete <Label
</Button> :for="`replacement-type-${ticketType.id}`"
>
Replacement type for
{{ ticketType.tickets_count }} assigned
ticket{{
ticketType.tickets_count === 1
? ''
: 's'
}}
</Label>
<select
:id="`replacement-type-${ticketType.id}`"
name="replacement_ticket_type_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 transition-[color,box-shadow] focus-visible:ring-[3px]"
required
>
<option value="">Choose type</option>
<option
v-for="replacementTicketType in replacementTicketTypes(
ticketType.id,
)"
:key="replacementTicketType.id"
:value="replacementTicketType.id"
>
{{ replacementTicketType.name }}
</option>
</select>
<InputError
:message="
errors.replacement_ticket_type_id
"
/>
</div>
<div
class="flex flex-wrap items-center justify-between gap-3"
>
<p
v-if="
(ticketType.tickets_count ?? 0) > 0
"
class="text-muted-foreground text-sm"
>
Assigned tickets will be moved before
this type is deleted.
</p>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</div>
</Form> </Form>
</div> </div>
</CardContent> </CardContent>
@@ -2,6 +2,7 @@ export type ProjectModel = {
id: number; id: number;
name: string; name: string;
key: string; key: string;
show_author: boolean;
show_story_points: boolean; show_story_points: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -2,10 +2,15 @@ import type { TicketFieldValueModel } from '@/types/models/ticket-field-value-mo
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model'; import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model'; import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { TicketWorklogEntryModel } from '@/types/models/ticket-worklog-entry-model'; import type { TicketWorklogEntryModel } from '@/types/models/ticket-worklog-entry-model';
import type { UserModel } from '@/types/models/user-model';
export type TicketModel = { export type TicketModel = {
id: number; id: number;
acceptance_criteria: string | null; acceptance_criteria: string | null;
assignee: UserModel | null;
assignee_user_id: number | null;
author: UserModel | null;
author_user_id: number | null;
board_column_id: number | null; board_column_id: number | null;
blocked_comment: string | null; blocked_comment: string | null;
description: string | null; description: string | null;
@@ -4,6 +4,7 @@ export type TicketTypeModel = {
name: string; name: string;
position: number; position: number;
project_id: number; project_id: number;
tickets_count?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
+3 -1
View File
@@ -83,7 +83,7 @@ export type TicketFieldTypeOption = {
export type ProjectTicketMetadataSettingsPageProps = { export type ProjectTicketMetadataSettingsPageProps = {
fieldTypeOptions: TicketFieldTypeOption[]; fieldTypeOptions: TicketFieldTypeOption[];
project: ProjectModel; project: ProjectModel;
error?: string | null; error?: boolean;
status?: string | null; status?: string | null;
ticketFieldDefinitions: TicketFieldDefinitionModel[]; ticketFieldDefinitions: TicketFieldDefinitionModel[];
ticketLinkTypes: TicketLinkTypeModel[]; ticketLinkTypes: TicketLinkTypeModel[];
@@ -107,10 +107,12 @@ export type ProjectBacklogPageProps = {
ticketPriorities: TicketPriorityModel[]; ticketPriorities: TicketPriorityModel[];
tickets: TicketModel[]; tickets: TicketModel[];
ticketTypes: TicketTypeModel[]; ticketTypes: TicketTypeModel[];
users: UserModel[];
}; };
export type ProjectTicketPageProps = { export type ProjectTicketPageProps = {
canEditTicket: boolean; canEditTicket: boolean;
canManageProject: boolean;
childTicketTree: TicketTreeNodeModel[]; childTicketTree: TicketTreeNodeModel[];
linkedTickets: TicketLinkModel[]; linkedTickets: TicketLinkModel[];
parentTicketOptions: TicketReferenceModel[]; parentTicketOptions: TicketReferenceModel[];
+1 -1
View File
@@ -82,7 +82,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
->name('projects.settings.metadata.types.store'); ->name('projects.settings.metadata.types.store');
Route::patch('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'updateType']) Route::patch('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'updateType'])
->name('projects.settings.metadata.types.update'); ->name('projects.settings.metadata.types.update');
Route::patch('projects/{project}/settings/metadata}', [ProjectTicketMetadataSettingsController::class, 'update']) Route::patch('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'update'])
->name('projects.settings.metadata.update'); ->name('projects.settings.metadata.update');
Route::delete('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'destroyType']) Route::delete('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'destroyType'])
->name('projects.settings.metadata.types.destroy'); ->name('projects.settings.metadata.types.destroy');
+1 -5
View File
@@ -6,7 +6,6 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketPriority;
use Database\Seeders\BoardColumnSeeder; use Database\Seeders\BoardColumnSeeder;
use Database\Seeders\ProjectSeeder; use Database\Seeders\ProjectSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -64,10 +63,7 @@ class BoardColumnTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$boardColumn = BoardColumn::factory()->for($project)->create([ $boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Doing', 'name' => 'Doing',
+2 -5
View File
@@ -109,7 +109,7 @@ class ProjectTest extends TestCase
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail(); $coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$this->assertSame( $this->assertSame(
['Task', 'Parent', 'Bug'], ['Task', 'Release', 'Bug'],
$coreProject->ticketTypes->pluck('name')->all(), $coreProject->ticketTypes->pluck('name')->all(),
); );
@@ -118,10 +118,7 @@ class ProjectTest extends TestCase
$coreProject->ticketPriorities->pluck('name')->all(), $coreProject->ticketPriorities->pluck('name')->all(),
); );
$this->assertSame( $this->assertSame([], $coreProject->ticketFieldDefinitions->pluck('label')->all());
['Assignee', 'Story Points'],
$coreProject->ticketFieldDefinitions->pluck('label')->all(),
);
} }
/** /**
@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketFieldDefinition; use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue; use App\Models\TicketFieldValue;
use App\Models\TicketPriority;
use Database\Seeders\ProjectSeeder; use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder; use Database\Seeders\ProjectTicketMetadataSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -33,7 +32,7 @@ class TicketFieldDefinitionTest extends TestCase
/** /**
* Ensure the metadata seeder creates project field definitions. * Ensure the metadata seeder creates project field definitions.
*/ */
public function test_metadata_seeder_creates_ticket_field_definitions(): void public function test_metadata_seeder_does_not_create_fixed_ticket_field_definitions(): void
{ {
$this->seed([ $this->seed([
ProjectSeeder::class, ProjectSeeder::class,
@@ -42,19 +41,7 @@ class TicketFieldDefinitionTest extends TestCase
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail(); $coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$this->assertDatabaseHas('ticket_field_definitions', [ $this->assertSame(0, $coreProject->ticketFieldDefinitions()->count());
'project_id' => $coreProject->id,
'label' => 'Assignee',
'field_type' => 'user',
'position' => 1,
]);
$this->assertDatabaseHas('ticket_field_definitions', [
'project_id' => $coreProject->id,
'label' => 'Story Points',
'field_type' => 'number',
'position' => 2,
]);
} }
/** /**
@@ -68,10 +55,7 @@ class TicketFieldDefinitionTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
@@ -81,7 +65,7 @@ class TicketFieldDefinitionTest extends TestCase
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([ $fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number', 'field_type' => 'number',
'label' => 'Story Points', 'label' => 'Complexity',
'position' => 1, 'position' => 1,
]); ]);
+5 -6
View File
@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketLink; use App\Models\TicketLink;
use App\Models\TicketLinkType; use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use Database\Seeders\ProjectSeeder; use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder; use Database\Seeders\ProjectTicketMetadataSeeder;
use Database\Seeders\TicketLinkSeeder; use Database\Seeders\TicketLinkSeeder;
@@ -31,11 +30,11 @@ class TicketLinkTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium', $taskType = $project->ticketTypes()
'position' => 1, ->where('is_parent', false)
]); ->orderBy('position')
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail(); ->firstOrFail();
$firstTicket = Ticket::factory()->create([ $firstTicket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
+33 -17
View File
@@ -9,8 +9,8 @@ use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue; use App\Models\TicketFieldValue;
use App\Models\TicketLink; use App\Models\TicketLink;
use App\Models\TicketLinkType; use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketWorklogEntry; use App\Models\TicketWorklogEntry;
use App\Models\User;
use Database\Seeders\BoardColumnSeeder; use Database\Seeders\BoardColumnSeeder;
use Database\Seeders\ProjectSeeder; use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder; use Database\Seeders\ProjectTicketMetadataSeeder;
@@ -96,10 +96,7 @@ class TicketTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
@@ -111,7 +108,7 @@ class TicketTest extends TestCase
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([ $fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number', 'field_type' => 'number',
'label' => 'Story Points', 'label' => 'Complexity',
'position' => 1, 'position' => 1,
]); ]);
@@ -139,13 +136,16 @@ class TicketTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail(); $taskType = $project->ticketTypes()
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail(); ->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$parentType = $project->ticketTypes()
->where('is_parent', true)
->orderBy('position')
->firstOrFail();
$parentTicket = Ticket::factory()->create([ $parentTicket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
@@ -184,6 +184,22 @@ class TicketTest extends TestCase
$this->assertSame('Waiting for environment access.', $freshTicket->blocked_comment); $this->assertSame('Waiting for environment access.', $freshTicket->blocked_comment);
} }
/**
* Ensure tickets expose their fixed assignee and author relations.
*/
public function test_ticket_assignee_and_author_relations_are_available(): void
{
$assignee = User::factory()->create();
$author = User::factory()->create();
$ticket = Ticket::factory()->create([
'assignee_user_id' => $assignee->id,
'author_user_id' => $author->id,
]);
$this->assertSame($assignee->id, $ticket->fresh()->assignee?->id);
$this->assertSame($author->id, $ticket->fresh()->author?->id);
}
/** /**
* Ensure tickets expose incoming and outgoing link relations. * Ensure tickets expose incoming and outgoing link relations.
*/ */
@@ -195,11 +211,11 @@ class TicketTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium', $taskType = $project->ticketTypes()
'position' => 1, ->where('is_parent', false)
]); ->orderBy('position')
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail(); ->firstOrFail();
$sourceTicket = Ticket::factory()->create([ $sourceTicket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
+13 -9
View File
@@ -20,14 +20,13 @@ class TicketTypeTest extends TestCase
{ {
$ticketType = TicketType::factory()->create(); $ticketType = TicketType::factory()->create();
$this->assertFalse($ticketType->is_builtin); $this->assertFalse($ticketType->is_parent);
$this->assertNull($ticketType->system_key);
} }
/** /**
* Ensure the metadata seeder creates the required built-in types for projects. * Ensure the metadata seeder creates the expected starter types for projects.
*/ */
public function test_metadata_seeder_creates_builtin_task_and_parent_types(): void public function test_metadata_seeder_creates_task_release_and_bug_types(): void
{ {
$this->seed([ $this->seed([
ProjectSeeder::class, ProjectSeeder::class,
@@ -39,17 +38,22 @@ class TicketTypeTest extends TestCase
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [
'project_id' => $coreProject->id, 'project_id' => $coreProject->id,
'name' => 'Task', 'name' => 'Task',
'system_key' => 'task', 'is_parent' => false,
'is_builtin' => true,
'position' => 1, 'position' => 1,
]); ]);
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [
'project_id' => $coreProject->id, 'project_id' => $coreProject->id,
'name' => 'Parent', 'name' => 'Release',
'system_key' => 'parent', 'is_parent' => true,
'is_builtin' => true,
'position' => 2, 'position' => 2,
]); ]);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $coreProject->id,
'name' => 'Bug',
'is_parent' => false,
'position' => 3,
]);
} }
} }
@@ -32,10 +32,7 @@ class ProjectBacklogControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
@@ -71,10 +68,7 @@ class ProjectBacklogControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$this->syncProjectPermissions($project, $user, ['view']); $this->syncProjectPermissions($project, $user, ['view']);
@@ -95,6 +89,7 @@ class ProjectBacklogControllerTest extends TestCase
public function test_users_with_create_permission_can_add_duplicate_titled_tickets_to_backlog(): void public function test_users_with_create_permission_can_add_duplicate_titled_tickets_to_backlog(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$assignee = User::factory()->create();
$project = Project::factory()->create([ $project = Project::factory()->create([
'key' => 'AUTH', 'key' => 'AUTH',
]); ]);
@@ -103,7 +98,7 @@ class ProjectBacklogControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([ $priority = TicketPriority::factory()->for($project)->create([
'name' => 'High', 'name' => 'High',
'position' => 1, 'position' => 2,
]); ]);
$this->syncProjectPermissions($project, $user, ['create', 'view']); $this->syncProjectPermissions($project, $user, ['create', 'view']);
@@ -112,6 +107,8 @@ class ProjectBacklogControllerTest extends TestCase
'title' => 'Same title', 'title' => 'Same title',
'description' => 'Description', 'description' => 'Description',
'acceptance_criteria' => 'Criteria', 'acceptance_criteria' => 'Criteria',
'assignee_user_id' => $assignee->id,
'story_points' => 3,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
]; ];
@@ -130,14 +127,20 @@ class ProjectBacklogControllerTest extends TestCase
'project_id' => $project->id, 'project_id' => $project->id,
'ticket_key' => 'AUTH-1', 'ticket_key' => 'AUTH-1',
'title' => 'Same title', 'title' => 'Same title',
'assignee_user_id' => $assignee->id,
'author_user_id' => $user->id,
'board_column_id' => null, 'board_column_id' => null,
'story_points' => 3,
]); ]);
$this->assertDatabaseHas('tickets', [ $this->assertDatabaseHas('tickets', [
'project_id' => $project->id, 'project_id' => $project->id,
'ticket_key' => 'AUTH-2', 'ticket_key' => 'AUTH-2',
'title' => 'Same title', 'title' => 'Same title',
'assignee_user_id' => $assignee->id,
'author_user_id' => $user->id,
'board_column_id' => null, 'board_column_id' => null,
'story_points' => 3,
]); ]);
$this->assertDatabaseCount('ticket_worklog_entries', 2); $this->assertDatabaseCount('ticket_worklog_entries', 2);
@@ -39,7 +39,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([ $priority = TicketPriority::factory()->for($project)->create([
'name' => 'High', 'name' => 'High',
'position' => 1, 'position' => 2,
]); ]);
Ticket::factory()->create([ Ticket::factory()->create([
@@ -99,7 +99,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([ $priority = TicketPriority::factory()->for($project)->create([
'name' => 'High', 'name' => 'High',
'position' => 1, 'position' => 2,
]); ]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
@@ -158,7 +158,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([ $priority = TicketPriority::factory()->for($project)->create([
'name' => 'High', 'name' => 'High',
'position' => 1, 'position' => 2,
]); ]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
@@ -213,7 +213,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([ $priority = TicketPriority::factory()->for($project)->create([
'name' => 'High', 'name' => 'High',
'position' => 1, 'position' => 2,
]); ]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
@@ -269,7 +269,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([ $priority = TicketPriority::factory()->for($project)->create([
'name' => 'High', 'name' => 'High',
'position' => 1, 'position' => 2,
]); ]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
@@ -7,7 +7,6 @@ use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
use App\Models\Team; use App\Models\Team;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
@@ -155,15 +154,13 @@ class ProjectControllerTest extends TestCase
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id, 'project_id' => $project->id,
'name' => 'Task', 'name' => 'Task',
'system_key' => 'task', 'is_parent' => false,
'is_builtin' => true,
]); ]);
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id, 'project_id' => $project->id,
'name' => 'Parent', 'name' => 'Release',
'system_key' => 'parent', 'is_parent' => true,
'is_builtin' => true,
]); ]);
} }
@@ -344,10 +341,7 @@ class ProjectControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$boardColumn = BoardColumn::factory()->for($project)->create([ $boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'In Progress', 'name' => 'In Progress',
'position' => 1, 'position' => 1,
@@ -358,7 +352,11 @@ class ProjectControllerTest extends TestCase
'sequence_number' => 1, 'sequence_number' => 1,
'ticket_key' => 'CORE-1', 'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->where('system_key', 'task')->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail()
->id,
'title' => 'Delete me', 'title' => 'Delete me',
]); ]);
@@ -7,7 +7,6 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project; use App\Models\Project;
use App\Models\RepositoryConnection; use App\Models\RepositoryConnection;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\User; use App\Models\User;
use App\Models\UserGitConnection; use App\Models\UserGitConnection;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -237,11 +236,11 @@ class ProjectTicketBranchControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium', $taskType = $project->ticketTypes()
'position' => 1, ->where('is_parent', false)
]); ->orderBy('position')
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail(); ->firstOrFail();
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
@@ -11,6 +11,7 @@ use App\Models\TicketFieldValue;
use App\Models\TicketLink; use App\Models\TicketLink;
use App\Models\TicketLinkType; use App\Models\TicketLinkType;
use App\Models\TicketPriority; use App\Models\TicketPriority;
use App\Models\TicketType;
use App\Models\TicketWorklogEntry; use App\Models\TicketWorklogEntry;
use App\Models\User; use App\Models\User;
use App\Models\UserGitConnection; use App\Models\UserGitConnection;
@@ -29,19 +30,23 @@ class ProjectTicketControllerTest extends TestCase
public function test_users_with_view_permission_can_view_ticket_details(): void public function test_users_with_view_permission_can_view_ticket_details(): void
{ {
$viewer = User::factory()->create(); $viewer = User::factory()->create();
$author = User::factory()->create([
'name' => 'Author User',
]);
$assignee = User::factory()->create([
'name' => 'Assigned User',
]);
$project = Project::factory()->create([ $project = Project::factory()->create([
'key' => 'CORE', 'key' => 'CORE',
'show_author' => false,
]); ]);
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([ $fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number', 'field_type' => 'number',
'label' => 'Story Points', 'label' => 'Complexity',
'position' => 1, 'position' => 1,
]); ]);
$ticketLinkType = TicketLinkType::factory()->for($project)->create([ $ticketLinkType = TicketLinkType::factory()->for($project)->create([
@@ -50,26 +55,30 @@ class ProjectTicketControllerTest extends TestCase
]); ]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'assignee_user_id' => $assignee->id,
'author_user_id' => $author->id,
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 1, 'sequence_number' => 1,
'story_points' => 5,
'ticket_key' => 'CORE-1', 'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Ticket detail test', 'title' => 'Ticket detail test',
]); ]);
$linkedTicket = Ticket::factory()->create([ $linkedTicket = Ticket::factory()->create([
'author_user_id' => $author->id,
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 2, 'sequence_number' => 2,
'ticket_key' => 'CORE-2', 'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Linked ticket', 'title' => 'Linked ticket',
]); ]);
TicketFieldValue::factory()->create([ TicketFieldValue::factory()->create([
'ticket_field_definition_id' => $fieldDefinition->id, 'ticket_field_definition_id' => $fieldDefinition->id,
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'value_number' => 5, 'value_number' => 3,
'value_user_id' => null, 'value_user_id' => null,
]); ]);
TicketLink::factory()->create([ TicketLink::factory()->create([
@@ -81,7 +90,7 @@ class ProjectTicketControllerTest extends TestCase
TicketWorklogEntry::factory()->create([ TicketWorklogEntry::factory()->create([
'actor_id' => $viewer->id, 'actor_id' => $viewer->id,
'entry_type' => 'updated', 'entry_type' => 'updated',
'summary' => 'Updated Story Points field.', 'summary' => 'Updated story points.',
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
]); ]);
@@ -93,16 +102,62 @@ class ProjectTicketControllerTest extends TestCase
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('projects/Ticket') ->component('projects/Ticket')
->where('project.id', $project->id) ->where('project.id', $project->id)
->where('project.show_author', false)
->where('ticket.ticket_key', 'CORE-1') ->where('ticket.ticket_key', 'CORE-1')
->where('suggestedBranchName', 'core-1-ticket-detail-test') ->where('ticket.story_points', 5)
->where('ticket.field_values.0.ticket_field_definition.label', 'Story Points') ->where('ticket.assignee.name', 'Assigned User')
->where('ticket.field_values.0.value_number', 5) ->where('ticket.author', null)
->where('ticket.author_user_id', null)
->where('ticket.field_values.0.ticket_field_definition.label', 'Complexity')
->where('ticket.field_values.0.value_number', 3)
->where('ticket.worklog_entries.0.actor.name', $viewer->name) ->where('ticket.worklog_entries.0.actor.name', $viewer->name)
->where('linkedTickets.0.linked_ticket.ticket_key', 'CORE-2') ->where('linkedTickets.0.linked_ticket.ticket_key', 'CORE-2')
->where('linkedTickets.0.ticket_link_type.label', 'Relates to') ->where('linkedTickets.0.ticket_link_type.label', 'Relates to')
->where('repositoryBranches', []) ->where('repositoryBranches', [])
->where('repositoryPullRequests', []) ->where('repositoryPullRequests', [])
->where('canEditTicket', false), ->where('canEditTicket', false)
->where('canManageProject', false),
);
}
/**
* Ensure project admins can still inspect the author when project author visibility is disabled.
*/
public function test_project_admins_can_still_view_hidden_authors(): void
{
$admin = User::factory()->create();
$author = User::factory()->create([
'name' => 'Author User',
]);
$project = Project::factory()->create([
'key' => 'CORE',
'show_author' => false,
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'author_user_id' => $author->id,
'project_id' => $project->id,
'sequence_number' => 1,
'story_points' => 5,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Admin author visibility',
]);
$this->syncProjectPermissions($project, $admin, ['admin', 'view']);
$this->actingAs($admin)
->get(route('projects.tickets.show', [$project, $ticket]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Ticket')
->where('ticket.author.name', 'Author User')
->where('ticket.author_user_id', $author->id)
->where('canManageProject', true),
); );
} }
@@ -160,17 +215,14 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 1, 'sequence_number' => 1,
'ticket_key' => 'CORE-1', 'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Branch detail test', 'title' => 'Branch detail test',
]); ]);
@@ -232,45 +284,40 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $this->nonParentType($project)->id,
]); ]);
$this->syncProjectPermissions($project, $viewer, ['view']); $this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer) $this->actingAs($viewer)
->patch(route('projects.tickets.update', [$project, $ticket]), [ ->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'title' => 'Updated title', 'title' => 'Updated title',
'description' => 'Updated description', ]))
'acceptance_criteria' => 'Updated criteria',
'blocked_comment' => $ticket->blocked_comment,
'is_blocked' => $ticket->is_blocked,
'parent_ticket_id' => $ticket->parent_ticket_id,
'ticket_type_id' => $ticket->ticket_type_id,
'ticket_priority_id' => $ticket->ticket_priority_id,
'field_values' => [],
])
->assertForbidden(); ->assertForbidden();
} }
/** /**
* Ensure editing tracked ticket fields creates a worklog entry and persists field values. * Ensure editing ticket details persists fixed ticket fields and custom fields and creates a worklog entry.
*/ */
public function test_users_with_edit_permission_can_update_ticket_details_and_custom_fields(): void public function test_users_with_edit_permission_can_update_ticket_details_and_custom_fields(): void
{ {
$editor = User::factory()->create([ $editor = User::factory()->create([
'name' => 'Editor User', 'name' => 'Editor User',
]); ]);
$author = User::factory()->create([
'name' => 'Author User',
]);
$assignee = User::factory()->create([ $assignee = User::factory()->create([
'name' => 'Assigned User', 'name' => 'Assigned User',
]); ]);
$reviewer = User::factory()->create([
'name' => 'Reviewer User',
]);
$project = Project::factory()->create([ $project = Project::factory()->create([
'key' => 'CORE', 'key' => 'CORE',
]); ]);
@@ -279,73 +326,72 @@ class ProjectTicketControllerTest extends TestCase
$lowPriority = TicketPriority::factory()->for($project)->create([ $lowPriority = TicketPriority::factory()->for($project)->create([
'name' => 'Low', 'name' => 'Low',
'position' => 1, 'position' => 2,
]); ]);
$highPriority = TicketPriority::factory()->for($project)->create([ $highPriority = TicketPriority::factory()->for($project)->create([
'name' => 'High', 'name' => 'High',
'position' => 2, 'position' => 3,
]); ]);
$reviewerField = TicketFieldDefinition::factory()->for($project)->create([
$assigneeField = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'user', 'field_type' => 'user',
'label' => 'Assignee', 'label' => 'Reviewer',
'position' => 1, 'position' => 1,
]); ]);
$storyPointsField = TicketFieldDefinition::factory()->for($project)->create([ $complexityField = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number', 'field_type' => 'number',
'label' => 'Story Points', 'label' => 'Complexity',
'position' => 2, 'position' => 2,
]); ]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'acceptance_criteria' => 'Old acceptance criteria', 'acceptance_criteria' => 'Old acceptance criteria',
'author_user_id' => $author->id,
'description' => 'Old description', 'description' => 'Old description',
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 1, 'sequence_number' => 1,
'story_points' => 3,
'ticket_key' => 'CORE-1', 'ticket_key' => 'CORE-1',
'ticket_priority_id' => $lowPriority->id, 'ticket_priority_id' => $lowPriority->id,
'ticket_type_id' => $taskType->id, 'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Old title', 'title' => 'Old title',
]); ]);
TicketFieldValue::factory()->create([ TicketFieldValue::factory()->create([
'ticket_field_definition_id' => $assigneeField->id, 'ticket_field_definition_id' => $reviewerField->id,
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'value_number' => null, 'value_number' => null,
'value_user_id' => $editor->id, 'value_user_id' => $editor->id,
]); ]);
TicketFieldValue::factory()->create([ TicketFieldValue::factory()->create([
'ticket_field_definition_id' => $storyPointsField->id, 'ticket_field_definition_id' => $complexityField->id,
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'value_number' => 3, 'value_number' => 2,
'value_user_id' => null, 'value_user_id' => null,
]); ]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']); $this->syncProjectPermissions($project, $editor, ['write', 'view']);
$this->actingAs($editor) $this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [ ->patch(route('projects.tickets.update', [$project, $ticket]), [
'title' => 'Updated title',
'description' => 'Updated description',
'acceptance_criteria' => 'Updated acceptance criteria', 'acceptance_criteria' => 'Updated acceptance criteria',
'assignee_user_id' => $assignee->id,
'blocked_comment' => $ticket->blocked_comment, 'blocked_comment' => $ticket->blocked_comment,
'is_blocked' => $ticket->is_blocked, 'description' => 'Updated description',
'parent_ticket_id' => $ticket->parent_ticket_id,
'ticket_type_id' => $parentType->id,
'ticket_priority_id' => $highPriority->id,
'field_values' => [ 'field_values' => [
[ [
'ticket_field_definition_id' => $assigneeField->id, 'ticket_field_definition_id' => $reviewerField->id,
'value_user_id' => $assignee->id, 'value_user_id' => $reviewer->id,
], ],
[ [
'ticket_field_definition_id' => $storyPointsField->id, 'ticket_field_definition_id' => $complexityField->id,
'value_number' => 8, 'value_number' => 8,
], ],
], ],
'is_blocked' => $ticket->is_blocked,
'parent_ticket_id' => $ticket->parent_ticket_id,
'story_points' => 8,
'ticket_priority_id' => $highPriority->id,
'ticket_type_id' => $this->parentType($project)->id,
'title' => 'Updated title',
]) ])
->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'CORE-1 was updated.'); ->assertSessionHas('status', 'CORE-1 was updated.');
@@ -355,27 +401,26 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Updated title', 'title' => 'Updated title',
'description' => 'Updated description', 'description' => 'Updated description',
'acceptance_criteria' => 'Updated acceptance criteria', 'acceptance_criteria' => 'Updated acceptance criteria',
'ticket_type_id' => $parentType->id, 'assignee_user_id' => $assignee->id,
'story_points' => 8,
'ticket_type_id' => $this->parentType($project)->id,
'ticket_priority_id' => $highPriority->id, 'ticket_priority_id' => $highPriority->id,
]); ]);
$this->assertDatabaseHas('ticket_field_values', [ $this->assertDatabaseHas('ticket_field_values', [
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'ticket_field_definition_id' => $assigneeField->id, 'ticket_field_definition_id' => $reviewerField->id,
'value_user_id' => $assignee->id, 'value_user_id' => $reviewer->id,
]); ]);
$this->assertDatabaseHas('ticket_field_values', [ $this->assertDatabaseHas('ticket_field_values', [
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'ticket_field_definition_id' => $storyPointsField->id, 'ticket_field_definition_id' => $complexityField->id,
'value_number' => 8.0, 'value_number' => 8.0,
]); ]);
$this->assertDatabaseHas('ticket_worklog_entries', [ $this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'actor_id' => $editor->id, 'actor_id' => $editor->id,
'entry_type' => 'updated', 'entry_type' => 'updated',
'summary' => 'Updated title, description, acceptance criteria, type, priority, Assignee field, and Story Points field.', 'summary' => 'Updated title, description, acceptance criteria, story points, type, priority, assignee, Reviewer field, and Complexity field.',
]); ]);
} }
@@ -391,12 +436,8 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium', $taskType = $this->nonParentType($project);
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$candidateParent = Ticket::factory()->create([ $candidateParent = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
@@ -406,7 +447,6 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $taskType->id, 'ticket_type_id' => $taskType->id,
'title' => 'Task ticket', 'title' => 'Task ticket',
]); ]);
$childTicket = Ticket::factory()->create([ $childTicket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 2, 'sequence_number' => 2,
@@ -420,17 +460,9 @@ class ProjectTicketControllerTest extends TestCase
$this->actingAs($editor) $this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $childTicket])) ->from(route('projects.tickets.show', [$project, $childTicket]))
->patch(route('projects.tickets.update', [$project, $childTicket]), [ ->patch(route('projects.tickets.update', [$project, $childTicket]), $this->updatePayload($childTicket, [
'title' => $childTicket->title,
'description' => $childTicket->description,
'acceptance_criteria' => $childTicket->acceptance_criteria,
'blocked_comment' => $childTicket->blocked_comment,
'is_blocked' => $childTicket->is_blocked,
'parent_ticket_id' => $candidateParent->id, 'parent_ticket_id' => $candidateParent->id,
'ticket_type_id' => $childTicket->ticket_type_id, ]))
'ticket_priority_id' => $childTicket->ticket_priority_id,
'field_values' => [],
])
->assertRedirect(route('projects.tickets.show', [$project, $childTicket])) ->assertRedirect(route('projects.tickets.show', [$project, $childTicket]))
->assertSessionHasErrors('parent_ticket_id'); ->assertSessionHasErrors('parent_ticket_id');
} }
@@ -447,46 +479,31 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
$parentTicket = Ticket::factory()->create([ $parentTicket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 1, 'sequence_number' => 1,
'ticket_key' => 'CORE-1', 'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $parentType->id, 'ticket_type_id' => $this->parentType($project)->id,
'title' => 'Parent ticket', 'title' => 'Parent ticket',
]); ]);
$childTicket = Ticket::factory()->create([ $childTicket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 2, 'sequence_number' => 2,
'ticket_key' => 'CORE-2', 'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id, 'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Child ticket', 'title' => 'Child ticket',
]); ]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']); $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor) $this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $childTicket]), [ ->patch(route('projects.tickets.update', [$project, $childTicket]), $this->updatePayload($childTicket, [
'title' => $childTicket->title,
'description' => $childTicket->description,
'acceptance_criteria' => $childTicket->acceptance_criteria,
'blocked_comment' => $childTicket->blocked_comment,
'is_blocked' => $childTicket->is_blocked,
'parent_ticket_id' => $parentTicket->id, 'parent_ticket_id' => $parentTicket->id,
'ticket_type_id' => $childTicket->ticket_type_id, ]))
'ticket_priority_id' => $childTicket->ticket_priority_id,
'field_values' => [],
])
->assertRedirect(route('projects.tickets.show', [$project, $childTicket])) ->assertRedirect(route('projects.tickets.show', [$project, $childTicket]))
->assertSessionHas('status', 'CORE-2 was updated.'); ->assertSessionHas('status', 'CORE-2 was updated.');
@@ -494,7 +511,6 @@ class ProjectTicketControllerTest extends TestCase
'id' => $childTicket->id, 'id' => $childTicket->id,
'parent_ticket_id' => $parentTicket->id, 'parent_ticket_id' => $parentTicket->id,
]); ]);
$this->assertDatabaseHas('ticket_worklog_entries', [ $this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $childTicket->id, 'ticket_id' => $childTicket->id,
'actor_id' => $editor->id, 'actor_id' => $editor->id,
@@ -503,6 +519,46 @@ class ProjectTicketControllerTest extends TestCase
]); ]);
} }
/**
* Ensure tickets with children must remain parent-enabled.
*/
public function test_tickets_with_children_must_keep_a_parent_enabled_type(): void
{
$editor = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$parentTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $this->parentType($project)->id,
'title' => 'Parent ticket',
]);
Ticket::factory()->create([
'parent_ticket_id' => $parentTicket->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Child ticket',
]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $parentTicket]))
->patch(route('projects.tickets.update', [$project, $parentTicket]), $this->updatePayload($parentTicket, [
'ticket_type_id' => $this->nonParentType($project)->id,
]))
->assertRedirect(route('projects.tickets.show', [$project, $parentTicket]))
->assertSessionHasErrors('ticket_type_id');
}
/** /**
* Ensure parent ticket detail pages show nested child trees. * Ensure parent ticket detail pages show nested child trees.
*/ */
@@ -515,50 +571,41 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
$rootParent = Ticket::factory()->create([ $rootParent = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 1, 'sequence_number' => 1,
'ticket_key' => 'CORE-1', 'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $parentType->id, 'ticket_type_id' => $this->parentType($project)->id,
'title' => 'Root parent', 'title' => 'Root parent',
]); ]);
$nestedParent = Ticket::factory()->create([ $nestedParent = Ticket::factory()->create([
'parent_ticket_id' => $rootParent->id, 'parent_ticket_id' => $rootParent->id,
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 2, 'sequence_number' => 2,
'ticket_key' => 'CORE-2', 'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $parentType->id, 'ticket_type_id' => $this->parentType($project)->id,
'title' => 'Nested parent', 'title' => 'Nested parent',
]); ]);
$directTask = Ticket::factory()->create([ $directTask = Ticket::factory()->create([
'parent_ticket_id' => $rootParent->id, 'parent_ticket_id' => $rootParent->id,
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 3, 'sequence_number' => 3,
'ticket_key' => 'CORE-3', 'ticket_key' => 'CORE-3',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id, 'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Direct task', 'title' => 'Direct task',
]); ]);
$nestedTask = Ticket::factory()->create([ $nestedTask = Ticket::factory()->create([
'parent_ticket_id' => $nestedParent->id, 'parent_ticket_id' => $nestedParent->id,
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 4, 'sequence_number' => 4,
'ticket_key' => 'CORE-4', 'ticket_key' => 'CORE-4',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id, 'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Nested task', 'title' => 'Nested task',
]); ]);
@@ -585,31 +632,21 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $this->nonParentType($project)->id,
]); ]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']); $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor) $this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $ticket])) ->from(route('projects.tickets.show', [$project, $ticket]))
->patch(route('projects.tickets.update', [$project, $ticket]), [ ->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'title' => $ticket->title,
'description' => $ticket->description,
'acceptance_criteria' => $ticket->acceptance_criteria,
'ticket_type_id' => $ticket->ticket_type_id,
'ticket_priority_id' => $ticket->ticket_priority_id,
'parent_ticket_id' => $ticket->parent_ticket_id,
'is_blocked' => true,
'blocked_comment' => '', 'blocked_comment' => '',
'field_values' => [], 'is_blocked' => true,
]) ]))
->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHasErrors('blocked_comment'); ->assertSessionHasErrors('blocked_comment');
@@ -632,32 +669,22 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium',
'position' => 1,
]);
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => 1, 'sequence_number' => 1,
'ticket_key' => 'CORE-1', 'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'ticket_type_id' => $this->nonParentType($project)->id,
]); ]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']); $this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor) $this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [ ->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'title' => $ticket->title,
'description' => $ticket->description,
'acceptance_criteria' => $ticket->acceptance_criteria,
'ticket_type_id' => $ticket->ticket_type_id,
'ticket_priority_id' => $ticket->ticket_priority_id,
'parent_ticket_id' => $ticket->parent_ticket_id,
'is_blocked' => true,
'blocked_comment' => 'Waiting for dependency approval.', 'blocked_comment' => 'Waiting for dependency approval.',
'field_values' => [], 'is_blocked' => true,
]) ]))
->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'CORE-1 was updated.'); ->assertSessionHas('status', 'CORE-1 was updated.');
@@ -666,7 +693,6 @@ class ProjectTicketControllerTest extends TestCase
'is_blocked' => true, 'is_blocked' => true,
'blocked_comment' => 'Waiting for dependency approval.', 'blocked_comment' => 'Waiting for dependency approval.',
]); ]);
$this->assertDatabaseHas('ticket_worklog_entries', [ $this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'actor_id' => $editor->id, 'actor_id' => $editor->id,
@@ -675,17 +701,10 @@ class ProjectTicketControllerTest extends TestCase
]); ]);
$this->actingAs($editor) $this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [ ->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'title' => $ticket->title,
'description' => $ticket->description,
'acceptance_criteria' => $ticket->acceptance_criteria,
'ticket_type_id' => $ticket->ticket_type_id,
'ticket_priority_id' => $ticket->ticket_priority_id,
'parent_ticket_id' => $ticket->parent_ticket_id,
'is_blocked' => false,
'blocked_comment' => '', 'blocked_comment' => '',
'field_values' => [], 'is_blocked' => false,
]) ]))
->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'CORE-1 was updated.'); ->assertSessionHas('status', 'CORE-1 was updated.');
@@ -694,7 +713,6 @@ class ProjectTicketControllerTest extends TestCase
'is_blocked' => false, 'is_blocked' => false,
'blocked_comment' => null, 'blocked_comment' => null,
]); ]);
$this->assertSame( $this->assertSame(
2, 2,
TicketWorklogEntry::query() TicketWorklogEntry::query()
@@ -704,4 +722,120 @@ class ProjectTicketControllerTest extends TestCase
->count(), ->count(),
); );
} }
/**
* Ensure non-admin users cannot change the fixed author.
*/
public function test_non_admin_users_cannot_change_the_ticket_author(): void
{
$editor = User::factory()->create();
$originalAuthor = User::factory()->create();
$replacementAuthor = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'author_user_id' => $originalAuthor->id,
'project_id' => $project->id,
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $this->nonParentType($project)->id,
]);
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
$this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $ticket]))
->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'author_user_id' => $replacementAuthor->id,
]))
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHasErrors('author_user_id');
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'author_user_id' => $originalAuthor->id,
]);
}
/**
* Ensure project admins can change the fixed author.
*/
public function test_project_admins_can_change_the_ticket_author(): void
{
$admin = User::factory()->create();
$originalAuthor = User::factory()->create();
$replacementAuthor = User::factory()->create([
'name' => 'Replacement Author',
]);
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'author_user_id' => $originalAuthor->id,
'project_id' => $project->id,
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $this->nonParentType($project)->id,
]);
$this->syncProjectPermissions($project, $admin, ['admin', 'view']);
$this->actingAs($admin)
->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'author_user_id' => $replacementAuthor->id,
]))
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', "{$ticket->ticket_key} was updated.");
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'author_user_id' => $replacementAuthor->id,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $ticket->id,
'actor_id' => $admin->id,
'entry_type' => 'updated',
'summary' => 'Updated author.',
]);
}
private function nonParentType(Project $project): TicketType
{
return $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
}
private function parentType(Project $project): TicketType
{
return $project->ticketTypes()
->where('is_parent', true)
->orderBy('position')
->firstOrFail();
}
/**
* @return array<string, mixed>
*/
private function updatePayload(Ticket $ticket, array $overrides = []): array
{
return array_merge([
'acceptance_criteria' => $ticket->acceptance_criteria,
'assignee_user_id' => $ticket->assignee_user_id,
'author_user_id' => $ticket->author_user_id,
'blocked_comment' => $ticket->blocked_comment,
'description' => $ticket->description,
'field_values' => [],
'is_blocked' => $ticket->is_blocked,
'parent_ticket_id' => $ticket->parent_ticket_id,
'story_points' => $ticket->story_points,
'ticket_priority_id' => $ticket->ticket_priority_id,
'ticket_type_id' => $ticket->ticket_type_id,
'title' => $ticket->title,
], $overrides);
}
} }
@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketLink; use App\Models\TicketLink;
use App\Models\TicketLinkType; use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -28,11 +27,11 @@ class ProjectTicketLinkControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium', $taskType = $project->ticketTypes()
'position' => 1, ->where('is_parent', false)
]); ->orderBy('position')
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail(); ->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([ $ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to', 'label' => 'Relates to',
'position' => 1, 'position' => 1,
@@ -88,16 +87,16 @@ class ProjectTicketLinkControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
app(InitializeProjectTicketMetadata::class)->handle($otherProject); app(InitializeProjectTicketMetadata::class)->handle($otherProject);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium', $otherPriority = $otherProject->ticketPriorities()->firstOrFail();
'position' => 1, $taskType = $project->ticketTypes()
]); ->where('is_parent', false)
$otherPriority = TicketPriority::factory()->for($otherProject)->create([ ->orderBy('position')
'name' => 'Medium', ->firstOrFail();
'position' => 1, $otherTaskType = $otherProject->ticketTypes()
]); ->where('is_parent', false)
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail(); ->orderBy('position')
$otherTaskType = $otherProject->ticketTypes()->where('system_key', 'task')->firstOrFail(); ->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([ $ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to', 'label' => 'Relates to',
'position' => 1, 'position' => 1,
@@ -150,11 +149,11 @@ class ProjectTicketLinkControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([ $priority = $project->ticketPriorities()->firstOrFail();
'name' => 'Medium', $taskType = $project->ticketTypes()
'position' => 1, ->where('is_parent', false)
]); ->orderBy('position')
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail(); ->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([ $ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to', 'label' => 'Relates to',
'position' => 1, 'position' => 1,
@@ -2,7 +2,9 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition; use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType; use App\Models\TicketLinkType;
use App\Models\TicketPriority; use App\Models\TicketPriority;
@@ -23,6 +25,9 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$project = Project::factory()->create(); $project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
TicketType::factory()->for($project)->create([ TicketType::factory()->for($project)->create([
'name' => 'Bug', 'name' => 'Bug',
'position' => 3, 'position' => 3,
@@ -39,8 +44,11 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
->assertOk() ->assertOk()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Metadata') ->component('projects/settings/Metadata')
->where('project.show_author', true)
->where('project.show_story_points', true)
->where('ticketTypes.0.name', 'Task') ->where('ticketTypes.0.name', 'Task')
->where('ticketTypes.1.name', 'Parent') ->where('ticketTypes.1.name', 'Release')
->where('ticketTypes.1.is_parent', true)
->where('ticketTypes.2.name', 'Bug') ->where('ticketTypes.2.name', 'Bug')
->where('ticketLinkTypes.0.label', 'Relates to'), ->where('ticketLinkTypes.0.label', 'Relates to'),
); );
@@ -58,6 +66,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->actingAs($user) $this->actingAs($user)
->post(route('projects.settings.metadata.types.store', $project), [ ->post(route('projects.settings.metadata.types.store', $project), [
'is_parent' => '1',
'name' => 'Bug', 'name' => 'Bug',
]) ])
->assertRedirect(route('projects.settings.metadata', $project)) ->assertRedirect(route('projects.settings.metadata', $project))
@@ -66,57 +75,245 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id, 'project_id' => $project->id,
'name' => 'Bug', 'name' => 'Bug',
'is_builtin' => false, 'is_parent' => true,
'system_key' => null,
]); ]);
} }
/** /**
* Ensure built-in ticket types can be renamed but not deleted. * Ensure type parent flags cannot be changed after creation.
*/ */
public function test_builtin_ticket_types_can_be_renamed_but_not_deleted(): void public function test_ticket_type_parent_flags_cannot_be_changed_after_creation(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$project = Project::factory()->create(); $project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$releaseType = $project->ticketTypes()
->where('is_parent', true)
->orderBy('position')
->firstOrFail();
$this->syncProjectPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$taskType = TicketType::query()
->where('project_id', $project->id)
->where('system_key', 'task')
->first();
if ($taskType === null) {
$this->actingAs($user)
->get(route('projects.settings.metadata', $project))
->assertOk();
$taskType = TicketType::query()
->where('project_id', $project->id)
->where('system_key', 'task')
->firstOrFail();
}
$this->actingAs($user) $this->actingAs($user)
->patch(route('projects.settings.metadata.types.update', [$project, $taskType]), [ ->patch(route('projects.settings.metadata.types.update', [$project, $releaseType]), [
'name' => 'Standard Task', 'is_parent' => '0',
'position' => 1, 'name' => 'Standard Release',
'position' => 2,
]) ])
->assertRedirect(route('projects.settings.metadata', $project)) ->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Standard Task type was updated.'); ->assertSessionHas('status', 'Standard Release type was updated.');
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [
'id' => $taskType->id, 'id' => $releaseType->id,
'name' => 'Standard Task', 'name' => 'Standard Release',
'system_key' => 'task', 'is_parent' => true,
'is_builtin' => true, ]);
}
/**
* Ensure users with edit permission can update project ticket visibility settings.
*/
public function test_users_with_edit_permission_can_update_ticket_visibility_settings(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'show_author' => true,
'show_story_points' => true,
]); ]);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
->delete(route('projects.settings.metadata.types.destroy', [$project, $taskType])) ->patch(route('projects.settings.metadata.update', $project), [
'show_author' => false,
'show_story_points' => false,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Settings have been updated.');
$this->assertDatabaseHas('projects', [
'id' => $project->id,
'show_author' => false,
'show_story_points' => false,
]);
}
/**
* Ensure users without project admin permission cannot update ticket metadata settings.
*/
public function test_non_admin_users_cannot_update_ticket_metadata_settings(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->patch(route('projects.settings.metadata.update', $project), [
'show_author' => false,
'show_story_points' => false,
])
->assertForbidden(); ->assertForbidden();
} }
/**
* Ensure the last remaining ticket type cannot be deleted.
*/
public function test_the_last_remaining_ticket_type_cannot_be_deleted(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$ticketType = TicketType::factory()->for($project)->create([
'name' => 'Solo',
'position' => 1,
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->delete(route('projects.settings.metadata.types.destroy', [$project, $ticketType]))
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'The last ticket type cannot be deleted.');
$this->assertDatabaseHas('ticket_types', [
'id' => $ticketType->id,
]);
}
/**
* Ensure deleting a used ticket type requires a replacement and reassigns affected tickets.
*/
public function test_used_ticket_types_require_replacement_before_deletion_and_reassign_tickets(): void
{
$user = User::factory()->create();
$author = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$releaseType = $project->ticketTypes()
->where('is_parent', true)
->orderBy('position')
->firstOrFail();
$ticket = Ticket::factory()->create([
'author_user_id' => $author->id,
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
'title' => 'Replace my type',
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->from(route('projects.settings.metadata', $project))
->delete(route('projects.settings.metadata.types.destroy', [$project, $taskType]))
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHasErrors('replacement_ticket_type_id');
$this->actingAs($user)
->delete(route('projects.settings.metadata.types.destroy', [$project, $taskType]), [
'replacement_ticket_type_id' => $releaseType->id,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Task type was removed.');
$this->assertDatabaseMissing('ticket_types', [
'id' => $taskType->id,
]);
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'ticket_type_id' => $releaseType->id,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $ticket->id,
'actor_id' => $user->id,
'entry_type' => 'updated',
'summary' => 'Updated type because Task type was deleted.',
]);
}
/**
* Ensure parent tickets cannot be reassigned to a non-parent replacement type during delete.
*/
public function test_parent_tickets_require_a_parent_enabled_replacement_type_when_deleting_types(): void
{
$user = User::factory()->create();
$author = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$releaseType = $project->ticketTypes()
->where('is_parent', true)
->orderBy('position')
->firstOrFail();
$epicType = TicketType::factory()->for($project)->create([
'is_parent' => true,
'name' => 'Epic',
'position' => 3,
]);
$parentTicket = Ticket::factory()->create([
'author_user_id' => $author->id,
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $releaseType->id,
'title' => 'Parent ticket',
]);
Ticket::factory()->create([
'author_user_id' => $author->id,
'parent_ticket_id' => $parentTicket->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
'title' => 'Child ticket',
]);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->from(route('projects.settings.metadata', $project))
->delete(route('projects.settings.metadata.types.destroy', [$project, $releaseType]), [
'replacement_ticket_type_id' => $taskType->id,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHasErrors('replacement_ticket_type_id');
$this->actingAs($user)
->delete(route('projects.settings.metadata.types.destroy', [$project, $releaseType]), [
'replacement_ticket_type_id' => $epicType->id,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Release type was removed.');
$this->assertDatabaseHas('tickets', [
'id' => $parentTicket->id,
'ticket_type_id' => $epicType->id,
]);
}
/** /**
* Ensure priorities can be created, updated, and deleted. * Ensure priorities can be created, updated, and deleted.
*/ */
@@ -163,7 +360,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$project = Project::factory()->create(); $project = Project::factory()->create();
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([ $fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'label' => 'Story Points', 'label' => 'Complexity',
'field_type' => 'number', 'field_type' => 'number',
'position' => 1, 'position' => 1,
]); ]);
@@ -180,16 +377,16 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->actingAs($user) $this->actingAs($user)
->patch(route('projects.settings.metadata.fields.update', [$project, $fieldDefinition]), [ ->patch(route('projects.settings.metadata.fields.update', [$project, $fieldDefinition]), [
'label' => 'Estimated Points', 'label' => 'Risk score',
'field_type' => 'number', 'field_type' => 'number',
'position' => 2, 'position' => 2,
]) ])
->assertRedirect(route('projects.settings.metadata', $project)) ->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Estimated Points field was updated.'); ->assertSessionHas('status', 'Risk score field was updated.');
$this->assertDatabaseHas('ticket_field_definitions', [ $this->assertDatabaseHas('ticket_field_definitions', [
'id' => $fieldDefinition->id, 'id' => $fieldDefinition->id,
'label' => 'Estimated Points', 'label' => 'Risk score',
'field_type' => 'number', 'field_type' => 'number',
]); ]);
} }