Merge pull request 'koda/recreate-67-checklist-acceptance-criteria' (#72) from koda/recreate-67-checklist-acceptance-criteria into main
Reviewed-on: OpenSource/KanbanGit#72 Reviewed-by: Koda <koda@twyco.de>
This commit was merged in pull request #72.
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Actions\Tickets;
|
|||||||
|
|
||||||
use App\Actions\Notifications\BuildTicketCreatedPayload;
|
use App\Actions\Notifications\BuildTicketCreatedPayload;
|
||||||
use App\Actions\Notifications\TicketCreated;
|
use App\Actions\Notifications\TicketCreated;
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
use App\Enums\TicketWorklogEntryType;
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
@@ -22,7 +23,11 @@ class CreateProjectTicket
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* acceptance_criteria: string|null,
|
* acceptance_criteria: array<int, array{
|
||||||
|
* checked: bool,
|
||||||
|
* indent: int,
|
||||||
|
* text: string
|
||||||
|
* }>,
|
||||||
* assignee_user_id?: int|null,
|
* assignee_user_id?: int|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
* story_points?: int|null,
|
* story_points?: int|null,
|
||||||
@@ -35,6 +40,9 @@ class CreateProjectTicket
|
|||||||
public function handle(Project $project, array $attributes, ?User $actor = null): Ticket
|
public function handle(Project $project, array $attributes, ?User $actor = null): Ticket
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($actor, $attributes, $project): Ticket {
|
return DB::transaction(function () use ($actor, $attributes, $project): Ticket {
|
||||||
|
$acceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||||
|
Arr::get($attributes, 'acceptance_criteria'),
|
||||||
|
);
|
||||||
$nextSequenceNumber = (int) Ticket::query()
|
$nextSequenceNumber = (int) Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->max('sequence_number') + 1;
|
->max('sequence_number') + 1;
|
||||||
@@ -43,7 +51,6 @@ class CreateProjectTicket
|
|||||||
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||||
|
|
||||||
$ticket = Ticket::query()->create([
|
$ticket = Ticket::query()->create([
|
||||||
'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'),
|
|
||||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||||
'author_user_id' => $actor?->id,
|
'author_user_id' => $actor?->id,
|
||||||
'description' => Arr::get($attributes, 'description'),
|
'description' => Arr::get($attributes, 'description'),
|
||||||
@@ -57,6 +64,8 @@ class CreateProjectTicket
|
|||||||
'story_points' => Arr::get($attributes, 'story_points'),
|
'story_points' => Arr::get($attributes, 'story_points'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
|
||||||
|
|
||||||
$occurredAt = CarbonImmutable::now();
|
$occurredAt = CarbonImmutable::now();
|
||||||
|
|
||||||
if ($actor !== null) {
|
if ($actor !== null) {
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Tickets\Support;
|
||||||
|
|
||||||
|
use App\Models\AcceptanceCriterion;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class AcceptanceCriteriaChecklistMapper
|
||||||
|
{
|
||||||
|
public const MAX_INDENT_LEVEL = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<AcceptanceCriterion> $criteria
|
||||||
|
* @return array<int, array{checked: bool, indent: int, text: string}>
|
||||||
|
*/
|
||||||
|
public static function fromModels(iterable $criteria): array
|
||||||
|
{
|
||||||
|
return self::flattenModels($criteria, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<AcceptanceCriterion> $criteria
|
||||||
|
* @return array<int, array{id: int, checked: bool, indent: int, text: string}>
|
||||||
|
*/
|
||||||
|
public static function fromModelsWithIds(iterable $criteria): array
|
||||||
|
{
|
||||||
|
return self::flattenModels($criteria, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
|
||||||
|
*/
|
||||||
|
public static function normalize(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return self::normalizeFromArray($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Ticket $ticket
|
||||||
|
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||||
|
*/
|
||||||
|
public static function syncTicket(Ticket $ticket, array $items): void
|
||||||
|
{
|
||||||
|
$normalized = self::normalize($items);
|
||||||
|
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||||
|
|
||||||
|
if ($normalized === []) {
|
||||||
|
$ticket->acceptanceCriteria()->delete();
|
||||||
|
$ticket->unsetRelation('acceptanceCriteria');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keptCriterionIds = [];
|
||||||
|
$parentStack = [];
|
||||||
|
$positionsByParent = [];
|
||||||
|
$fallbackCriteria = $existingCriteria->values();
|
||||||
|
$fallbackIndex = 0;
|
||||||
|
|
||||||
|
foreach ($normalized as $item) {
|
||||||
|
$indent = min($item['indent'], count($parentStack));
|
||||||
|
$parentId = $indent === 0 ? null : $parentStack[$indent - 1];
|
||||||
|
$positionKey = $parentId ?? 'root';
|
||||||
|
$position = $positionsByParent[$positionKey] ?? 0;
|
||||||
|
$criterionId = $item['id'] ?? null;
|
||||||
|
$criterion = $criterionId === null
|
||||||
|
? null
|
||||||
|
: $existingCriteria->get($criterionId);
|
||||||
|
|
||||||
|
while (
|
||||||
|
$criterion === null
|
||||||
|
&& $fallbackIndex < $fallbackCriteria->count()
|
||||||
|
) {
|
||||||
|
/** @var AcceptanceCriterion $candidate */
|
||||||
|
$candidate = $fallbackCriteria[$fallbackIndex];
|
||||||
|
$fallbackIndex++;
|
||||||
|
|
||||||
|
if (in_array($candidate->id, $keptCriterionIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$criterion = $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($criterion === null) {
|
||||||
|
$criterion = $ticket->acceptanceCriteria()->create([
|
||||||
|
'parent_acceptance_criterion_id' => $parentId,
|
||||||
|
'position' => $position,
|
||||||
|
'text' => $item['text'],
|
||||||
|
'is_checked' => $item['checked'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$criterion->fill([
|
||||||
|
'parent_acceptance_criterion_id' => $parentId,
|
||||||
|
'position' => $position,
|
||||||
|
'text' => $item['text'],
|
||||||
|
'is_checked' => $item['checked'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($criterion->isDirty()) {
|
||||||
|
$criterion->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$keptCriterionIds[] = $criterion->id;
|
||||||
|
$positionsByParent[$positionKey] = $position + 1;
|
||||||
|
$parentStack[$indent] = $criterion->id;
|
||||||
|
$parentStack = array_slice($parentStack, 0, $indent + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$idsToDelete = $existingCriteria->keys()->diff($keptCriterionIds);
|
||||||
|
|
||||||
|
if ($idsToDelete->isNotEmpty()) {
|
||||||
|
$ticket->acceptanceCriteria()
|
||||||
|
->whereIn('id', $idsToDelete->all())
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->unsetRelation('acceptanceCriteria');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string, mixed> $value
|
||||||
|
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
|
||||||
|
*/
|
||||||
|
private static function normalizeFromArray(array $value): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
foreach ($value as $rawItem) {
|
||||||
|
if (! is_array($rawItem)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim((string) Arr::get($rawItem, 'text', ''));
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedItem = [
|
||||||
|
'checked' => filter_var(Arr::get($rawItem, 'checked', false), FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'indent' => self::clampIndent((int) Arr::get($rawItem, 'indent', 0)),
|
||||||
|
'text' => $text,
|
||||||
|
];
|
||||||
|
|
||||||
|
$id = filter_var(Arr::get($rawItem, 'id'), FILTER_VALIDATE_INT);
|
||||||
|
|
||||||
|
if (is_int($id) && $id > 0) {
|
||||||
|
$normalizedItem['id'] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = $normalizedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function clampIndent(int $indent): int
|
||||||
|
{
|
||||||
|
return max(0, min(self::MAX_INDENT_LEVEL, $indent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<AcceptanceCriterion> $criteria
|
||||||
|
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
|
||||||
|
*/
|
||||||
|
private static function flattenModels(iterable $criteria, bool $includeIds): array
|
||||||
|
{
|
||||||
|
$models = ($criteria instanceof EloquentCollection ? $criteria : collect($criteria))
|
||||||
|
->sort(function (AcceptanceCriterion $left, AcceptanceCriterion $right): int {
|
||||||
|
return [
|
||||||
|
$left->parent_acceptance_criterion_id ?? 0,
|
||||||
|
$left->position,
|
||||||
|
$left->id,
|
||||||
|
] <=> [
|
||||||
|
$right->parent_acceptance_criterion_id ?? 0,
|
||||||
|
$right->position,
|
||||||
|
$right->id,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($models->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int|string, iterable<AcceptanceCriterion>> $grouped */
|
||||||
|
$grouped = $models->groupBy('parent_acceptance_criterion_id')->all();
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
self::flattenItems($items, $grouped, null, 0, $includeIds);
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||||
|
* @param array<int|string, iterable<AcceptanceCriterion>> $grouped
|
||||||
|
*/
|
||||||
|
private static function flattenItems(array &$items, array $grouped, ?int $parentId, int $indent, bool $includeIds): void
|
||||||
|
{
|
||||||
|
foreach ($grouped[$parentId] ?? [] as $criterion) {
|
||||||
|
$item = [
|
||||||
|
'checked' => $criterion->is_checked,
|
||||||
|
'indent' => $indent,
|
||||||
|
'text' => $criterion->text,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($includeIds) {
|
||||||
|
$item['id'] = $criterion->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = $item;
|
||||||
|
|
||||||
|
self::flattenItems($items, $grouped, $criterion->id, $indent + 1, $includeIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\Tickets;
|
namespace App\Actions\Tickets;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
use App\Enums\TicketWorklogEntryType;
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketFieldDefinition;
|
use App\Models\TicketFieldDefinition;
|
||||||
@@ -20,7 +21,11 @@ class UpdateProjectTicket
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* acceptance_criteria: string|null,
|
* acceptance_criteria: array<int, array{
|
||||||
|
* checked: bool,
|
||||||
|
* indent: int,
|
||||||
|
* text: string
|
||||||
|
* }>,
|
||||||
* assignee_user_id?: int|null,
|
* assignee_user_id?: int|null,
|
||||||
* author_user_id?: int|null,
|
* author_user_id?: int|null,
|
||||||
* blocked_comment: string|null,
|
* blocked_comment: string|null,
|
||||||
@@ -44,9 +49,12 @@ class UpdateProjectTicket
|
|||||||
array $validated,
|
array $validated,
|
||||||
): bool {
|
): bool {
|
||||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||||
|
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||||
|
Arr::get($validated, 'acceptance_criteria'),
|
||||||
|
);
|
||||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
||||||
|
|
||||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $ticket, $ticketAttributes): bool {
|
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
|
||||||
if (! $ticketAttributes['is_blocked']) {
|
if (! $ticketAttributes['is_blocked']) {
|
||||||
$ticketAttributes['blocked_comment'] = null;
|
$ticketAttributes['blocked_comment'] = null;
|
||||||
}
|
}
|
||||||
@@ -54,6 +62,7 @@ class UpdateProjectTicket
|
|||||||
$ticket->loadMissing([
|
$ticket->loadMissing([
|
||||||
'assignee',
|
'assignee',
|
||||||
'author',
|
'author',
|
||||||
|
'acceptanceCriteria',
|
||||||
'fieldValues.ticketFieldDefinition',
|
'fieldValues.ticketFieldDefinition',
|
||||||
'fieldValues.userValue',
|
'fieldValues.userValue',
|
||||||
'parentTicket',
|
'parentTicket',
|
||||||
@@ -77,6 +86,7 @@ class UpdateProjectTicket
|
|||||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||||
$existingFieldValues = $ticket->fieldValues
|
$existingFieldValues = $ticket->fieldValues
|
||||||
->keyBy('ticket_field_definition_id');
|
->keyBy('ticket_field_definition_id');
|
||||||
|
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||||
|
|
||||||
$changes = [];
|
$changes = [];
|
||||||
$summaryLabels = [];
|
$summaryLabels = [];
|
||||||
@@ -97,13 +107,11 @@ class UpdateProjectTicket
|
|||||||
$ticket->description,
|
$ticket->description,
|
||||||
$ticketAttributes['description'],
|
$ticketAttributes['description'],
|
||||||
);
|
);
|
||||||
$this->trackScalarChange(
|
$this->trackAcceptanceCriteriaChange(
|
||||||
$changes,
|
$changes,
|
||||||
$summaryLabels,
|
$summaryLabels,
|
||||||
'acceptance_criteria',
|
$currentAcceptanceCriteria,
|
||||||
'acceptance criteria',
|
$nextAcceptanceCriteria,
|
||||||
$ticket->acceptance_criteria,
|
|
||||||
$ticketAttributes['acceptance_criteria'],
|
|
||||||
);
|
);
|
||||||
$this->trackScalarChange(
|
$this->trackScalarChange(
|
||||||
$changes,
|
$changes,
|
||||||
@@ -184,6 +192,10 @@ class UpdateProjectTicket
|
|||||||
$ticket->save();
|
$ticket->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||||
|
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
||||||
|
}
|
||||||
|
|
||||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$fieldDefinitions,
|
$fieldDefinitions,
|
||||||
@@ -217,7 +229,11 @@ class UpdateProjectTicket
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* acceptance_criteria: string|null,
|
* acceptance_criteria: array<int, array{
|
||||||
|
* checked: bool,
|
||||||
|
* indent: int,
|
||||||
|
* text: string
|
||||||
|
* }>,
|
||||||
* assignee_user_id?: int|null,
|
* assignee_user_id?: int|null,
|
||||||
* author_user_id?: int|null,
|
* author_user_id?: int|null,
|
||||||
* blocked_comment: string|null,
|
* blocked_comment: string|null,
|
||||||
@@ -230,7 +246,6 @@ class UpdateProjectTicket
|
|||||||
* title: string
|
* title: string
|
||||||
* } $validated
|
* } $validated
|
||||||
* @return array{
|
* @return array{
|
||||||
* acceptance_criteria: string|null,
|
|
||||||
* assignee_user_id: int|null,
|
* assignee_user_id: int|null,
|
||||||
* author_user_id: int|null,
|
* author_user_id: int|null,
|
||||||
* blocked_comment: string|null,
|
* blocked_comment: string|null,
|
||||||
@@ -246,7 +261,6 @@ class UpdateProjectTicket
|
|||||||
private function buildTicketAttributes(Ticket $ticket, array $validated): array
|
private function buildTicketAttributes(Ticket $ticket, array $validated): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'),
|
|
||||||
'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
|
'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
|
||||||
? Arr::get($validated, 'assignee_user_id')
|
? Arr::get($validated, 'assignee_user_id')
|
||||||
: $ticket->assignee_user_id,
|
: $ticket->assignee_user_id,
|
||||||
@@ -266,6 +280,29 @@ class UpdateProjectTicket
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $changes
|
||||||
|
* @param array<int, string> $summaryLabels
|
||||||
|
* @param array<int, array{checked: bool, indent: int, text: string}> $currentValue
|
||||||
|
* @param array<int, array{checked: bool, indent: int, text: string}> $newValue
|
||||||
|
*/
|
||||||
|
private function trackAcceptanceCriteriaChange(
|
||||||
|
array &$changes,
|
||||||
|
array &$summaryLabels,
|
||||||
|
array $currentValue,
|
||||||
|
array $newValue,
|
||||||
|
): void {
|
||||||
|
if ($currentValue === $newValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes['acceptance_criteria'] = [
|
||||||
|
'from' => $currentValue,
|
||||||
|
'to' => $newValue,
|
||||||
|
];
|
||||||
|
$summaryLabels[] = 'acceptance criteria';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $changes
|
* @param array<string, mixed> $changes
|
||||||
* @param array<int, string> $summaryLabels
|
* @param array<int, string> $summaryLabels
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
@@ -17,6 +18,24 @@ class StoreProjectTicketRequest extends FormRequest
|
|||||||
return $this->user() !== null;
|
return $this->user() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the data for validation.
|
||||||
|
*/
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$acceptanceCriteria = $this->input('acceptance_criteria');
|
||||||
|
|
||||||
|
if ($acceptanceCriteria !== null && ! is_array($acceptanceCriteria)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::normalize(
|
||||||
|
$acceptanceCriteria,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
@@ -28,7 +47,12 @@ class StoreProjectTicketRequest extends FormRequest
|
|||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => ['nullable', 'string'],
|
'acceptance_criteria' => ['nullable', 'array'],
|
||||||
|
'acceptance_criteria.*' => ['array'],
|
||||||
|
'acceptance_criteria.*.id' => ['nullable', 'integer'],
|
||||||
|
'acceptance_criteria.*.text' => ['required', 'string'],
|
||||||
|
'acceptance_criteria.*.checked' => ['required', 'boolean'],
|
||||||
|
'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL],
|
||||||
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string'],
|
||||||
'ticket_priority_id' => [
|
'ticket_priority_id' => [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
@@ -23,6 +24,24 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
return $this->user() !== null;
|
return $this->user() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the data for validation.
|
||||||
|
*/
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$acceptanceCriteria = $this->input('acceptance_criteria');
|
||||||
|
|
||||||
|
if ($acceptanceCriteria !== null && ! is_array($acceptanceCriteria)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::normalize(
|
||||||
|
$acceptanceCriteria,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
@@ -34,7 +53,12 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => ['nullable', 'string'],
|
'acceptance_criteria' => ['nullable', 'array'],
|
||||||
|
'acceptance_criteria.*' => ['array'],
|
||||||
|
'acceptance_criteria.*.id' => ['nullable', 'integer'],
|
||||||
|
'acceptance_criteria.*.text' => ['required', 'string'],
|
||||||
|
'acceptance_criteria.*.checked' => ['required', 'boolean'],
|
||||||
|
'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL],
|
||||||
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
'blocked_comment' => ['nullable', 'string'],
|
'blocked_comment' => ['nullable', 'string'],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources;
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -16,7 +17,11 @@ class TicketResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'acceptance_criteria' => $this->acceptance_criteria,
|
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds(
|
||||||
|
$this->relationLoaded('acceptanceCriteria')
|
||||||
|
? $this->acceptanceCriteria
|
||||||
|
: $this->acceptanceCriteria()->get(),
|
||||||
|
),
|
||||||
'assignee' => $this->whenLoaded(
|
'assignee' => $this->whenLoaded(
|
||||||
'assignee',
|
'assignee',
|
||||||
fn (): ?array => $this->assignee === null
|
fn (): ?array => $this->assignee === null
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Mcp\Support;
|
namespace App\Mcp\Support;
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Http\Resources\TicketResource;
|
use App\Http\Resources\TicketResource;
|
||||||
@@ -207,7 +208,7 @@ class KanbanGitMcp
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* acceptance_criteria: string|null,
|
* acceptance_criteria: array<int, array{checked: bool, indent: int, text: string}>,
|
||||||
* assignee_user_id: int|null,
|
* assignee_user_id: int|null,
|
||||||
* author_user_id: int|null,
|
* author_user_id: int|null,
|
||||||
* blocked_comment: string|null,
|
* blocked_comment: string|null,
|
||||||
@@ -223,7 +224,11 @@ class KanbanGitMcp
|
|||||||
public function updatePayload(Ticket $ticket): array
|
public function updatePayload(Ticket $ticket): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => $ticket->acceptance_criteria,
|
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModels(
|
||||||
|
$ticket->relationLoaded('acceptanceCriteria')
|
||||||
|
? $ticket->acceptanceCriteria
|
||||||
|
: $ticket->acceptanceCriteria()->get(),
|
||||||
|
),
|
||||||
'assignee_user_id' => $ticket->assignee_user_id,
|
'assignee_user_id' => $ticket->assignee_user_id,
|
||||||
'author_user_id' => $ticket->author_user_id,
|
'author_user_id' => $ticket->author_user_id,
|
||||||
'blocked_comment' => $ticket->blocked_comment,
|
'blocked_comment' => $ticket->blocked_comment,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'ticket_id',
|
||||||
|
'parent_acceptance_criterion_id',
|
||||||
|
'position',
|
||||||
|
'text',
|
||||||
|
'is_checked',
|
||||||
|
])]
|
||||||
|
class AcceptanceCriterion extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_checked' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function children(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(self::class, 'parent_acceptance_criterion_id')
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(self::class, 'parent_acceptance_criterion_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'acceptance_criteria',
|
|
||||||
'assignee_user_id',
|
'assignee_user_id',
|
||||||
'author_user_id',
|
'author_user_id',
|
||||||
'blocked_comment',
|
'blocked_comment',
|
||||||
@@ -41,6 +40,13 @@ class Ticket extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function acceptanceCriteria(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AcceptanceCriterion::class)
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
public function assignee(): BelongsTo
|
public function assignee(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'assignee_user_id');
|
return $this->belongsTo(User::class, 'assignee_user_id');
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class TicketFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => fake()->paragraph(),
|
|
||||||
'assignee_user_id' => null,
|
'assignee_user_id' => null,
|
||||||
'author_user_id' => User::factory(),
|
'author_user_id' => User::factory(),
|
||||||
'blocked_comment' => null,
|
'blocked_comment' => null,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('acceptance_criteria', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('ticket_id')
|
||||||
|
->constrained()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->foreignId('parent_acceptance_criterion_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('acceptance_criteria')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('position')->default(0);
|
||||||
|
$table->text('text');
|
||||||
|
$table->boolean('is_checked')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['ticket_id', 'parent_acceptance_criterion_id', 'position']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('acceptance_criteria');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('tickets', 'acceptance_criteria')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('acceptance_criteria');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('tickets', 'acceptance_criteria')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->text('acceptance_criteria')->nullable()->after('description');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -42,7 +42,11 @@ class TicketSeeder extends Seeder
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$createProjectTicket->handle($project, [
|
$createProjectTicket->handle($project, [
|
||||||
'acceptance_criteria' => 'The seeded ticket should be visible in the backlog list.',
|
'acceptance_criteria' => [[
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 0,
|
||||||
|
'text' => 'The seeded ticket should be visible in the backlog list.',
|
||||||
|
]],
|
||||||
'assignee_user_id' => $assignee?->id,
|
'assignee_user_id' => $assignee?->id,
|
||||||
'description' => 'Seeded backlog item for demonstrating the first ticket workflow.',
|
'description' => 'Seeded backlog item for demonstrating the first ticket workflow.',
|
||||||
'story_points' => 3,
|
'story_points' => 3,
|
||||||
@@ -53,7 +57,11 @@ class TicketSeeder extends Seeder
|
|||||||
], $author);
|
], $author);
|
||||||
|
|
||||||
$createProjectTicket->handle($project, [
|
$createProjectTicket->handle($project, [
|
||||||
'acceptance_criteria' => 'Duplicate titles must be allowed for project-local ticket keys.',
|
'acceptance_criteria' => [[
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 0,
|
||||||
|
'text' => 'Duplicate titles must be allowed for project-local ticket keys.',
|
||||||
|
]],
|
||||||
'assignee_user_id' => null,
|
'assignee_user_id' => null,
|
||||||
'description' => 'Second seeded backlog item using the same title on purpose.',
|
'description' => 'Second seeded backlog item using the same title on purpose.',
|
||||||
'story_points' => 5,
|
'story_points' => 5,
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type {
|
||||||
|
AcceptanceCriteriaItemModel,
|
||||||
|
AcceptanceCriteriaModel,
|
||||||
|
} from '@/types/models/acceptance-criteria-model';
|
||||||
|
|
||||||
|
const MAX_INDENT_LEVEL = 8;
|
||||||
|
|
||||||
|
export type AcceptanceCriteriaEditorState = {
|
||||||
|
checklist: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => [];
|
||||||
|
|
||||||
|
export const normalizeAcceptanceCriteria = (
|
||||||
|
value: AcceptanceCriteriaModel | null | undefined,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
const items = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
.map((item) => ({
|
||||||
|
...(typeof item.id === 'number' && item.id > 0
|
||||||
|
? { id: item.id }
|
||||||
|
: {}),
|
||||||
|
checked: item.checked === true,
|
||||||
|
indent: clampIndent(item.indent),
|
||||||
|
text: item.text.trim(),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.text !== '')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseChecklistLines = (
|
||||||
|
checklistInput: string,
|
||||||
|
): AcceptanceCriteriaItemModel[] => {
|
||||||
|
if (checklistInput.trim() === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return checklistInput
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((line) => parseChecklistLine(line))
|
||||||
|
.filter(
|
||||||
|
(parsedLine): parsedLine is AcceptanceCriteriaItemModel =>
|
||||||
|
parsedLine !== null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatChecklistLines = (
|
||||||
|
items: AcceptanceCriteriaItemModel[],
|
||||||
|
): string => {
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
const indentation = ' '.repeat(clampIndent(item.indent));
|
||||||
|
const marker = item.checked ? '[x]' : '[ ]';
|
||||||
|
|
||||||
|
return `${indentation}${marker} ${item.text}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAcceptanceCriteria = (
|
||||||
|
checklistInput: string,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
return normalizeAcceptanceCriteria(parseChecklistLines(checklistInput));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const acceptanceCriteriaToEditorState = (
|
||||||
|
value: AcceptanceCriteriaModel | null | undefined,
|
||||||
|
): AcceptanceCriteriaEditorState => {
|
||||||
|
const normalized = normalizeAcceptanceCriteria(value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
checklist: formatChecklistLines(normalized),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseChecklistLine = (
|
||||||
|
line: string,
|
||||||
|
): AcceptanceCriteriaItemModel | null => {
|
||||||
|
const normalizedLine = line.replaceAll('\t', ' ').trimEnd();
|
||||||
|
|
||||||
|
if (normalizedLine.trim() === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^(?<indent>\s*)(?<content>.+)$/u.exec(normalizedLine);
|
||||||
|
|
||||||
|
if (match === null || match.groups === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indentLevel = clampIndent(
|
||||||
|
Math.floor(match.groups.indent.length / 2),
|
||||||
|
);
|
||||||
|
let content = match.groups.content.trim();
|
||||||
|
let checked = false;
|
||||||
|
|
||||||
|
const checkboxMatch =
|
||||||
|
/^(?:[-*+]\s+)?\[(?<state>[xX ])\]\s*(?<text>.+)$/u.exec(content);
|
||||||
|
|
||||||
|
if (checkboxMatch?.groups !== undefined) {
|
||||||
|
checked = checkboxMatch.groups.state.toLowerCase() === 'x';
|
||||||
|
content = checkboxMatch.groups.text.trim();
|
||||||
|
} else {
|
||||||
|
const bulletMatch = /^[-*+]\s+(?<text>.+)$/u.exec(content);
|
||||||
|
|
||||||
|
if (bulletMatch?.groups !== undefined) {
|
||||||
|
content = bulletMatch.groups.text.trim();
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checked,
|
||||||
|
indent: indentLevel,
|
||||||
|
text: content,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampIndent = (indent: number): number => {
|
||||||
|
if (!Number.isFinite(indent)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(MAX_INDENT_LEVEL, Math.trunc(indent)));
|
||||||
|
};
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
|
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
|
||||||
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
acceptanceCriteriaToEditorState,
|
||||||
|
buildAcceptanceCriteria,
|
||||||
|
formatChecklistLines,
|
||||||
|
normalizeAcceptanceCriteria,
|
||||||
|
parseChecklistLines,
|
||||||
|
} from '@/lib/acceptance-criteria';
|
||||||
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||||
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
@@ -103,8 +110,18 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
||||||
|
props.ticket.acceptance_criteria,
|
||||||
|
);
|
||||||
|
const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist);
|
||||||
|
const savedAcceptanceCriteria = ref(
|
||||||
|
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
||||||
|
);
|
||||||
|
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
||||||
|
const acceptanceCriteriaToggleError = ref('');
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
acceptance_criteria: props.ticket.acceptance_criteria ?? '',
|
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
|
||||||
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
||||||
author_user_id: props.ticket.author_user_id ?? '',
|
author_user_id: props.ticket.author_user_id ?? '',
|
||||||
blocked_comment: props.ticket.blocked_comment ?? '',
|
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||||
@@ -130,6 +147,26 @@ const form = useForm({
|
|||||||
story_points: props.ticket.story_points,
|
story_points: props.ticket.story_points,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||||
|
savedAcceptanceCriteria.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||||
|
() =>
|
||||||
|
acceptanceCriteriaChecklist.value !==
|
||||||
|
formatChecklistLines(savedAcceptanceCriteria.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptanceCriteriaError = computed(() => {
|
||||||
|
return (
|
||||||
|
form.errors.acceptance_criteria ??
|
||||||
|
Object.entries(form.errors).find(([key]) =>
|
||||||
|
key.startsWith('acceptance_criteria.'),
|
||||||
|
)?.[1] ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const isCreateBranchDialogOpen = ref(false);
|
const isCreateBranchDialogOpen = ref(false);
|
||||||
const isCheckingBranchName = ref(false);
|
const isCheckingBranchName = ref(false);
|
||||||
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||||
@@ -313,11 +350,118 @@ const submitBranchCreation = (): void => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildAcceptanceCriteriaForSubmit = () => {
|
||||||
|
const draftItems = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
|
||||||
|
|
||||||
|
return draftItems.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
...(savedAcceptanceCriteria.value[index]?.id !== undefined
|
||||||
|
? { id: savedAcceptanceCriteria.value[index].id }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSavedTicketPayload = (acceptanceCriteria = savedAcceptanceCriteria.value) => ({
|
||||||
|
acceptance_criteria: acceptanceCriteria,
|
||||||
|
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.ticket.field_values ?? []).map((fieldValue) => ({
|
||||||
|
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
|
||||||
|
value_number: fieldValue.value_number,
|
||||||
|
value_user_id: fieldValue.value_user_id,
|
||||||
|
})),
|
||||||
|
is_blocked: props.ticket.is_blocked,
|
||||||
|
parent_ticket_id: props.ticket.parent_ticket_id,
|
||||||
|
story_points: props.ticket.story_points,
|
||||||
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||||
|
ticket_type_id: props.ticket.ticket_type_id,
|
||||||
|
title: props.ticket.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistAcceptanceCriteriaToggle = async (
|
||||||
|
index: number,
|
||||||
|
checked: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (
|
||||||
|
!props.canEditTicket ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value ||
|
||||||
|
hasUnsavedAcceptanceCriteriaDraft.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAcceptanceCriteria = savedAcceptanceCriteria.value.map((item, itemIndex) =>
|
||||||
|
itemIndex === index ? { ...item, checked } : item,
|
||||||
|
);
|
||||||
|
const previousChecklist = formatChecklistLines(savedAcceptanceCriteria.value);
|
||||||
|
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
|
||||||
|
const csrfToken = document
|
||||||
|
.querySelector('meta[name="csrf-token"]')
|
||||||
|
?.getAttribute('content');
|
||||||
|
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value = true;
|
||||||
|
acceptanceCriteriaToggleError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
ProjectTicketController.update.url({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/html, application/xhtml+xml',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken ?? '',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(
|
||||||
|
buildSavedTicketPayload(nextAcceptanceCriteria),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Checklist toggle update failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
|
||||||
|
|
||||||
|
if (acceptanceCriteriaChecklist.value === previousChecklist) {
|
||||||
|
acceptanceCriteriaChecklist.value = nextChecklist;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
acceptanceCriteriaToggleError.value =
|
||||||
|
'Checklist status could not be saved right now.';
|
||||||
|
} finally {
|
||||||
|
isPersistingAcceptanceCriteriaToggle.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAcceptanceCriteriaCheckboxChange = (
|
||||||
|
index: number,
|
||||||
|
event: Event,
|
||||||
|
): void => {
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
if (!(target instanceof HTMLInputElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void persistAcceptanceCriteriaToggle(index, target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
if (!props.canEditTicket) {
|
if (!props.canEditTicket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
||||||
|
|
||||||
form.submit(
|
form.submit(
|
||||||
ProjectTicketController.update({
|
ProjectTicketController.update({
|
||||||
project: props.project.id,
|
project: props.project.id,
|
||||||
@@ -589,22 +733,77 @@ const formatDateTime = (value: string): string => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-4">
|
||||||
<Label for="acceptance_criteria">
|
<div class="grid gap-2">
|
||||||
Acceptance criteria
|
<Label for="acceptance_criteria_checklist">
|
||||||
</Label>
|
Acceptance criteria checklist
|
||||||
<textarea
|
</Label>
|
||||||
id="acceptance_criteria"
|
<textarea
|
||||||
v-model="form.acceptance_criteria"
|
id="acceptance_criteria_checklist"
|
||||||
rows="5"
|
v-model="acceptanceCriteriaChecklist"
|
||||||
:disabled="
|
rows="5"
|
||||||
!canEditTicket || form.processing
|
: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"
|
"
|
||||||
/>
|
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"
|
||||||
<InputError
|
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||||
:message="form.errors.acceptance_criteria"
|
/>
|
||||||
/>
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Use <code>[ ]</code> / <code>[x]</code> for checklist items. Indent with two spaces for nested steps.
|
||||||
|
Text changes are saved with the main ticket update.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
Save checklist text changes before toggling completion directly.
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
:message="acceptanceCriteriaError"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="acceptanceCriteriaToggleError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
||||||
|
class="border-border rounded-xl border p-4 text-sm"
|
||||||
|
>
|
||||||
|
<p class="mb-3 font-medium">
|
||||||
|
Saved checklist
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in acceptanceCriteriaPreviewItems"
|
||||||
|
:key="item.id ?? index"
|
||||||
|
:style="{
|
||||||
|
marginLeft: `${item.indent * 1.25}rem`,
|
||||||
|
}"
|
||||||
|
class="flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="`acceptance-criteria-${item.id ?? index}`"
|
||||||
|
:checked="item.checked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="
|
||||||
|
!canEditTicket ||
|
||||||
|
form.processing ||
|
||||||
|
isPersistingAcceptanceCriteriaToggle ||
|
||||||
|
hasUnsavedAcceptanceCriteriaDraft
|
||||||
|
"
|
||||||
|
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
||||||
|
@change="onAcceptanceCriteriaCheckboxChange(index, $event)"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
:for="`acceptance-criteria-${item.id ?? index}`"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
@@ -15,6 +16,10 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import {
|
||||||
|
buildAcceptanceCriteria,
|
||||||
|
parseChecklistLines,
|
||||||
|
} from '@/lib/acceptance-criteria';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
@@ -56,6 +61,43 @@ const hasTicketMetadata =
|
|||||||
props.ticketPriorities.length > 0 &&
|
props.ticketPriorities.length > 0 &&
|
||||||
props.ticketStatusOptions.length > 0;
|
props.ticketStatusOptions.length > 0;
|
||||||
|
|
||||||
|
const acceptanceCriteriaChecklist = ref('');
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
|
||||||
|
assignee_user_id: '',
|
||||||
|
description: '',
|
||||||
|
story_points: '',
|
||||||
|
ticket_priority_id: String(props.ticketPriorities[0]?.id ?? ''),
|
||||||
|
ticket_status_id: String(
|
||||||
|
props.defaultTicketStatusId ?? props.ticketStatusOptions[0]?.id ?? '',
|
||||||
|
),
|
||||||
|
ticket_type_id: String(props.ticketTypes[0]?.id ?? ''),
|
||||||
|
title: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||||
|
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptanceCriteriaError = computed(() => {
|
||||||
|
return (
|
||||||
|
form.errors.acceptance_criteria ??
|
||||||
|
Object.entries(form.errors).find(([key]) =>
|
||||||
|
key.startsWith('acceptance_criteria.'),
|
||||||
|
)?.[1] ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
|
||||||
|
|
||||||
|
form.submit(ProjectTicketController.store({ project: props.project.id }), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||||
if (
|
if (
|
||||||
ticketStatus.board_column === null ||
|
ticketStatus.board_column === null ||
|
||||||
@@ -116,21 +158,18 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form
|
<form class="grid gap-6" @submit.prevent="submit">
|
||||||
v-bind="ProjectTicketController.store.form(project.id)"
|
|
||||||
class="grid gap-6"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="title">Title</Label>
|
<Label for="title">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
v-model="form.title"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
|
:disabled="form.processing"
|
||||||
placeholder="Implement repository sync"
|
placeholder="Implement repository sync"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.title" />
|
<InputError :message="form.errors.title" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||||
@@ -139,28 +178,55 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Label for="description">Description</Label>
|
<Label for="description">Description</Label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
v-model="form.description"
|
||||||
rows="4"
|
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]"
|
:disabled="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"
|
||||||
placeholder="Describe the expected behavior and relevant context."
|
placeholder="Describe the expected behavior and relevant context."
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.description" />
|
<InputError :message="form.errors.description" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-4">
|
||||||
<Label for="acceptance_criteria">
|
<div class="grid gap-2">
|
||||||
Acceptance criteria
|
<Label for="acceptance_criteria_checklist">
|
||||||
</Label>
|
Acceptance criteria checklist
|
||||||
<textarea
|
</Label>
|
||||||
id="acceptance_criteria"
|
<textarea
|
||||||
name="acceptance_criteria"
|
id="acceptance_criteria_checklist"
|
||||||
rows="4"
|
v-model="acceptanceCriteriaChecklist"
|
||||||
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]"
|
rows="6"
|
||||||
placeholder="List the conditions that must be true before this ticket is done."
|
:disabled="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"
|
||||||
<InputError
|
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||||
:message="errors.acceptance_criteria"
|
/>
|
||||||
/>
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Use <code>[ ]</code> / <code>[x]</code> for checklist items. Indent with two spaces for nested steps.
|
||||||
|
</p>
|
||||||
|
<InputError :message="acceptanceCriteriaError" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
||||||
|
class="border-border rounded-xl border p-4 text-sm"
|
||||||
|
>
|
||||||
|
<p class="mb-3 font-medium">
|
||||||
|
Checklist preview
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in acceptanceCriteriaPreviewItems"
|
||||||
|
:key="index"
|
||||||
|
:style="{
|
||||||
|
marginLeft: `${item.indent * 1.25}rem`,
|
||||||
|
}"
|
||||||
|
class="flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5">{{ item.checked ? '☑' : '☐' }}</span>
|
||||||
|
<span>{{ item.text }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,20 +235,19 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
<Label for="ticket_type_id">Type</Label>
|
<Label for="ticket_type_id">Type</Label>
|
||||||
<select
|
<select
|
||||||
id="ticket_type_id"
|
id="ticket_type_id"
|
||||||
name="ticket_type_id"
|
v-model="form.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]"
|
:disabled="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
|
<option
|
||||||
v-for="ticketType in ticketTypes"
|
v-for="ticketType in ticketTypes"
|
||||||
:key="ticketType.id"
|
:key="ticketType.id"
|
||||||
:value="ticketType.id"
|
:value="String(ticketType.id)"
|
||||||
>
|
>
|
||||||
{{ ticketType.name }}
|
{{ ticketType.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError
|
<InputError :message="form.errors.ticket_type_id" />
|
||||||
:message="errors.ticket_type_id"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
@@ -191,44 +256,38 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
</Label>
|
</Label>
|
||||||
<select
|
<select
|
||||||
id="ticket_priority_id"
|
id="ticket_priority_id"
|
||||||
name="ticket_priority_id"
|
v-model="form.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]"
|
:disabled="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
|
<option
|
||||||
v-for="ticketPriority in ticketPriorities"
|
v-for="ticketPriority in ticketPriorities"
|
||||||
:key="ticketPriority.id"
|
:key="ticketPriority.id"
|
||||||
:value="ticketPriority.id"
|
:value="String(ticketPriority.id)"
|
||||||
>
|
>
|
||||||
{{ ticketPriority.name }}
|
{{ ticketPriority.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError
|
<InputError :message="form.errors.ticket_priority_id" />
|
||||||
:message="errors.ticket_priority_id"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="ticket_status_id">Status</Label>
|
<Label for="ticket_status_id">Status</Label>
|
||||||
<select
|
<select
|
||||||
id="ticket_status_id"
|
id="ticket_status_id"
|
||||||
name="ticket_status_id"
|
v-model="form.ticket_status_id"
|
||||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
:disabled="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
|
<option
|
||||||
v-for="ticketStatus in ticketStatusOptions"
|
v-for="ticketStatus in ticketStatusOptions"
|
||||||
:key="ticketStatus.id"
|
:key="ticketStatus.id"
|
||||||
:value="ticketStatus.id"
|
:value="String(ticketStatus.id)"
|
||||||
:selected="
|
|
||||||
ticketStatus.id ===
|
|
||||||
defaultTicketStatusId
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ ticketStatusLabel(ticketStatus) }}
|
{{ ticketStatusLabel(ticketStatus) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError
|
<InputError :message="form.errors.ticket_status_id" />
|
||||||
:message="errors.ticket_status_id"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
@@ -237,21 +296,20 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
</Label>
|
</Label>
|
||||||
<select
|
<select
|
||||||
id="assignee_user_id"
|
id="assignee_user_id"
|
||||||
name="assignee_user_id"
|
v-model="form.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]"
|
:disabled="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 value="">Unassigned</option>
|
||||||
<option
|
<option
|
||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
:value="user.id"
|
:value="String(user.id)"
|
||||||
>
|
>
|
||||||
{{ user.name }}
|
{{ user.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError
|
<InputError :message="form.errors.assignee_user_id" />
|
||||||
:message="errors.assignee_user_id"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -263,19 +321,18 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="story_points"
|
id="story_points"
|
||||||
name="story_points"
|
v-model="form.story_points"
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="1"
|
||||||
|
:disabled="form.processing"
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError :message="form.errors.story_points" />
|
||||||
:message="errors.story_points"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Button type="submit" :disabled="processing">
|
<Button type="submit" :disabled="form.processing">
|
||||||
Create ticket
|
Create ticket
|
||||||
</Button>
|
</Button>
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
@@ -284,7 +341,7 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type AcceptanceCriteriaItemModel = {
|
||||||
|
id?: number;
|
||||||
|
checked: boolean;
|
||||||
|
indent: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AcceptanceCriteriaModel = AcceptanceCriteriaItemModel[];
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
import type { TicketFieldValueModel } from '@/types/models/ticket-field-value-model';
|
import type { TicketFieldValueModel } from '@/types/models/ticket-field-value-model';
|
||||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
@@ -7,7 +8,7 @@ import type { UserModel } from '@/types/models/user-model';
|
|||||||
|
|
||||||
export type TicketModel = {
|
export type TicketModel = {
|
||||||
id: number;
|
id: number;
|
||||||
acceptance_criteria: string | null;
|
acceptance_criteria: AcceptanceCriteriaModel;
|
||||||
assignee: UserModel | null;
|
assignee: UserModel | null;
|
||||||
assignee_user_id: number | null;
|
assignee_user_id: number | null;
|
||||||
author: UserModel | null;
|
author: UserModel | null;
|
||||||
|
|||||||
@@ -43,7 +43,18 @@ class TicketApiTest extends TestCase
|
|||||||
|
|
||||||
$createResponse = $this->withApplicationKey($user)
|
$createResponse = $this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.tickets.store', $project), [
|
->postJson(route('api.projects.tickets.store', $project), [
|
||||||
'acceptance_criteria' => 'Can sign in',
|
'acceptance_criteria' => [
|
||||||
|
[
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 0,
|
||||||
|
'text' => 'Credentials are validated',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checked' => true,
|
||||||
|
'indent' => 1,
|
||||||
|
'text' => 'Nested validation is supported',
|
||||||
|
],
|
||||||
|
],
|
||||||
'description' => 'Build the API-created ticket',
|
'description' => 'Build the API-created ticket',
|
||||||
'story_points' => 3,
|
'story_points' => 3,
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
@@ -53,6 +64,7 @@ class TicketApiTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.ticket_key', 'CORE-1')
|
->assertJsonPath('data.ticket_key', 'CORE-1')
|
||||||
|
->assertJsonPath('data.acceptance_criteria.1.indent', 1)
|
||||||
->assertJsonPath('meta.message', 'CORE-1 was created.');
|
->assertJsonPath('meta.message', 'CORE-1 was created.');
|
||||||
|
|
||||||
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
|
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
|
||||||
@@ -64,7 +76,13 @@ class TicketApiTest extends TestCase
|
|||||||
|
|
||||||
$this->withApplicationKey($user)
|
$this->withApplicationKey($user)
|
||||||
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
|
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
|
||||||
'acceptance_criteria' => 'Can sign in from the API',
|
'acceptance_criteria' => [
|
||||||
|
[
|
||||||
|
'checked' => true,
|
||||||
|
'indent' => 0,
|
||||||
|
'text' => 'API update preserves checked state',
|
||||||
|
],
|
||||||
|
],
|
||||||
'blocked_comment' => null,
|
'blocked_comment' => null,
|
||||||
'description' => 'Updated through the API',
|
'description' => 'Updated through the API',
|
||||||
'is_blocked' => false,
|
'is_blocked' => false,
|
||||||
@@ -76,6 +94,7 @@ class TicketApiTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.title', 'Updated API ticket')
|
->assertJsonPath('data.title', 'Updated API ticket')
|
||||||
|
->assertJsonPath('data.acceptance_criteria.0.checked', true)
|
||||||
->assertJsonPath('meta.message', 'CORE-1 was updated.');
|
->assertJsonPath('meta.message', 'CORE-1 was updated.');
|
||||||
|
|
||||||
$this->withApplicationKey($user)
|
$this->withApplicationKey($user)
|
||||||
@@ -117,7 +136,7 @@ class TicketApiTest extends TestCase
|
|||||||
|
|
||||||
$this->withApplicationKey($writer)
|
$this->withApplicationKey($writer)
|
||||||
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
|
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
|
||||||
'acceptance_criteria' => $ticket->acceptance_criteria,
|
'acceptance_criteria' => [],
|
||||||
'blocked_comment' => $ticket->blocked_comment,
|
'blocked_comment' => $ticket->blocked_comment,
|
||||||
'description' => $ticket->description,
|
'description' => $ticket->description,
|
||||||
'is_blocked' => $ticket->is_blocked,
|
'is_blocked' => $ticket->is_blocked,
|
||||||
|
|||||||
@@ -110,7 +110,11 @@ class TicketCreatedNotificationDispatchTest extends TestCase
|
|||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->post(route('projects.tickets.store', $project), [
|
->post(route('projects.tickets.store', $project), [
|
||||||
'acceptance_criteria' => 'Ship it.',
|
'acceptance_criteria' => [[
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 0,
|
||||||
|
'text' => 'Ship it.',
|
||||||
|
]],
|
||||||
'assignee_user_id' => null,
|
'assignee_user_id' => null,
|
||||||
'description' => 'Created from notification dispatch test.',
|
'description' => 'Created from notification dispatch test.',
|
||||||
'story_points' => 3,
|
'story_points' => 3,
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ class ProjectBacklogTest extends TestCase
|
|||||||
->post(route('projects.tickets.store', $project), [
|
->post(route('projects.tickets.store', $project), [
|
||||||
'title' => 'New ticket',
|
'title' => 'New ticket',
|
||||||
'description' => 'Description',
|
'description' => 'Description',
|
||||||
'acceptance_criteria' => 'Criteria',
|
'acceptance_criteria' => [[
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 0,
|
||||||
|
'text' => 'Criteria',
|
||||||
|
]],
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'ticket_priority_id' => $priority->id,
|
'ticket_priority_id' => $priority->id,
|
||||||
'ticket_status_id' => $backlogStatus->id,
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
@@ -118,7 +122,11 @@ class ProjectBacklogTest extends TestCase
|
|||||||
$payload = [
|
$payload = [
|
||||||
'title' => 'Same title',
|
'title' => 'Same title',
|
||||||
'description' => 'Description',
|
'description' => 'Description',
|
||||||
'acceptance_criteria' => 'Criteria',
|
'acceptance_criteria' => [[
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 0,
|
||||||
|
'text' => 'Criteria',
|
||||||
|
]],
|
||||||
'assignee_user_id' => $assignee->id,
|
'assignee_user_id' => $assignee->id,
|
||||||
'story_points' => 3,
|
'story_points' => 3,
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
@@ -192,7 +200,11 @@ class ProjectBacklogTest extends TestCase
|
|||||||
->post(route('projects.tickets.store', $project), [
|
->post(route('projects.tickets.store', $project), [
|
||||||
'title' => 'New ticket',
|
'title' => 'New ticket',
|
||||||
'description' => 'Description',
|
'description' => 'Description',
|
||||||
'acceptance_criteria' => 'Criteria',
|
'acceptance_criteria' => [[
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 0,
|
||||||
|
'text' => 'Criteria',
|
||||||
|
]],
|
||||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
'ticket_status_id' => $boardStatus->id,
|
'ticket_status_id' => $boardStatus->id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Feature\Projects;
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
|
use App\Models\AcceptanceCriterion;
|
||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\RepositoryConnection;
|
use App\Models\RepositoryConnection;
|
||||||
@@ -67,6 +68,20 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'ticket_type_id' => $this->nonParentType($project)->id,
|
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||||
'title' => 'Ticket detail test',
|
'title' => 'Ticket detail test',
|
||||||
]);
|
]);
|
||||||
|
$rootCriterion = AcceptanceCriterion::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_acceptance_criterion_id' => null,
|
||||||
|
'position' => 0,
|
||||||
|
'text' => 'User can sign in',
|
||||||
|
'is_checked' => false,
|
||||||
|
]);
|
||||||
|
AcceptanceCriterion::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_acceptance_criterion_id' => $rootCriterion->id,
|
||||||
|
'position' => 0,
|
||||||
|
'text' => 'Nested verification',
|
||||||
|
'is_checked' => true,
|
||||||
|
]);
|
||||||
$linkedTicket = Ticket::factory()->create([
|
$linkedTicket = Ticket::factory()->create([
|
||||||
'author_user_id' => $author->id,
|
'author_user_id' => $author->id,
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
@@ -107,6 +122,12 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
->where('project.show_author', false)
|
->where('project.show_author', false)
|
||||||
->where('ticket.ticket_key', 'CORE-1')
|
->where('ticket.ticket_key', 'CORE-1')
|
||||||
->where('ticket.story_points', 5)
|
->where('ticket.story_points', 5)
|
||||||
|
->where('ticket.acceptance_criteria.0.text', 'User can sign in')
|
||||||
|
->where('ticket.acceptance_criteria.0.checked', false)
|
||||||
|
->where('ticket.acceptance_criteria.0.indent', 0)
|
||||||
|
->where('ticket.acceptance_criteria.1.text', 'Nested verification')
|
||||||
|
->where('ticket.acceptance_criteria.1.checked', true)
|
||||||
|
->where('ticket.acceptance_criteria.1.indent', 1)
|
||||||
->where('ticket.assignee.name', 'Assigned User')
|
->where('ticket.assignee.name', 'Assigned User')
|
||||||
->where('ticket.author', null)
|
->where('ticket.author', null)
|
||||||
->where('ticket.author_user_id', null)
|
->where('ticket.author_user_id', null)
|
||||||
@@ -339,7 +360,11 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
|
|
||||||
$this->actingAs($editor)
|
$this->actingAs($editor)
|
||||||
->post(route('projects.tickets.store', $project), [
|
->post(route('projects.tickets.store', $project), [
|
||||||
'acceptance_criteria' => 'The ticket exists.',
|
'acceptance_criteria' => [[
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 0,
|
||||||
|
'text' => 'The ticket exists.',
|
||||||
|
]],
|
||||||
'assignee_user_id' => $assignee->id,
|
'assignee_user_id' => $assignee->id,
|
||||||
'description' => 'Created from the dedicated page.',
|
'description' => 'Created from the dedicated page.',
|
||||||
'story_points' => 5,
|
'story_points' => 5,
|
||||||
@@ -437,7 +462,6 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$ticket = Ticket::factory()->create([
|
$ticket = Ticket::factory()->create([
|
||||||
'acceptance_criteria' => 'Old acceptance criteria',
|
|
||||||
'author_user_id' => $author->id,
|
'author_user_id' => $author->id,
|
||||||
'description' => 'Old description',
|
'description' => 'Old description',
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
@@ -463,9 +487,22 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
|
|
||||||
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||||
|
|
||||||
|
$updatedAcceptanceCriteria = [
|
||||||
|
[
|
||||||
|
'text' => 'Shows success banner',
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'text' => 'Includes nested confirmations',
|
||||||
|
'checked' => true,
|
||||||
|
'indent' => 1,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
$this->actingAs($editor)
|
$this->actingAs($editor)
|
||||||
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
'acceptance_criteria' => 'Updated acceptance criteria',
|
'acceptance_criteria' => $updatedAcceptanceCriteria,
|
||||||
'assignee_user_id' => $assignee->id,
|
'assignee_user_id' => $assignee->id,
|
||||||
'blocked_comment' => $ticket->blocked_comment,
|
'blocked_comment' => $ticket->blocked_comment,
|
||||||
'description' => 'Updated description',
|
'description' => 'Updated description',
|
||||||
@@ -493,12 +530,37 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
'id' => $ticket->id,
|
'id' => $ticket->id,
|
||||||
'title' => 'Updated title',
|
'title' => 'Updated title',
|
||||||
'description' => 'Updated description',
|
'description' => 'Updated description',
|
||||||
'acceptance_criteria' => 'Updated acceptance criteria',
|
|
||||||
'assignee_user_id' => $assignee->id,
|
'assignee_user_id' => $assignee->id,
|
||||||
'story_points' => 8,
|
'story_points' => 8,
|
||||||
'ticket_type_id' => $this->parentType($project)->id,
|
'ticket_type_id' => $this->parentType($project)->id,
|
||||||
'ticket_priority_id' => $highPriority->id,
|
'ticket_priority_id' => $highPriority->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$ticket->refresh()->load('acceptanceCriteria');
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$updatedAcceptanceCriteria,
|
||||||
|
\App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria),
|
||||||
|
);
|
||||||
|
$this->assertDatabaseHas('acceptance_criteria', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_acceptance_criterion_id' => null,
|
||||||
|
'position' => 0,
|
||||||
|
'text' => 'Shows success banner',
|
||||||
|
'is_checked' => false,
|
||||||
|
]);
|
||||||
|
$rootCriterionId = \App\Models\AcceptanceCriterion::query()
|
||||||
|
->where('ticket_id', $ticket->id)
|
||||||
|
->where('text', 'Shows success banner')
|
||||||
|
->value('id');
|
||||||
|
$this->assertNotNull($rootCriterionId);
|
||||||
|
$this->assertDatabaseHas('acceptance_criteria', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_acceptance_criterion_id' => $rootCriterionId,
|
||||||
|
'position' => 0,
|
||||||
|
'text' => 'Includes nested confirmations',
|
||||||
|
'is_checked' => true,
|
||||||
|
]);
|
||||||
$this->assertDatabaseHas('ticket_field_values', [
|
$this->assertDatabaseHas('ticket_field_values', [
|
||||||
'ticket_id' => $ticket->id,
|
'ticket_id' => $ticket->id,
|
||||||
'ticket_field_definition_id' => $reviewerField->id,
|
'ticket_field_definition_id' => $reviewerField->id,
|
||||||
@@ -517,6 +579,88 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_ticket_update_reuses_acceptance_criteria_ids_when_provided(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = $project->ticketPriorities()->firstOrFail();
|
||||||
|
$ticketType = $this->nonParentType($project);
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $ticketType->id,
|
||||||
|
'title' => 'Acceptance criteria update test',
|
||||||
|
]);
|
||||||
|
$rootCriterion = AcceptanceCriterion::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_acceptance_criterion_id' => null,
|
||||||
|
'position' => 0,
|
||||||
|
'text' => 'Original root item',
|
||||||
|
'is_checked' => false,
|
||||||
|
]);
|
||||||
|
$childCriterion = AcceptanceCriterion::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_acceptance_criterion_id' => $rootCriterion->id,
|
||||||
|
'position' => 0,
|
||||||
|
'text' => 'Original child item',
|
||||||
|
'is_checked' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($editor)
|
||||||
|
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
'acceptance_criteria' => [
|
||||||
|
[
|
||||||
|
'id' => $rootCriterion->id,
|
||||||
|
'text' => 'Updated root item',
|
||||||
|
'checked' => true,
|
||||||
|
'indent' => 0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => $childCriterion->id,
|
||||||
|
'text' => 'Updated child item',
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'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' => $priority->id,
|
||||||
|
'ticket_type_id' => $ticketType->id,
|
||||||
|
'title' => $ticket->title,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('acceptance_criteria', [
|
||||||
|
'id' => $rootCriterion->id,
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_acceptance_criterion_id' => null,
|
||||||
|
'position' => 0,
|
||||||
|
'text' => 'Updated root item',
|
||||||
|
'is_checked' => true,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('acceptance_criteria', [
|
||||||
|
'id' => $childCriterion->id,
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_acceptance_criterion_id' => $rootCriterion->id,
|
||||||
|
'position' => 0,
|
||||||
|
'text' => 'Updated child item',
|
||||||
|
'is_checked' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure non-parent tickets cannot be assigned as a parent target.
|
* Ensure non-parent tickets cannot be assigned as a parent target.
|
||||||
*/
|
*/
|
||||||
@@ -917,7 +1061,9 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
private function updatePayload(Ticket $ticket, array $overrides = []): array
|
private function updatePayload(Ticket $ticket, array $overrides = []): array
|
||||||
{
|
{
|
||||||
return array_merge([
|
return array_merge([
|
||||||
'acceptance_criteria' => $ticket->acceptance_criteria,
|
'acceptance_criteria' => \App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper::fromModels(
|
||||||
|
$ticket->acceptanceCriteria()->get(),
|
||||||
|
),
|
||||||
'assignee_user_id' => $ticket->assignee_user_id,
|
'assignee_user_id' => $ticket->assignee_user_id,
|
||||||
'author_user_id' => $ticket->author_user_id,
|
'author_user_id' => $ticket->author_user_id,
|
||||||
'blocked_comment' => $ticket->blocked_comment,
|
'blocked_comment' => $ticket->blocked_comment,
|
||||||
|
|||||||
Reference in New Issue
Block a user