ticket-rework #27

Merged
Twyco merged 8 commits from ticket-rework into codex/permissions-refactoring 2026-04-14 16:42:26 +00:00
51 changed files with 1509 additions and 581 deletions
@@ -3,40 +3,29 @@
namespace App\Actions\ProjectTicketMetadata;
use App\Models\Project;
use App\Models\TicketType;
class InitializeProjectTicketMetadata
{
public function handle(Project $project): void
{
$project->ticketTypes()->firstOrCreate(
['system_key' => 'task'],
[
'is_builtin' => true,
if (! $project->ticketTypes()->exists()) {
$project->ticketTypes()->create([
'name' => 'Task',
'position' => 1,
],
);
]);
$project->ticketTypes()->firstOrCreate(
['system_key' => 'parent'],
[
'is_builtin' => true,
'name' => 'Parent',
$project->ticketTypes()->create([
'name' => 'Release',
'is_parent' => true,
'position' => 2,
],
);
]);
}
$project->ticketTypes()
->whereIn('system_key', TicketType::BUILTIN_KEYS)
->orderBy('position')
->get()
->values()
->each(function (TicketType $ticketType, int $index): void {
$ticketType->forceFill([
'is_builtin' => true,
'position' => $index + 1,
])->saveQuietly();
});
if (! $project->ticketPriorities()->exists()) {
$project->ticketPriorities()->create([
'name' => 'Medium',
'position' => 1,
]);
}
}
}
+11 -5
View File
@@ -5,6 +5,7 @@ namespace App\Actions\Tickets;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class CreateProjectTicket
@@ -16,7 +17,9 @@ class CreateProjectTicket
/**
* @param array{
* acceptance_criteria: string|null,
* assignee_user_id?: int|null,
* description: string|null,
* story_points?: int|null,
* ticket_priority_id: int,
* ticket_type_id: int,
* title: string
@@ -30,15 +33,18 @@ class CreateProjectTicket
->max('sequence_number') + 1;
$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,
'description' => $attributes['description'],
'description' => Arr::get($attributes, 'description'),
'project_id' => $project->id,
'sequence_number' => $nextSequenceNumber,
'ticket_key' => "{$project->key}-{$nextSequenceNumber}",
'ticket_priority_id' => $attributes['ticket_priority_id'],
'ticket_type_id' => $attributes['ticket_type_id'],
'title' => $attributes['title'],
'ticket_priority_id' => Arr::get($attributes, 'ticket_priority_id'),
'ticket_type_id' => Arr::get($attributes, 'ticket_type_id'),
'title' => Arr::get($attributes, 'title'),
'story_points' => Arr::get($attributes, 'story_points'),
]);
if ($actor !== null) {
@@ -18,9 +18,12 @@ class UpdateProjectTicket
/**
* @param array{
* acceptance_criteria: string|null,
* assignee_user_id: int|null,
* author_user_id: int|null,
* blocked_comment: string|null,
* description: string|null,
* is_blocked: bool,
* story_points: int|null,
* ticket_priority_id: int,
* ticket_type_id: int,
* parent_ticket_id: int|null,
@@ -44,6 +47,8 @@ class UpdateProjectTicket
}
$ticket->loadMissing([
'assignee',
'author',
'fieldValues.ticketFieldDefinition',
'fieldValues.userValue',
'parentTicket',
@@ -103,6 +108,14 @@ class UpdateProjectTicket
$ticket->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']) {
$changes['ticket_type'] = [
@@ -140,6 +153,26 @@ class UpdateProjectTicket
$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);
if ($ticket->isDirty()) {
@@ -217,6 +250,29 @@ class UpdateProjectTicket
$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{value_user_id?: int|string|null} $fieldValueAttribute
@@ -9,9 +9,9 @@ use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
#[Signature('app:create-initial-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
#[Description('Command description')]
class CreateInitialAdminUser extends Command
class CreateAdminUser extends Command
{
/**
* Execute the console command.
@@ -23,12 +23,13 @@ class CreateInitialAdminUser extends Command
'email' => $this->argument('email'),
'password' => Hash::make($this->argument('password')),
]);
$this->info('User created');
$user->permissionAssignments()->create([
'permission' => Permission::GlobalAdmin,
]);
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
return self::SUCCESS;
}
}
@@ -8,6 +8,7 @@ use App\Enums\Permission;
use App\Http\Requests\StoreProjectTicketRequest;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -43,6 +44,9 @@ class ProjectBacklogController extends Controller
->whereNull('board_column_id')
->orderByDesc('sequence_number')
->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,16 +38,34 @@ class ProjectTicketController extends Controller
403,
);
$canEditTicket = $this->permissionService->allows($user, Permission::ProjectWrite, $project);
$canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project);
$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', [
'canEditTicket' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'canEditTicket' => $canEditTicket,
'canManageProject' => $canManageProject,
'childTicketTree' => $this->buildChildTicketTree($ticket),
'linkedTickets' => $this->linkedTickets($ticket),
'parentTicketOptions' => Ticket::query()
->where('project_id', $project->id)
->whereNotIn('id', $excludedParentIds)
->whereHas('ticketType', fn ($query) => $query->where('system_key', 'parent'))
->whereHas('ticketType', fn ($query) => $query->where('is_parent', true))
->orderByDesc('sequence_number')
->get()
->map(fn (Ticket $parentTicket): array => [
@@ -68,14 +86,7 @@ class ProjectTicketController extends Controller
),
'status' => session('status'),
'suggestedBranchName' => $this->buildSuggestedTicketBranchName->handle($ticket),
'ticket' => $ticket->load([
'fieldValues.ticketFieldDefinition',
'fieldValues.userValue',
'parentTicket',
'ticketPriority',
'ticketType',
'worklogEntries.actor',
]),
'ticket' => $displayTicket,
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
'ticketLinkOptions' => Ticket::query()
->where('project_id', $project->id)
@@ -109,13 +120,22 @@ class ProjectTicketController extends Controller
);
$validated = $request->validated();
$fieldValues = Arr::get($validated, 'field_values');
$fieldValues = Arr::get($validated, 'field_values', []);
$ticketAttributes = [
'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'),
'description' => Arr::get($validated, 'description'),
'is_blocked' => Arr::get($validated, 'is_blocked', false),
'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_type_id' => Arr::get($validated, 'ticket_type_id'),
'title' => Arr::get($validated, 'title'),
@@ -3,13 +3,18 @@
namespace App\Http\Controllers;
use App\Actions\Permissions\PermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\CreateTicketWorklogEntry;
use App\Enums\Permission;
use App\Http\Requests\DestroyProjectTicketTypeRequest;
use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreProjectTicketTypeRequest;
use App\Http\Requests\StoreTicketFieldDefinitionRequest;
use App\Http\Requests\UpdateProjectNamedSettingRequest;
use App\Http\Requests\UpdateProjectTicketMetadataSettings;
use App\Http\Requests\UpdateProjectTicketTypeRequest;
use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
@@ -26,14 +31,13 @@ use Inertia\Response;
class ProjectTicketMetadataSettingsController extends Controller
{
public function __construct(
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly PermissionService $permissionService,
) {}
public function show(Request $request, Project $project): Response
{
$this->abortUnlessCanManage($request, $project);
$this->initializeProjectTicketMetadata->handle($project);
return Inertia::render('projects/settings/Metadata', [
'fieldTypeOptions' => collect(TicketFieldDefinition::FIELD_TYPES)
@@ -43,35 +47,47 @@ class ProjectTicketMetadataSettingsController extends Controller
])
->all(),
'project' => $project,
'error' => session('error'),
'status' => session('status'),
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
'ticketLinkTypes' => $project->ticketLinkTypes()->get(),
'ticketPriorities' => $project->ticketPriorities()->get(),
'ticketTypes' => $project->ticketTypes()->get(),
'ticketTypes' => $project->ticketTypes()->withCount('tickets')->get(),
]);
}
public function storeType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse
public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse
{
$this->abortUnlessCanManage($request, $project);
$this->initializeProjectTicketMetadata->handle($project);
$validated = $request->validated();
$name = Arr::get($validated, 'name');
$position = Arr::get($validated, 'position');
$ticketType = DB::transaction(function () use ($project, $name, $position): TicketType {
$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)
->with('status', 'Settings have been updated.');
}
public function storeType(StoreProjectTicketTypeRequest $request, Project $project): RedirectResponse
{
$this->abortUnlessCanManage($request, $project);
$validated = $request->validated();
$ticketType = DB::transaction(function () use ($project, $validated): TicketType {
$ticketType = $project->ticketTypes()->create([
'is_builtin' => false,
'name' => $name,
'name' => Arr::get($validated, 'name'),
'position' => $project->ticketTypes()->count() + 1,
'system_key' => null,
'is_parent' => Arr::get($validated, 'is_parent', false),
]);
$this->reorderModels(
$project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(),
$ticketType,
$position ?? $project->ticketTypes()->count(),
Arr::get($validated, 'position') ?? $project->ticketTypes()->count(),
);
return $ticketType;
@@ -82,26 +98,23 @@ class ProjectTicketMetadataSettingsController extends Controller
}
public function updateType(
UpdateProjectNamedSettingRequest $request,
UpdateProjectTicketTypeRequest $request,
Project $project,
TicketType $ticketType,
): RedirectResponse {
$this->abortUnlessCanManage($request, $project);
$this->initializeProjectTicketMetadata->handle($project);
$validated = $request->validated();
$name = Arr::get($validated, 'name');
$position = Arr::get($validated, 'position');
DB::transaction(function () use ($project, $name, $position, $ticketType): void {
DB::transaction(function () use ($project, $validated, $ticketType): void {
$ticketType->update([
'name' => $name,
'name' => Arr::get($validated, 'name'),
]);
$this->reorderModels(
$project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(),
$ticketType,
$position,
Arr::get($validated, 'position'),
);
});
@@ -109,15 +122,54 @@ class ProjectTicketMetadataSettingsController extends Controller
->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);
abort_if($ticketType->is_builtin, 403);
if ($project->ticketTypes()->count() === 1) {
return to_route('projects.settings.metadata', $project)
->with('error', true)
->with('status', 'The last ticket type cannot be deleted.');
}
$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();
$this->persistOrderedModels($project->ticketTypes()->get()->values());
@@ -188,6 +240,12 @@ class ProjectTicketMetadataSettingsController extends Controller
): RedirectResponse {
$this->abortUnlessCanManage($request, $project);
if ($project->ticketPriorities()->count() === 1) {
return to_route('projects.settings.metadata', $project)
->with('error', true)
->with('status', 'The last ticket priority cannot be deleted.');
}
$deletedName = $ticketPriority->name;
DB::transaction(function () use ($project, $ticketPriority): void {
@@ -207,21 +265,18 @@ class ProjectTicketMetadataSettingsController extends Controller
$this->abortUnlessCanManage($request, $project);
$validated = $request->validated();
$label = Arr::get($validated, 'label');
$fieldType = Arr::get($validated, 'field_type');
$position = Arr::get($validated, 'position');
$ticketFieldDefinition = DB::transaction(function () use ($project, $fieldType, $label): TicketFieldDefinition {
$ticketFieldDefinition = DB::transaction(function () use ($project, $validated): TicketFieldDefinition {
$ticketFieldDefinition = $project->ticketFieldDefinitions()->create([
'field_type' => $fieldType,
'label' => $label,
'field_type' => Arr::get($validated, 'field_type'),
'label' => Arr::get($validated, 'label'),
'position' => $project->ticketFieldDefinitions()->count() + 1,
]);
$this->reorderModels(
$project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(),
$ticketFieldDefinition,
$position ?? $project->ticketFieldDefinitions()->count(),
Arr::get($validated, 'position') ?? $project->ticketFieldDefinitions()->count(),
);
return $ticketFieldDefinition;
@@ -239,20 +294,17 @@ class ProjectTicketMetadataSettingsController extends Controller
$this->abortUnlessCanManage($request, $project);
$validated = $request->validated();
$label = Arr::get($validated, 'label');
$fieldType = Arr::get($validated, 'field_type');
$position = Arr::get($validated, 'position');
DB::transaction(function () use ($project, $fieldType, $label, $ticketFieldDefinition, $position): void {
DB::transaction(function () use ($project, $ticketFieldDefinition, $validated): void {
$ticketFieldDefinition->update([
'field_type' => $fieldType,
'label' => $label,
'field_type' => Arr::get($validated, 'field_type'),
'label' => Arr::get($validated, 'label'),
]);
$this->reorderModels(
$project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(),
$ticketFieldDefinition,
$position,
Arr::get($validated, 'position'),
);
});
@@ -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 [
'acceptance_criteria' => ['nullable', 'string'],
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'description' => ['nullable', 'string'],
'ticket_priority_id' => [
'required',
@@ -40,6 +41,7 @@ class StoreProjectTicketRequest extends FormRequest
'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
],
'story_points' => ['nullable', 'integer', 'min:0'],
'title' => ['required', 'string', 'max:255'],
];
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreProjectTicketTypeRequest 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' => ['nullable', 'integer', 'min:1'],
'is_parent' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProjectTicketMetadataSettings 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 [
'show_author' => ['nullable', 'boolean'],
'show_story_points' => ['nullable', 'boolean'],
];
}
}
@@ -2,6 +2,8 @@
namespace App\Http\Requests;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketType;
@@ -33,6 +35,8 @@ class UpdateProjectTicketRequest extends FormRequest
return [
'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'],
'description' => ['nullable', 'string'],
'field_values' => ['array'],
@@ -61,6 +65,7 @@ class UpdateProjectTicketRequest extends FormRequest
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
],
'title' => ['required', 'string', 'max:255'],
'story_points' => ['nullable', 'integer', 'min:0'],
];
}
@@ -72,7 +77,7 @@ class UpdateProjectTicketRequest extends FormRequest
return [
function (Validator $validator): void {
/** @var array<string, mixed> $validated */
$validated = $this->validated();
$validated = $this->safe()->all();
/** @var Project $project */
$project = $this->route('project');
@@ -80,6 +85,9 @@ class UpdateProjectTicketRequest extends FormRequest
/** @var Ticket $ticket */
$ticket = $this->route('ticket');
$canManageProject = app(PermissionService::class)
->allows($this->user(), Permission::ProjectAdmin, $project);
$parentTicketId = Arr::get($validated, 'parent_ticket_id');
if ($parentTicketId !== null) {
@@ -94,8 +102,8 @@ class UpdateProjectTicketRequest extends FormRequest
->with('ticketType')
->find($parentTicketId);
if ($parentTicket !== null && $parentTicket->ticketType?->system_key !== 'parent') {
$validator->errors()->add('parent_ticket_id', 'Only tickets of type Parent can be selected as a parent.');
if ($parentTicket !== null && ! $parentTicket->ticketType?->is_parent) {
$validator->errors()->add('parent_ticket_id', 'Only tickets with a parent-enabled type can be selected as a parent.');
return;
}
@@ -113,15 +121,23 @@ class UpdateProjectTicketRequest extends FormRequest
->find($requestedTicketTypeId);
if (
$requestedTicketType?->system_key !== 'parent'
! $requestedTicketType?->is_parent
&& $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'))) {
$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'],
];
}
}
+9 -1
View File
@@ -11,12 +11,20 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
#[Fillable(['name', 'key'])]
#[Fillable(['name', 'key', 'show_author', 'show_story_points'])]
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
use HasFactory;
protected function casts(): array
{
return [
'show_author' => 'boolean',
'show_story_points' => 'boolean',
];
}
protected static function booted(): void
{
static::deleting(function (self $project): void {
+13
View File
@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'acceptance_criteria',
'assignee_user_id',
'author_user_id',
'board_column_id',
'blocked_comment',
'description',
@@ -22,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'ticket_priority_id',
'ticket_type_id',
'title',
'story_points',
])]
class Ticket extends Model
{
@@ -43,6 +46,16 @@ class Ticket extends Model
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
{
return $this->hasMany(Ticket::class, 'parent_ticket_id')
+14 -9
View File
@@ -7,23 +7,28 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['is_builtin', 'name', 'position', 'project_id', 'system_key'])]
#[Fillable(['name', 'position', 'project_id', 'is_parent'])]
class TicketType extends Model
{
/**
* @var array<int, string>
*/
public const BUILTIN_KEYS = [
'task',
'parent',
];
/** @use HasFactory<TicketTypeFactory> */
use HasFactory;
protected function casts(): array
{
return [
'is_parent' => 'boolean',
];
}
public function project(): BelongsTo
{
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('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 [
'name' => Str::title(fake()->unique()->words(asText: true)),
'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\Models\Project;
use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@@ -22,12 +22,15 @@ class TicketFactory extends Factory
{
return [
'acceptance_criteria' => fake()->paragraph(),
'assignee_user_id' => null,
'author_user_id' => User::factory(),
'board_column_id' => null,
'blocked_comment' => null,
'description' => fake()->paragraphs(2, true),
'is_blocked' => false,
'parent_ticket_id' => null,
'sequence_number' => 1,
'story_points' => null,
'ticket_key' => 'TEST-1',
'title' => fake()->sentence(4),
];
@@ -46,14 +49,9 @@ class TicketFactory extends Factory
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$ticket->project_id = $project->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->ticket_key = "{$project->key}-1";
});
@@ -39,7 +39,7 @@ class TicketFieldValueFactory extends Factory
$ticketFieldDefinition = $ticketFieldValue->ticket_field_definition_id === null
? TicketFieldDefinition::factory()->for($ticket->project)->create([
'field_type' => 'number',
'label' => 'Story Points',
'label' => 'Custom Number',
'position' => $ticket->project->ticketFieldDefinitions()->count() + 1,
])
: TicketFieldDefinition::query()->findOrFail($ticketFieldValue->ticket_field_definition_id);
+1 -2
View File
@@ -19,11 +19,10 @@ class TicketTypeFactory extends Factory
public function definition(): array
{
return [
'is_builtin' => false,
'is_parent' => false,
'name' => fake()->unique()->jobTitle(),
'position' => 1,
'project_id' => Project::factory(),
'system_key' => null,
];
}
}
@@ -15,6 +15,8 @@ return new class extends Migration
$table->id();
$table->string('name');
$table->string('key', 5)->unique();
$table->boolean('show_author')->default(true);
$table->boolean('show_story_points')->default(true);
$table->timestamps();
});
}
@@ -15,13 +15,11 @@ return new class extends Migration
$table->id();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('system_key')->nullable();
$table->boolean('is_builtin')->default(false);
$table->boolean('is_parent')->default(false);
$table->unsignedInteger('position');
$table->timestamps();
$table->unique(['project_id', 'position']);
$table->unique(['project_id', 'system_key']);
});
}
@@ -16,12 +16,15 @@ return new class extends Migration
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('ticket_type_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->unsignedInteger('sequence_number');
$table->string('ticket_key')->unique();
$table->string('title');
$table->text('description')->nullable();
$table->text('acceptance_criteria')->nullable();
$table->unsignedInteger('story_points')->nullable()->default(null);
$table->timestamps();
$table->unique(['project_id', 'sequence_number']);
@@ -5,8 +5,6 @@ namespace Database\Seeders;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use App\Models\TicketPriority;
use App\Models\TicketType;
use Illuminate\Database\Seeder;
class ProjectTicketMetadataSeeder extends Seeder
@@ -24,32 +22,30 @@ class ProjectTicketMetadataSeeder extends Seeder
->each(function (Project $project) use ($initializeProjectTicketMetadata): void {
$initializeProjectTicketMetadata->handle($project);
TicketType::factory()
->for($project)
->create([
'name' => 'Bug',
$project->ticketTypes()->updateOrCreate(
['name' => 'Bug'],
[
'is_parent' => false,
'position' => 3,
'system_key' => null,
]);
],
);
TicketPriority::factory()
->count(3)
->sequence(
['name' => 'Low', 'position' => 1],
['name' => 'Medium', 'position' => 2],
['name' => 'High', 'position' => 3],
)
->for($project)
->create();
$project->ticketPriorities()->updateOrCreate(
['name' => 'Medium'],
['position' => 2],
);
$project->ticketPriorities()->updateOrCreate(
['name' => 'Low'],
['position' => 1],
);
$project->ticketPriorities()->updateOrCreate(
['name' => 'High'],
['position' => 3],
);
TicketFieldDefinition::factory()
->count(2)
->sequence(
['label' => 'Assignee', 'field_type' => 'user', 'position' => 1],
['label' => 'Story Points', 'field_type' => 'number', 'position' => 2],
)
->for($project)
->create();
->create(['label' => 'Driver', 'field_type' => 'user', 'position' => 1]);
});
}
}
+10 -3
View File
@@ -6,6 +6,7 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\CreateProjectTicket;
use App\Models\Project;
use App\Models\TicketPriority;
use App\Models\User;
use Illuminate\Database\Seeder;
class TicketSeeder extends Seeder
@@ -17,11 +18,13 @@ class TicketSeeder extends Seeder
{
$createProjectTicket = app(CreateProjectTicket::class);
$initializeProjectTicketMetadata = app(InitializeProjectTicketMetadata::class);
$author = User::query()->orderBy('id')->first();
$assignee = User::query()->orderBy('id')->skip(1)->first() ?? $author;
Project::query()
->orderBy('id')
->get()
->each(function (Project $project) use ($createProjectTicket, $initializeProjectTicketMetadata): void {
->each(function (Project $project) use ($assignee, $author, $createProjectTicket, $initializeProjectTicketMetadata): void {
$initializeProjectTicketMetadata->handle($project);
$priority = $project->ticketPriorities()->first();
@@ -37,19 +40,23 @@ class TicketSeeder extends Seeder
$createProjectTicket->handle($project, [
'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.',
'story_points' => 3,
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $defaultType->id,
'title' => 'Seed backlog item',
]);
], $author);
$createProjectTicket->handle($project, [
'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.',
'story_points' => 5,
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $defaultType->id,
'title' => 'Seed backlog item',
]);
], $author);
});
}
}
@@ -17,6 +17,7 @@ class TicketWorklogEntrySeeder extends Seeder
$defaultActor = User::query()->orderBy('id')->first();
Ticket::query()
->whereDoesntHave('worklogEntries')
->orderBy('id')
->get()
->each(function (Ticket $ticket) use ($defaultActor): void {
+5 -18
View File
@@ -8,19 +8,7 @@
- app/Http/Requests/UpdateProjectTicketRequest.php and other ProjectTicket only to Ticket...
- mehrere actions/quereys in services auslagern
- 05
- ticket type remove build in
- → add is parentBool to ticketType -> remove system_key (maybe add slug)
- auto generate one parent one task on new project as now
- disable delete last child ticket type
- initializeProjectTicketMetadata nur beim init benutzen
- every form request removed unnecessary methods: like position in StoreProjectNamedSettingRequest
- use Arr:get(\$validated, 'value'). instead of these messages or \$request->value
- Made ticket priority more static(change to has one) in ticket, but only show if there are more than one configured.
- made story points more static(change to table field in table), but only show if checked in project settings → new value on Project: hasStoryPoints
- more ticket field types (string, string array, boolean, precision (double))
- abortUnlessCanManage(xxx) entfernen, auch gate benutzen (ProjectTicketMetadataSettingsController)
- ticket assignee fest einbauen in ticket
- 06
- add Ticket Status
- ticketstatus can have one or more board columns
@@ -33,13 +21,12 @@
- 09
- app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null
- later:
- if deleting ticket type select a new ticket type which all existing ticket should get or select delete all tickets of that type
- repository_connections check base Url maybe unnessecary?
- Git listBranches replace baseUrl, owner, rperoName with GItReprository
- Git prs replace baseUrl, owner, rperoName with GItReprository
- git php docu replace @ reutrn with data models
- replace Kanbangit with env App Name
- 08
- worklog überarbeiten → type als worklog enum → json je nach enum type
- UpdateProjectTicket aufräumen
- 08
- worklog überarbeiten → type als worklog enum → json je nach enum type
- UpdateProjectTicket aufräumen
+1 -1
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
modelValue?: string | number | null
class?: HTMLAttributes["class"]
}>()
+113 -64
View File
@@ -107,74 +107,123 @@ const hasTicketMetadata =
<InputError :message="errors.title" />
</div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
name="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]"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError :message="errors.ticket_type_id" />
</div>
<div class="flex gap-2">
<div class="flex-4/5 grid gap-2 md:gap-6">
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
name="description"
rows="4"
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]"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="errors.description" />
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
name="ticket_priority_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
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
<div class="grid gap-2">
<Label for="acceptance_criteria">
Acceptance criteria
</Label>
<textarea
id="acceptance_criteria"
name="acceptance_criteria"
rows="4"
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]"
placeholder="List the conditions that must be true before this ticket is done."
/>
<InputError
:message="errors.acceptance_criteria"
/>
</div>
</div>
<div
class="grid h-fit flex-1 gap-2 md:ml-4 md:gap-6"
>
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
name="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]"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="errors.ticket_priority_id"
/>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
name="ticket_priority_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
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="errors.ticket_priority_id"
/>
</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
class="grid gap-2"
v-if="project.show_story_points"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
type="number"
step="1"
/>
<InputError
:message="errors.story_points"
/>
</div>
</div>
</div>
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
name="description"
rows="4"
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]"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="errors.description" />
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria">
Acceptance criteria
</Label>
<textarea
id="acceptance_criteria"
name="acceptance_criteria"
rows="4"
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]"
placeholder="List the conditions that must be true before this ticket is done."
/>
<InputError :message="errors.acceptance_criteria" />
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="processing">
Create ticket
+80
View File
@@ -66,6 +66,12 @@ const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
const canCreateBranches = computed(
() => 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(() =>
String(
@@ -97,6 +103,8 @@ const breadcrumbs: BreadcrumbItem[] = [
const form = useForm({
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 ?? '',
description: props.ticket.description ?? '',
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
@@ -117,6 +125,7 @@ const form = useForm({
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
story_points: props.ticket.story_points,
});
const isCreateBranchDialogOpen = ref(false);
@@ -460,6 +469,77 @@ const formatDateTime = (value: string): string => {
</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
class="grid gap-2"
v-if="project.show_story_points"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
v-model="form.story_points"
type="number"
step="1"
/>
<InputError
:message="form.errors.story_points"
/>
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
Parent ticket
+143 -37
View File
@@ -26,6 +26,7 @@ import type {
BreadcrumbItem,
ProjectTicketMetadataSettingsPageProps,
} from '@/types';
import { Checkbox } from '@/components/ui/checkbox';
const props = defineProps<ProjectTicketMetadataSettingsPageProps>();
@@ -43,6 +44,9 @@ const breadcrumbs: BreadcrumbItem[] = [
href: metadataSettings(props.project.id),
},
];
const replacementTicketTypes = (ticketTypeId: number) =>
props.ticketTypes.filter((ticketType) => ticketType.id !== ticketTypeId);
</script>
<template>
@@ -94,19 +98,66 @@ const breadcrumbs: BreadcrumbItem[] = [
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
:class="[
'rounded-xl border px-4 py-3 text-sm',
error
? 'border-red-200/80 bg-red-50/60 text-red-800/80 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-200/80'
: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100',
]"
>
{{ status }}
</div>
<div class="grid gap-6 xl:grid-cols-2">
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>General ticket settings</CardTitle>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.update.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<Label for="enable-story-points">
<Checkbox
id="enable-story-points"
name="show_story_points"
:default-value="project.show_story_points"
value="1"
/>
Enable story points for tickets
</Label>
<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">
Save
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Ticket types</CardTitle>
<CardDescription>
Built-in Task and Parent always exist. Custom types
can be added and reordered per project.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
@@ -128,6 +179,16 @@ const breadcrumbs: BreadcrumbItem[] = [
placeholder="Bug"
/>
<InputError :message="errors.name" />
<Label for="type-is-parent">
<Checkbox
id="type-is-parent"
name="is_parent"
value="1"
/>
Ticket type can act as a parent
</Label>
<InputError :message="errors.is_parent" />
</div>
<Button type="submit" :disabled="processing">
@@ -149,30 +210,21 @@ const breadcrumbs: BreadcrumbItem[] = [
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div
class="flex items-center justify-between gap-3"
>
<Badge
:variant="
ticketType.is_builtin
? 'default'
: 'secondary'
"
>
{{
ticketType.is_builtin
? 'Built-in'
: 'Custom'
}}
</Badge>
<p
class="text-muted-foreground text-xs uppercase tracking-wide"
>
Position {{ ticketType.position }}
</p>
</div>
<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}`">
Name
</Label>
@@ -217,22 +269,76 @@ const breadcrumbs: BreadcrumbItem[] = [
</Form>
<Form
v-if="!ticketType.is_builtin"
v-bind="
ProjectTicketMetadataSettingsController.destroyType.form(
[project.id, ticketType.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
class="mt-3 grid gap-3"
v-slot="{ errors, processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
<div
v-if="
(ticketType.tickets_count ?? 0) > 0 &&
ticketTypes.length > 1
"
class="grid gap-2"
>
Delete
</Button>
<Label
: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>
</div>
</CardContent>
@@ -2,6 +2,8 @@ export type ProjectModel = {
id: number;
name: string;
key: string;
show_author: boolean;
show_story_points: boolean;
created_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 { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { TicketWorklogEntryModel } from '@/types/models/ticket-worklog-entry-model';
import type { UserModel } from '@/types/models/user-model';
export type TicketModel = {
id: number;
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;
blocked_comment: string | null;
description: string | null;
@@ -21,6 +26,7 @@ export type TicketModel = {
ticket_type_id: number;
title: string;
worklog_entries?: TicketWorklogEntryModel[];
story_points: number | null;
created_at: string;
updated_at: string;
};
@@ -1,10 +1,10 @@
export type TicketTypeModel = {
id: number;
is_builtin: boolean;
is_parent: boolean;
name: string;
position: number;
project_id: number;
system_key: string | null;
tickets_count?: number;
created_at: string;
updated_at: string;
};
+3
View File
@@ -83,6 +83,7 @@ export type TicketFieldTypeOption = {
export type ProjectTicketMetadataSettingsPageProps = {
fieldTypeOptions: TicketFieldTypeOption[];
project: ProjectModel;
error?: boolean;
status?: string | null;
ticketFieldDefinitions: TicketFieldDefinitionModel[];
ticketLinkTypes: TicketLinkTypeModel[];
@@ -106,10 +107,12 @@ export type ProjectBacklogPageProps = {
ticketPriorities: TicketPriorityModel[];
tickets: TicketModel[];
ticketTypes: TicketTypeModel[];
users: UserModel[];
};
export type ProjectTicketPageProps = {
canEditTicket: boolean;
canManageProject: boolean;
childTicketTree: TicketTreeNodeModel[];
linkedTickets: TicketLinkModel[];
parentTicketOptions: TicketReferenceModel[];
+2
View File
@@ -82,6 +82,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
->name('projects.settings.metadata.types.store');
Route::patch('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'updateType'])
->name('projects.settings.metadata.types.update');
Route::patch('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'update'])
->name('projects.settings.metadata.update');
Route::delete('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'destroyType'])
->name('projects.settings.metadata.types.destroy');
Route::post('projects/{project}/settings/metadata/priorities', [ProjectTicketMetadataSettingsController::class, 'storePriority'])
+1 -5
View File
@@ -6,7 +6,6 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketPriority;
use Database\Seeders\BoardColumnSeeder;
use Database\Seeders\ProjectSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -64,10 +63,7 @@ class BoardColumnTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Doing',
+2 -2
View File
@@ -109,7 +109,7 @@ class ProjectTest extends TestCase
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$this->assertSame(
['Task', 'Parent', 'Bug'],
['Task', 'Release', 'Bug'],
$coreProject->ticketTypes->pluck('name')->all(),
);
@@ -119,7 +119,7 @@ class ProjectTest extends TestCase
);
$this->assertSame(
['Assignee', 'Story Points'],
['Driver'],
$coreProject->ticketFieldDefinitions->pluck('label')->all(),
);
}
@@ -7,9 +7,6 @@ use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
use App\Models\TicketPriority;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -30,33 +27,6 @@ class TicketFieldDefinitionTest extends TestCase
);
}
/**
* Ensure the metadata seeder creates project field definitions.
*/
public function test_metadata_seeder_creates_ticket_field_definitions(): void
{
$this->seed([
ProjectSeeder::class,
ProjectTicketMetadataSeeder::class,
]);
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$this->assertDatabaseHas('ticket_field_definitions', [
'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,
]);
}
/**
* Ensure ticket field definitions expose their related ticket values.
*/
@@ -68,10 +38,7 @@ class TicketFieldDefinitionTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
@@ -81,7 +48,7 @@ class TicketFieldDefinitionTest extends TestCase
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number',
'label' => 'Story Points',
'label' => 'Complexity',
'position' => 1,
]);
@@ -3,6 +3,7 @@
namespace Tests\Feature\Models;
use App\Models\TicketFieldValue;
use App\Models\User;
use Database\Seeders\BoardColumnSeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder;
@@ -42,9 +43,9 @@ class TicketFieldValueTest extends TestCase
]);
$this->assertDatabaseHas('ticket_field_values', [
'value_number' => 3.0,
'value_user_id' => User::query()->orderBy('id')->first()->id,
]);
$this->assertSame(12, TicketFieldValue::query()->count());
$this->assertSame(6, TicketFieldValue::query()->count());
}
}
+5 -6
View File
@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketLink;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder;
use Database\Seeders\TicketLinkSeeder;
@@ -31,11 +30,11 @@ class TicketLinkTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$firstTicket = Ticket::factory()->create([
'project_id' => $project->id,
+33 -17
View File
@@ -9,8 +9,8 @@ use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
use App\Models\TicketLink;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketWorklogEntry;
use App\Models\User;
use Database\Seeders\BoardColumnSeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ProjectTicketMetadataSeeder;
@@ -96,10 +96,7 @@ class TicketTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
@@ -111,7 +108,7 @@ class TicketTest extends TestCase
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number',
'label' => 'Story Points',
'label' => 'Complexity',
'position' => 1,
]);
@@ -139,13 +136,16 @@ class TicketTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$parentType = $project->ticketTypes()
->where('is_parent', true)
->orderBy('position')
->firstOrFail();
$parentTicket = Ticket::factory()->create([
'project_id' => $project->id,
@@ -184,6 +184,22 @@ class TicketTest extends TestCase
$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.
*/
@@ -195,11 +211,11 @@ class TicketTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$sourceTicket = Ticket::factory()->create([
'project_id' => $project->id,
+13 -9
View File
@@ -20,14 +20,13 @@ class TicketTypeTest extends TestCase
{
$ticketType = TicketType::factory()->create();
$this->assertFalse($ticketType->is_builtin);
$this->assertNull($ticketType->system_key);
$this->assertFalse($ticketType->is_parent);
}
/**
* 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([
ProjectSeeder::class,
@@ -39,17 +38,22 @@ class TicketTypeTest extends TestCase
$this->assertDatabaseHas('ticket_types', [
'project_id' => $coreProject->id,
'name' => 'Task',
'system_key' => 'task',
'is_builtin' => true,
'is_parent' => false,
'position' => 1,
]);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $coreProject->id,
'name' => 'Parent',
'system_key' => 'parent',
'is_builtin' => true,
'name' => 'Release',
'is_parent' => true,
'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,
]);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
@@ -71,10 +68,7 @@ class ProjectBacklogControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$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
{
$user = User::factory()->create();
$assignee = User::factory()->create();
$project = Project::factory()->create([
'key' => 'AUTH',
]);
@@ -103,7 +98,7 @@ class ProjectBacklogControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 1,
'position' => 2,
]);
$this->syncProjectPermissions($project, $user, ['create', 'view']);
@@ -112,6 +107,8 @@ class ProjectBacklogControllerTest extends TestCase
'title' => 'Same title',
'description' => 'Description',
'acceptance_criteria' => 'Criteria',
'assignee_user_id' => $assignee->id,
'story_points' => 3,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_priority_id' => $priority->id,
];
@@ -130,14 +127,20 @@ class ProjectBacklogControllerTest extends TestCase
'project_id' => $project->id,
'ticket_key' => 'AUTH-1',
'title' => 'Same title',
'assignee_user_id' => $assignee->id,
'author_user_id' => $user->id,
'board_column_id' => null,
'story_points' => 3,
]);
$this->assertDatabaseHas('tickets', [
'project_id' => $project->id,
'ticket_key' => 'AUTH-2',
'title' => 'Same title',
'assignee_user_id' => $assignee->id,
'author_user_id' => $user->id,
'board_column_id' => null,
'story_points' => 3,
]);
$this->assertDatabaseCount('ticket_worklog_entries', 2);
@@ -39,7 +39,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 1,
'position' => 2,
]);
Ticket::factory()->create([
@@ -99,7 +99,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 1,
'position' => 2,
]);
$ticket = Ticket::factory()->create([
@@ -158,7 +158,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 1,
'position' => 2,
]);
$ticket = Ticket::factory()->create([
@@ -213,7 +213,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 1,
'position' => 2,
]);
$ticket = Ticket::factory()->create([
@@ -269,7 +269,7 @@ class ProjectBoardControllerTest extends TestCase
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 1,
'position' => 2,
]);
$ticket = Ticket::factory()->create([
@@ -7,7 +7,6 @@ use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
@@ -155,15 +154,13 @@ class ProjectControllerTest extends TestCase
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'name' => 'Task',
'system_key' => 'task',
'is_builtin' => true,
'is_parent' => false,
]);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'name' => 'Parent',
'system_key' => 'parent',
'is_builtin' => true,
'name' => 'Release',
'is_parent' => true,
]);
}
@@ -344,10 +341,7 @@ class ProjectControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'In Progress',
'position' => 1,
@@ -358,7 +352,11 @@ class ProjectControllerTest extends TestCase
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'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',
]);
@@ -7,7 +7,6 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\User;
use App\Models\UserGitConnection;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -237,11 +236,11 @@ class ProjectTicketBranchControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
@@ -11,6 +11,7 @@ use App\Models\TicketFieldValue;
use App\Models\TicketLink;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketType;
use App\Models\TicketWorklogEntry;
use App\Models\User;
use App\Models\UserGitConnection;
@@ -29,19 +30,23 @@ class ProjectTicketControllerTest extends TestCase
public function test_users_with_view_permission_can_view_ticket_details(): void
{
$viewer = User::factory()->create();
$author = User::factory()->create([
'name' => 'Author User',
]);
$assignee = User::factory()->create([
'name' => 'Assigned User',
]);
$project = Project::factory()->create([
'key' => 'CORE',
'show_author' => false,
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number',
'label' => 'Story Points',
'label' => 'Complexity',
'position' => 1,
]);
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
@@ -50,26 +55,30 @@ class ProjectTicketControllerTest extends TestCase
]);
$ticket = Ticket::factory()->create([
'assignee_user_id' => $assignee->id,
'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' => $project->ticketTypes()->firstOrFail()->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Ticket detail test',
]);
$linkedTicket = Ticket::factory()->create([
'author_user_id' => $author->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Linked ticket',
]);
TicketFieldValue::factory()->create([
'ticket_field_definition_id' => $fieldDefinition->id,
'ticket_id' => $ticket->id,
'value_number' => 5,
'value_number' => 3,
'value_user_id' => null,
]);
TicketLink::factory()->create([
@@ -81,7 +90,7 @@ class ProjectTicketControllerTest extends TestCase
TicketWorklogEntry::factory()->create([
'actor_id' => $viewer->id,
'entry_type' => 'updated',
'summary' => 'Updated Story Points field.',
'summary' => 'Updated story points.',
'ticket_id' => $ticket->id,
]);
@@ -93,16 +102,62 @@ class ProjectTicketControllerTest extends TestCase
->assertInertia(fn (Assert $page) => $page
->component('projects/Ticket')
->where('project.id', $project->id)
->where('project.show_author', false)
->where('ticket.ticket_key', 'CORE-1')
->where('suggestedBranchName', 'core-1-ticket-detail-test')
->where('ticket.field_values.0.ticket_field_definition.label', 'Story Points')
->where('ticket.field_values.0.value_number', 5)
->where('ticket.story_points', 5)
->where('ticket.assignee.name', 'Assigned User')
->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('linkedTickets.0.linked_ticket.ticket_key', 'CORE-2')
->where('linkedTickets.0.ticket_link_type.label', 'Relates to')
->where('repositoryBranches', [])
->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);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Branch detail test',
]);
@@ -232,45 +284,40 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->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->actingAs($viewer)
->patch(route('projects.tickets.update', [$project, $ticket]), [
->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'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();
}
/**
* 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
{
$editor = User::factory()->create([
'name' => 'Editor User',
]);
$author = User::factory()->create([
'name' => 'Author User',
]);
$assignee = User::factory()->create([
'name' => 'Assigned User',
]);
$reviewer = User::factory()->create([
'name' => 'Reviewer User',
]);
$project = Project::factory()->create([
'key' => 'CORE',
]);
@@ -279,73 +326,72 @@ class ProjectTicketControllerTest extends TestCase
$lowPriority = TicketPriority::factory()->for($project)->create([
'name' => 'Low',
'position' => 1,
'position' => 2,
]);
$highPriority = TicketPriority::factory()->for($project)->create([
'name' => 'High',
'position' => 2,
'position' => 3,
]);
$assigneeField = TicketFieldDefinition::factory()->for($project)->create([
$reviewerField = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'user',
'label' => 'Assignee',
'label' => 'Reviewer',
'position' => 1,
]);
$storyPointsField = TicketFieldDefinition::factory()->for($project)->create([
$complexityField = TicketFieldDefinition::factory()->for($project)->create([
'field_type' => 'number',
'label' => 'Story Points',
'label' => 'Complexity',
'position' => 2,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
$ticket = Ticket::factory()->create([
'acceptance_criteria' => 'Old acceptance criteria',
'author_user_id' => $author->id,
'description' => 'Old description',
'project_id' => $project->id,
'sequence_number' => 1,
'story_points' => 3,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $lowPriority->id,
'ticket_type_id' => $taskType->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Old title',
]);
TicketFieldValue::factory()->create([
'ticket_field_definition_id' => $assigneeField->id,
'ticket_field_definition_id' => $reviewerField->id,
'ticket_id' => $ticket->id,
'value_number' => null,
'value_user_id' => $editor->id,
]);
TicketFieldValue::factory()->create([
'ticket_field_definition_id' => $storyPointsField->id,
'ticket_field_definition_id' => $complexityField->id,
'ticket_id' => $ticket->id,
'value_number' => 3,
'value_number' => 2,
'value_user_id' => null,
]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
$this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [
'title' => 'Updated title',
'description' => 'Updated description',
'acceptance_criteria' => 'Updated acceptance criteria',
'assignee_user_id' => $assignee->id,
'blocked_comment' => $ticket->blocked_comment,
'is_blocked' => $ticket->is_blocked,
'parent_ticket_id' => $ticket->parent_ticket_id,
'ticket_type_id' => $parentType->id,
'ticket_priority_id' => $highPriority->id,
'description' => 'Updated description',
'field_values' => [
[
'ticket_field_definition_id' => $assigneeField->id,
'value_user_id' => $assignee->id,
'ticket_field_definition_id' => $reviewerField->id,
'value_user_id' => $reviewer->id,
],
[
'ticket_field_definition_id' => $storyPointsField->id,
'ticket_field_definition_id' => $complexityField->id,
'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]))
->assertSessionHas('status', 'CORE-1 was updated.');
@@ -355,27 +401,26 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Updated title',
'description' => 'Updated description',
'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,
]);
$this->assertDatabaseHas('ticket_field_values', [
'ticket_id' => $ticket->id,
'ticket_field_definition_id' => $assigneeField->id,
'value_user_id' => $assignee->id,
'ticket_field_definition_id' => $reviewerField->id,
'value_user_id' => $reviewer->id,
]);
$this->assertDatabaseHas('ticket_field_values', [
'ticket_id' => $ticket->id,
'ticket_field_definition_id' => $storyPointsField->id,
'ticket_field_definition_id' => $complexityField->id,
'value_number' => 8.0,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $ticket->id,
'actor_id' => $editor->id,
'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);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $this->nonParentType($project);
$candidateParent = Ticket::factory()->create([
'project_id' => $project->id,
@@ -406,7 +447,6 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $taskType->id,
'title' => 'Task ticket',
]);
$childTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 2,
@@ -420,17 +460,9 @@ class ProjectTicketControllerTest extends TestCase
$this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $childTicket]))
->patch(route('projects.tickets.update', [$project, $childTicket]), [
'title' => $childTicket->title,
'description' => $childTicket->description,
'acceptance_criteria' => $childTicket->acceptance_criteria,
'blocked_comment' => $childTicket->blocked_comment,
'is_blocked' => $childTicket->is_blocked,
->patch(route('projects.tickets.update', [$project, $childTicket]), $this->updatePayload($childTicket, [
'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]))
->assertSessionHasErrors('parent_ticket_id');
}
@@ -447,46 +479,31 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
$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' => $parentType->id,
'ticket_type_id' => $this->parentType($project)->id,
'title' => 'Parent ticket',
]);
$childTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Child ticket',
]);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $childTicket]), [
'title' => $childTicket->title,
'description' => $childTicket->description,
'acceptance_criteria' => $childTicket->acceptance_criteria,
'blocked_comment' => $childTicket->blocked_comment,
'is_blocked' => $childTicket->is_blocked,
->patch(route('projects.tickets.update', [$project, $childTicket]), $this->updatePayload($childTicket, [
'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]))
->assertSessionHas('status', 'CORE-2 was updated.');
@@ -494,7 +511,6 @@ class ProjectTicketControllerTest extends TestCase
'id' => $childTicket->id,
'parent_ticket_id' => $parentTicket->id,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $childTicket->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.
*/
@@ -515,50 +571,41 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$parentType = $project->ticketTypes()->where('system_key', 'parent')->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$rootParent = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $parentType->id,
'ticket_type_id' => $this->parentType($project)->id,
'title' => 'Root parent',
]);
$nestedParent = Ticket::factory()->create([
'parent_ticket_id' => $rootParent->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $parentType->id,
'ticket_type_id' => $this->parentType($project)->id,
'title' => 'Nested parent',
]);
$directTask = Ticket::factory()->create([
'parent_ticket_id' => $rootParent->id,
'project_id' => $project->id,
'sequence_number' => 3,
'ticket_key' => 'CORE-3',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Direct task',
]);
$nestedTask = Ticket::factory()->create([
'parent_ticket_id' => $nestedParent->id,
'project_id' => $project->id,
'sequence_number' => 4,
'ticket_key' => 'CORE-4',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Nested task',
]);
@@ -585,31 +632,21 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->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->actingAs($editor)
->from(route('projects.tickets.show', [$project, $ticket]))
->patch(route('projects.tickets.update', [$project, $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,
->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'blocked_comment' => '',
'field_values' => [],
])
'is_blocked' => true,
]))
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHasErrors('blocked_comment');
@@ -632,32 +669,22 @@ class ProjectTicketControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$priority = $project->ticketPriorities()->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'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->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $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,
->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'blocked_comment' => 'Waiting for dependency approval.',
'field_values' => [],
])
'is_blocked' => true,
]))
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'CORE-1 was updated.');
@@ -666,7 +693,6 @@ class ProjectTicketControllerTest extends TestCase
'is_blocked' => true,
'blocked_comment' => 'Waiting for dependency approval.',
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $ticket->id,
'actor_id' => $editor->id,
@@ -675,17 +701,10 @@ class ProjectTicketControllerTest extends TestCase
]);
$this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $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,
->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
'blocked_comment' => '',
'field_values' => [],
])
'is_blocked' => false,
]))
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'CORE-1 was updated.');
@@ -694,7 +713,6 @@ class ProjectTicketControllerTest extends TestCase
'is_blocked' => false,
'blocked_comment' => null,
]);
$this->assertSame(
2,
TicketWorklogEntry::query()
@@ -704,4 +722,120 @@ class ProjectTicketControllerTest extends TestCase
->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\TicketLink;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -28,11 +27,11 @@ class ProjectTicketLinkControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to',
'position' => 1,
@@ -88,16 +87,16 @@ class ProjectTicketLinkControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$otherPriority = TicketPriority::factory()->for($otherProject)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$otherTaskType = $otherProject->ticketTypes()->where('system_key', 'task')->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$otherPriority = $otherProject->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$otherTaskType = $otherProject->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to',
'position' => 1,
@@ -150,11 +149,11 @@ class ProjectTicketLinkControllerTest extends TestCase
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()
->where('is_parent', false)
->orderBy('position')
->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to',
'position' => 1,
@@ -2,7 +2,9 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
@@ -23,6 +25,9 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
{
$user = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
TicketType::factory()->for($project)->create([
'name' => 'Bug',
'position' => 3,
@@ -39,8 +44,11 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/Metadata')
->where('project.show_author', true)
->where('project.show_story_points', true)
->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('ticketLinkTypes.0.label', 'Relates to'),
);
@@ -58,6 +66,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->actingAs($user)
->post(route('projects.settings.metadata.types.store', $project), [
'is_parent' => '1',
'name' => 'Bug',
])
->assertRedirect(route('projects.settings.metadata', $project))
@@ -66,57 +75,245 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'name' => 'Bug',
'is_builtin' => false,
'system_key' => null,
'is_parent' => true,
]);
}
/**
* 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();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$releaseType = $project->ticketTypes()
->where('is_parent', true)
->orderBy('position')
->firstOrFail();
$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)
->patch(route('projects.settings.metadata.types.update', [$project, $taskType]), [
'name' => 'Standard Task',
'position' => 1,
->patch(route('projects.settings.metadata.types.update', [$project, $releaseType]), [
'is_parent' => '0',
'name' => 'Standard Release',
'position' => 2,
])
->assertRedirect(route('projects.settings.metadata', $project))
->assertSessionHas('status', 'Standard Task type was updated.');
->assertSessionHas('status', 'Standard Release type was updated.');
$this->assertDatabaseHas('ticket_types', [
'id' => $taskType->id,
'name' => 'Standard Task',
'system_key' => 'task',
'is_builtin' => true,
'id' => $releaseType->id,
'name' => 'Standard Release',
'is_parent' => 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)
->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();
}
/**
* 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.
*/
@@ -163,7 +360,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create();
$project = Project::factory()->create();
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
'label' => 'Story Points',
'label' => 'Complexity',
'field_type' => 'number',
'position' => 1,
]);
@@ -180,16 +377,16 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$this->actingAs($user)
->patch(route('projects.settings.metadata.fields.update', [$project, $fieldDefinition]), [
'label' => 'Estimated Points',
'label' => 'Risk score',
'field_type' => 'number',
'position' => 2,
])
->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', [
'id' => $fieldDefinition->id,
'label' => 'Estimated Points',
'label' => 'Risk score',
'field_type' => 'number',
]);
}