Merge origin/main into koda/recreate-68-assignee-changed-notification
linter / quality (pull_request) Successful in 1m32s
tests / ci (8.4) (pull_request) Successful in 1m41s

This commit is contained in:
2026-05-20 09:35:43 +00:00
25 changed files with 767 additions and 565 deletions
@@ -190,6 +190,10 @@ class MatchNotificationFilters
$fromStatusLabel = property_exists($payload, 'fromStatusLabel') ? $payload->fromStatusLabel : null;
$toStatusId = property_exists($payload, 'toStatusId') ? $payload->toStatusId : null;
$toStatusLabel = property_exists($payload, 'toStatusLabel') ? $payload->toStatusLabel : null;
$fromAssigneeId = property_exists($payload, 'fromAssigneeId') ? $payload->fromAssigneeId : null;
$fromAssigneeName = property_exists($payload, 'fromAssigneeName') ? $payload->fromAssigneeName : null;
$toAssigneeId = property_exists($payload, 'toAssigneeId') ? $payload->toAssigneeId : null;
$toAssigneeName = property_exists($payload, 'toAssigneeName') ? $payload->toAssigneeName : null;
return [
'project' => [
@@ -209,12 +213,12 @@ class MatchNotificationFilters
],
'assignee' => [
'from' => [
'id' => $payload->fromAssigneeId,
'name' => $payload->fromAssigneeName,
'id' => $fromAssigneeId,
'name' => $fromAssigneeName,
],
'to' => [
'id' => $payload->toAssigneeId,
'name' => $payload->toAssigneeName,
'id' => $toAssigneeId,
'name' => $toAssigneeName,
],
],
'status' => [
+9 -12
View File
@@ -4,7 +4,7 @@ namespace App\Actions\Tickets;
use App\Actions\Notifications\BuildTicketCreatedPayload;
use App\Actions\Notifications\TicketCreated;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Enums\TicketWorklogEntryType;
use App\Models\Project;
use App\Models\Ticket;
@@ -23,14 +23,11 @@ class CreateProjectTicket
/**
* @param array{
* acceptance_criteria: array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* },
* acceptance_criteria: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>,
* assignee_user_id?: int|null,
* description: string|null,
* story_points?: int|null,
@@ -43,7 +40,7 @@ class CreateProjectTicket
public function handle(Project $project, array $attributes, ?User $actor = null): Ticket
{
return DB::transaction(function () use ($actor, $attributes, $project): Ticket {
$acceptanceCriteria = TicketAcceptanceCriteriaData::normalize(
$acceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
Arr::get($attributes, 'acceptance_criteria'),
);
$nextSequenceNumber = (int) Ticket::query()
@@ -54,8 +51,6 @@ class CreateProjectTicket
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$ticket = Ticket::query()->create([
'acceptance_criteria' => TicketAcceptanceCriteriaData::toLegacyText($acceptanceCriteria),
'acceptance_criteria_data' => TicketAcceptanceCriteriaData::forStorage($acceptanceCriteria),
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
'author_user_id' => $actor?->id,
'description' => Arr::get($attributes, 'description'),
@@ -69,6 +64,8 @@ class CreateProjectTicket
'story_points' => Arr::get($attributes, 'story_points'),
]);
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
$occurredAt = CarbonImmutable::now();
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);
}
}
}
@@ -1,316 +0,0 @@
<?php
namespace App\Actions\Tickets\Support;
use Illuminate\Support\Arr;
class TicketAcceptanceCriteriaData
{
public const MAX_INDENT_LEVEL = 8;
/**
* @return array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
public static function fromStored(mixed $storedData, ?string $legacyText): array
{
$normalizedStoredData = is_array($storedData)
? self::normalize($storedData)
: self::empty();
if (self::hasContent($normalizedStoredData)) {
return $normalizedStoredData;
}
return self::normalize($legacyText);
}
/**
* @return array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
public static function normalize(mixed $value): array
{
if (is_array($value)) {
return self::normalizeFromArray($value);
}
if (is_string($value) || $value === null) {
return self::normalizeFromString($value);
}
return self::empty();
}
/**
* @param array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* } $acceptanceCriteria
*/
public static function hasContent(array $acceptanceCriteria): bool
{
return $acceptanceCriteria['summary'] !== null
|| $acceptanceCriteria['items'] !== [];
}
/**
* @param array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* } $acceptanceCriteria
* @return array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }|null
*/
public static function forStorage(array $acceptanceCriteria): ?array
{
$normalized = self::normalize($acceptanceCriteria);
return self::hasContent($normalized)
? $normalized
: null;
}
/**
* @param array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* } $acceptanceCriteria
*/
public static function toLegacyText(array $acceptanceCriteria): ?string
{
$normalized = self::normalize($acceptanceCriteria);
$lines = [];
if ($normalized['summary'] !== null) {
$lines[] = $normalized['summary'];
}
if ($normalized['items'] !== []) {
if ($normalized['summary'] !== null) {
$lines[] = '';
}
foreach ($normalized['items'] as $item) {
$lines[] = sprintf(
'%s- [%s] %s',
str_repeat(' ', $item['indent']),
$item['checked'] ? 'x' : ' ',
$item['text'],
);
}
}
$legacyText = trim(implode("\n", $lines));
return $legacyText === '' ? null : $legacyText;
}
/**
* @return array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
private static function empty(): array
{
return [
'summary' => null,
'items' => [],
];
}
/**
* @param array<string, mixed> $value
* @return array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
private static function normalizeFromArray(array $value): array
{
$summary = trim((string) Arr::get($value, 'summary', ''));
$rawItems = Arr::get($value, 'items', []);
if (! is_array($rawItems)) {
$rawItems = [];
}
$items = [];
foreach ($rawItems as $rawItem) {
if (! is_array($rawItem)) {
continue;
}
$text = trim((string) Arr::get($rawItem, 'text', ''));
if ($text === '') {
continue;
}
$rawChecked = Arr::get($rawItem, 'checked', false);
$rawIndent = Arr::get($rawItem, 'indent', 0);
$items[] = [
'checked' => filter_var($rawChecked, FILTER_VALIDATE_BOOLEAN),
'indent' => self::clampIndent((int) $rawIndent),
'text' => $text,
];
}
return [
'summary' => $summary === '' ? null : $summary,
'items' => $items,
];
}
/**
* @return array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }
*/
private static function normalizeFromString(?string $value): array
{
$text = trim((string) $value);
if ($text === '') {
return self::empty();
}
$lines = preg_split('/\R/u', $text);
if (! is_array($lines)) {
return [
'summary' => $text,
'items' => [],
];
}
$summary = null;
$items = [];
foreach ($lines as $line) {
$trimmedLine = trim($line);
if ($trimmedLine === '') {
continue;
}
if ($summary === null) {
$summary = $trimmedLine;
continue;
}
$parsedItem = self::parseChecklistItem($line);
if ($parsedItem === null) {
continue;
}
$items[] = $parsedItem;
}
return [
'summary' => $summary,
'items' => $items,
];
}
/**
* @return array{checked: bool, indent: int, text: string}|null
*/
private static function parseChecklistItem(string $line): ?array
{
$line = rtrim(str_replace("\t", ' ', $line));
if ($line === '') {
return null;
}
if (! preg_match('/^(?<indent>\s*)(?<content>.+)$/u', $line, $matches)) {
return null;
}
$content = trim((string) Arr::get($matches, 'content', ''));
if ($content === '') {
return null;
}
$checked = false;
if (preg_match('/^(?:[-*+]\s+)?\[(?<state>[xX ])\]\s*(?<text>.+)$/u', $content, $checkboxMatches)) {
$checked = strtolower((string) Arr::get($checkboxMatches, 'state', '')) === 'x';
$content = trim((string) Arr::get($checkboxMatches, 'text', ''));
} elseif (preg_match('/^[-*+]\s+(?<text>.+)$/u', $content, $bulletMatches)) {
$content = trim((string) Arr::get($bulletMatches, 'text', ''));
}
if ($content === '') {
return null;
}
return [
'checked' => $checked,
'indent' => self::indentLevel((string) Arr::get($matches, 'indent', '')),
'text' => $content,
];
}
private static function indentLevel(string $leadingWhitespace): int
{
$normalizedWhitespace = str_replace("\t", ' ', $leadingWhitespace);
return self::clampIndent(intdiv(strlen($normalizedWhitespace), 2));
}
private static function clampIndent(int $indent): int
{
return max(0, min(self::MAX_INDENT_LEVEL, $indent));
}
}
+23 -49
View File
@@ -4,7 +4,7 @@ namespace App\Actions\Tickets;
use App\Actions\Notifications\BuildTicketAssigneeChangedPayload;
use App\Actions\Notifications\TicketAssigneeChanged;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Enums\TicketWorklogEntryType;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
@@ -25,14 +25,11 @@ class UpdateProjectTicket
/**
* @param array{
* acceptance_criteria: array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* },
* acceptance_criteria: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>,
* assignee_user_id?: int|null,
* author_user_id?: int|null,
* blocked_comment: string|null,
@@ -56,9 +53,12 @@ class UpdateProjectTicket
array $validated,
): bool {
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
Arr::get($validated, 'acceptance_criteria'),
);
$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']) {
$ticketAttributes['blocked_comment'] = null;
}
@@ -66,6 +66,7 @@ class UpdateProjectTicket
$ticket->loadMissing([
'assignee',
'author',
'acceptanceCriteria',
'fieldValues.ticketFieldDefinition',
'fieldValues.userValue',
'parentTicket',
@@ -90,14 +91,7 @@ class UpdateProjectTicket
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
$existingFieldValues = $ticket->fieldValues
->keyBy('ticket_field_definition_id');
$currentAcceptanceCriteria = TicketAcceptanceCriteriaData::fromStored(
$ticket->acceptance_criteria_data,
$ticket->acceptance_criteria,
);
$nextAcceptanceCriteria = TicketAcceptanceCriteriaData::fromStored(
$ticketAttributes['acceptance_criteria_data'],
$ticketAttributes['acceptance_criteria'],
);
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$changes = [];
$summaryLabels = [];
@@ -214,6 +208,10 @@ class UpdateProjectTicket
$ticket->save();
}
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
}
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
$ticket,
$fieldDefinitions,
@@ -262,14 +260,11 @@ class UpdateProjectTicket
/**
* @param array{
* acceptance_criteria: array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* },
* acceptance_criteria: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>,
* assignee_user_id?: int|null,
* author_user_id?: int|null,
* blocked_comment: string|null,
@@ -282,15 +277,6 @@ class UpdateProjectTicket
* title: string
* } $validated
* @return array{
* acceptance_criteria: string|null,
* acceptance_criteria_data: array{
* summary: string|null,
* items: array<int, array{
* checked: bool,
* indent: int,
* text: string
* }>
* }|null,
* assignee_user_id: int|null,
* author_user_id: int|null,
* blocked_comment: string|null,
@@ -305,13 +291,7 @@ class UpdateProjectTicket
*/
private function buildTicketAttributes(Ticket $ticket, array $validated): array
{
$acceptanceCriteria = TicketAcceptanceCriteriaData::normalize(
Arr::get($validated, 'acceptance_criteria'),
);
return [
'acceptance_criteria' => TicketAcceptanceCriteriaData::toLegacyText($acceptanceCriteria),
'acceptance_criteria_data' => TicketAcceptanceCriteriaData::forStorage($acceptanceCriteria),
'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
? Arr::get($validated, 'assignee_user_id')
: $ticket->assignee_user_id,
@@ -334,14 +314,8 @@ class UpdateProjectTicket
/**
* @param array<string, mixed> $changes
* @param array<int, string> $summaryLabels
* @param array{
* summary: string|null,
* items: array<int, array{checked: bool, indent: int, text: string}>
* } $currentValue
* @param array{
* summary: string|null,
* items: array<int, array{checked: bool, indent: int, text: string}>
* } $newValue
* @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,
@@ -2,7 +2,7 @@
namespace App\Http\Requests;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
@@ -23,9 +23,15 @@ class StoreProjectTicketRequest extends FormRequest
*/
protected function prepareForValidation(): void
{
$acceptanceCriteria = $this->input('acceptance_criteria');
if ($acceptanceCriteria !== null && ! is_array($acceptanceCriteria)) {
return;
}
$this->merge([
'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize(
$this->input('acceptance_criteria'),
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::normalize(
$acceptanceCriteria,
),
]);
}
@@ -42,12 +48,11 @@ class StoreProjectTicketRequest extends FormRequest
return [
'acceptance_criteria' => ['nullable', 'array'],
'acceptance_criteria.summary' => ['nullable', 'string', 'max:255'],
'acceptance_criteria.items' => ['array'],
'acceptance_criteria.items.*' => ['array'],
'acceptance_criteria.items.*.text' => ['required', 'string'],
'acceptance_criteria.items.*.checked' => ['required', 'boolean'],
'acceptance_criteria.items.*.indent' => ['required', 'integer', 'min:0', 'max:'.TicketAcceptanceCriteriaData::MAX_INDENT_LEVEL],
'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')],
'description' => ['nullable', 'string'],
'ticket_priority_id' => [
@@ -3,7 +3,7 @@
namespace App\Http\Requests;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Enums\Permission;
use App\Models\Project;
use App\Models\Ticket;
@@ -29,9 +29,15 @@ class UpdateProjectTicketRequest extends FormRequest
*/
protected function prepareForValidation(): void
{
$acceptanceCriteria = $this->input('acceptance_criteria');
if ($acceptanceCriteria !== null && ! is_array($acceptanceCriteria)) {
return;
}
$this->merge([
'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize(
$this->input('acceptance_criteria'),
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::normalize(
$acceptanceCriteria,
),
]);
}
@@ -48,12 +54,11 @@ class UpdateProjectTicketRequest extends FormRequest
return [
'acceptance_criteria' => ['nullable', 'array'],
'acceptance_criteria.summary' => ['nullable', 'string', 'max:255'],
'acceptance_criteria.items' => ['array'],
'acceptance_criteria.items.*' => ['array'],
'acceptance_criteria.items.*.text' => ['required', 'string'],
'acceptance_criteria.items.*.checked' => ['required', 'boolean'],
'acceptance_criteria.items.*.indent' => ['required', 'integer', 'min:0', 'max:'.TicketAcceptanceCriteriaData::MAX_INDENT_LEVEL],
'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')],
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'blocked_comment' => ['nullable', 'string'],
+5 -5
View File
@@ -2,7 +2,7 @@
namespace App\Http\Resources;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,11 +17,11 @@ class TicketResource extends JsonResource
{
return [
'id' => $this->id,
'acceptance_criteria' => TicketAcceptanceCriteriaData::fromStored(
$this->acceptance_criteria_data,
$this->acceptance_criteria,
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds(
$this->relationLoaded('acceptanceCriteria')
? $this->acceptanceCriteria
: $this->acceptanceCriteria()->get(),
),
'acceptance_criteria_text' => $this->acceptance_criteria,
'assignee' => $this->whenLoaded(
'assignee',
fn (): ?array => $this->assignee === null
+6 -8
View File
@@ -3,7 +3,7 @@
namespace App\Mcp\Support;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Enums\Permission;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\TicketResource;
@@ -208,10 +208,7 @@ class KanbanGitMcp
/**
* @return array{
* acceptance_criteria: array{
* summary: string|null,
* items: array<int, array{checked: bool, indent: int, text: string}>
* },
* acceptance_criteria: array<int, array{checked: bool, indent: int, text: string}>,
* assignee_user_id: int|null,
* author_user_id: int|null,
* blocked_comment: string|null,
@@ -227,9 +224,10 @@ class KanbanGitMcp
public function updatePayload(Ticket $ticket): array
{
return [
'acceptance_criteria' => TicketAcceptanceCriteriaData::fromStored(
$ticket->acceptance_criteria_data,
$ticket->acceptance_criteria,
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModels(
$ticket->relationLoaded('acceptanceCriteria')
? $ticket->acceptanceCriteria
: $ticket->acceptanceCriteria()->get(),
),
'assignee_user_id' => $ticket->assignee_user_id,
'author_user_id' => $ticket->author_user_id,
+45
View File
@@ -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);
}
}
+7 -3
View File
@@ -10,8 +10,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'acceptance_criteria',
'acceptance_criteria_data',
'assignee_user_id',
'author_user_id',
'blocked_comment',
@@ -38,11 +36,17 @@ class Ticket extends Model
protected function casts(): array
{
return [
'acceptance_criteria_data' => 'array',
'is_blocked' => 'boolean',
];
}
public function acceptanceCriteria(): HasMany
{
return $this->hasMany(AcceptanceCriterion::class)
->orderBy('position')
->orderBy('id');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_user_id');
-1
View File
@@ -22,7 +22,6 @@ class TicketFactory extends Factory
public function definition(): array
{
return [
'acceptance_criteria' => fake()->paragraph(),
'assignee_user_id' => null,
'author_user_id' => User::factory(),
'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');
}
};
@@ -11,8 +11,12 @@ return new class extends Migration
*/
public function up(): void
{
if (! Schema::hasColumn('tickets', 'acceptance_criteria')) {
return;
}
Schema::table('tickets', function (Blueprint $table): void {
$table->json('acceptance_criteria_data')->nullable();
$table->dropColumn('acceptance_criteria');
});
}
@@ -21,8 +25,12 @@ return new class extends Migration
*/
public function down(): void
{
if (Schema::hasColumn('tickets', 'acceptance_criteria')) {
return;
}
Schema::table('tickets', function (Blueprint $table): void {
$table->dropColumn('acceptance_criteria_data');
$table->text('acceptance_criteria')->nullable()->after('description');
});
}
};
+10 -2
View File
@@ -42,7 +42,11 @@ class TicketSeeder extends Seeder
->firstOrFail();
$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,
'description' => 'Seeded backlog item for demonstrating the first ticket workflow.',
'story_points' => 3,
@@ -53,7 +57,11 @@ class TicketSeeder extends Seeder
], $author);
$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,
'description' => 'Second seeded backlog item using the same title on purpose.',
'story_points' => 5,
+11 -25
View File
@@ -7,25 +7,19 @@ const MAX_INDENT_LEVEL = 8;
export type AcceptanceCriteriaEditorState = {
checklist: string;
summary: string;
};
export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => ({
summary: null,
items: [],
});
export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => [];
export const normalizeAcceptanceCriteria = (
value: AcceptanceCriteriaModel | null | undefined,
): AcceptanceCriteriaModel => {
if (value === null || value === undefined) {
return emptyAcceptanceCriteria();
}
const summary = value.summary?.trim() ?? null;
const items = Array.isArray(value.items)
? value.items
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(),
@@ -33,10 +27,7 @@ export const normalizeAcceptanceCriteria = (
.filter((item) => item.text !== '')
: [];
return {
summary: summary === '' ? null : summary,
items,
};
return items;
};
export const parseChecklistLines = (
@@ -69,15 +60,9 @@ export const formatChecklistLines = (
};
export const buildAcceptanceCriteria = (
summaryInput: string,
checklistInput: string,
): AcceptanceCriteriaModel => {
const summary = summaryInput.trim();
return normalizeAcceptanceCriteria({
summary: summary === '' ? null : summary,
items: parseChecklistLines(checklistInput),
});
return normalizeAcceptanceCriteria(parseChecklistLines(checklistInput));
};
export const acceptanceCriteriaToEditorState = (
@@ -86,8 +71,7 @@ export const acceptanceCriteriaToEditorState = (
const normalized = normalizeAcceptanceCriteria(value);
return {
checklist: formatChecklistLines(normalized.items),
summary: normalized.summary ?? '',
checklist: formatChecklistLines(normalized),
};
};
@@ -123,6 +107,8 @@ const parseChecklistLine = (
if (bulletMatch?.groups !== undefined) {
content = bulletMatch.groups.text.trim();
} else {
return null;
}
}
+153 -31
View File
@@ -5,6 +5,8 @@ import { computed, ref, watch } from 'vue';
import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria,
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
@@ -111,14 +113,15 @@ const breadcrumbs: BreadcrumbItem[] = [
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const acceptanceCriteriaSummary = ref(acceptanceCriteriaEditorState.summary);
const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist);
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
),
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '',
@@ -145,16 +148,20 @@ const form = useForm({
});
const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value),
savedAcceptanceCriteria.value,
);
const hasUnsavedAcceptanceCriteriaDraft = computed(
() =>
acceptanceCriteriaChecklist.value !==
formatChecklistLines(savedAcceptanceCriteria.value),
);
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
form.errors['acceptance_criteria.summary'] ??
form.errors['acceptance_criteria.items'] ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('acceptance_criteria.items.'),
key.startsWith('acceptance_criteria.'),
)?.[1] ??
''
);
@@ -343,15 +350,117 @@ 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 => {
if (!props.canEditTicket) {
return;
}
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
);
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
form.submit(
ProjectTicketController.update({
@@ -625,20 +734,6 @@ const formatDateTime = (value: string): string => {
</div>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="acceptance_criteria_summary">
Acceptance criteria summary
</Label>
<Input
id="acceptance_criteria_summary"
v-model="acceptanceCriteriaSummary"
:disabled="
!canEditTicket || form.processing
"
placeholder="What should be true when this ticket is done?"
/>
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria_checklist">
Acceptance criteria checklist
@@ -655,10 +750,20 @@ const formatDateTime = (value: string): string => {
/>
<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
@@ -666,19 +771,36 @@ const formatDateTime = (value: string): string => {
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
Checklist preview
Saved checklist
</p>
<ul class="space-y-2">
<li
v-for="(item, index) in acceptanceCriteriaPreviewItems"
:key="index"
:key="item.id ?? 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>
<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>
+3 -24
View File
@@ -61,14 +61,10 @@ const hasTicketMetadata =
props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0;
const acceptanceCriteriaSummary = ref('');
const acceptanceCriteriaChecklist = ref('');
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
),
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
assignee_user_id: '',
description: '',
story_points: '',
@@ -87,20 +83,15 @@ const acceptanceCriteriaPreviewItems = computed(() =>
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
form.errors['acceptance_criteria.summary'] ??
form.errors['acceptance_criteria.items'] ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('acceptance_criteria.items.'),
key.startsWith('acceptance_criteria.'),
)?.[1] ??
''
);
});
const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
);
form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
form.submit(ProjectTicketController.store({ project: props.project.id }), {
preserveScroll: true,
@@ -197,18 +188,6 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
</div>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="acceptance_criteria_summary">
Acceptance criteria summary
</Label>
<Input
id="acceptance_criteria_summary"
v-model="acceptanceCriteriaSummary"
:disabled="form.processing"
placeholder="What should be true when this ticket is done?"
/>
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria_checklist">
Acceptance criteria checklist
@@ -1,10 +1,8 @@
export type AcceptanceCriteriaItemModel = {
id?: number;
checked: boolean;
indent: number;
text: string;
};
export type AcceptanceCriteriaModel = {
summary: string | null;
items: AcceptanceCriteriaItemModel[];
};
export type AcceptanceCriteriaModel = AcceptanceCriteriaItemModel[];
@@ -9,7 +9,6 @@ import type { UserModel } from '@/types/models/user-model';
export type TicketModel = {
id: number;
acceptance_criteria: AcceptanceCriteriaModel;
acceptance_criteria_text: string | null;
assignee: UserModel | null;
assignee_user_id: number | null;
author: UserModel | null;
+16 -23
View File
@@ -44,18 +44,15 @@ class TicketApiTest extends TestCase
$createResponse = $this->withApplicationKey($user)
->postJson(route('api.projects.tickets.store', $project), [
'acceptance_criteria' => [
'summary' => 'Can sign in',
'items' => [
[
'checked' => false,
'indent' => 0,
'text' => 'Credentials are validated',
],
[
'checked' => true,
'indent' => 1,
'text' => 'Nested validation is supported',
],
[
'checked' => false,
'indent' => 0,
'text' => 'Credentials are validated',
],
[
'checked' => true,
'indent' => 1,
'text' => 'Nested validation is supported',
],
],
'description' => 'Build the API-created ticket',
@@ -67,8 +64,7 @@ class TicketApiTest extends TestCase
])
->assertCreated()
->assertJsonPath('data.ticket_key', 'CORE-1')
->assertJsonPath('data.acceptance_criteria.summary', 'Can sign in')
->assertJsonPath('data.acceptance_criteria.items.1.indent', 1)
->assertJsonPath('data.acceptance_criteria.1.indent', 1)
->assertJsonPath('meta.message', 'CORE-1 was created.');
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
@@ -81,13 +77,10 @@ class TicketApiTest extends TestCase
$this->withApplicationKey($user)
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
'acceptance_criteria' => [
'summary' => 'Can sign in from the API',
'items' => [
[
'checked' => true,
'indent' => 0,
'text' => 'API update preserves checked state',
],
[
'checked' => true,
'indent' => 0,
'text' => 'API update preserves checked state',
],
],
'blocked_comment' => null,
@@ -101,7 +94,7 @@ class TicketApiTest extends TestCase
])
->assertOk()
->assertJsonPath('data.title', 'Updated API ticket')
->assertJsonPath('data.acceptance_criteria.items.0.checked', true)
->assertJsonPath('data.acceptance_criteria.0.checked', true)
->assertJsonPath('meta.message', 'CORE-1 was updated.');
$this->withApplicationKey($user)
@@ -143,7 +136,7 @@ class TicketApiTest extends TestCase
$this->withApplicationKey($writer)
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
'acceptance_criteria' => $ticket->acceptance_criteria,
'acceptance_criteria' => [],
'blocked_comment' => $ticket->blocked_comment,
'description' => $ticket->description,
'is_blocked' => $ticket->is_blocked,
@@ -4,7 +4,7 @@ namespace Tests\Feature\Notifications;
use App\Actions\Notifications\DeliverNotificationAction;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Enums\NotificationDeliveryStatus;
use App\Enums\NotificationEventType;
use App\Models\NotificationAction;
@@ -321,9 +321,8 @@ class TicketAssigneeChangedNotificationDispatchTest extends TestCase
private function updatePayload(Ticket $ticket, array $overrides = []): array
{
return [
'acceptance_criteria' => TicketAcceptanceCriteriaData::fromStored(
$ticket->acceptance_criteria_data,
$ticket->acceptance_criteria,
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModels(
$ticket->acceptanceCriteria()->get(),
),
'assignee_user_id' => $ticket->assignee_user_id,
'author_user_id' => $ticket->author_user_id,
@@ -110,7 +110,11 @@ class TicketCreatedNotificationDispatchTest extends TestCase
$response = $this->actingAs($user)
->post(route('projects.tickets.store', $project), [
'acceptance_criteria' => 'Ship it.',
'acceptance_criteria' => [[
'checked' => false,
'indent' => 0,
'text' => 'Ship it.',
]],
'assignee_user_id' => null,
'description' => 'Created from notification dispatch test.',
'story_points' => 3,
+15 -3
View File
@@ -84,7 +84,11 @@ class ProjectBacklogTest extends TestCase
->post(route('projects.tickets.store', $project), [
'title' => 'New ticket',
'description' => 'Description',
'acceptance_criteria' => 'Criteria',
'acceptance_criteria' => [[
'checked' => false,
'indent' => 0,
'text' => 'Criteria',
]],
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
@@ -118,7 +122,11 @@ class ProjectBacklogTest extends TestCase
$payload = [
'title' => 'Same title',
'description' => 'Description',
'acceptance_criteria' => 'Criteria',
'acceptance_criteria' => [[
'checked' => false,
'indent' => 0,
'text' => 'Criteria',
]],
'assignee_user_id' => $assignee->id,
'story_points' => 3,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
@@ -192,7 +200,11 @@ class ProjectBacklogTest extends TestCase
->post(route('projects.tickets.store', $project), [
'title' => 'New ticket',
'description' => 'Description',
'acceptance_criteria' => 'Criteria',
'acceptance_criteria' => [[
'checked' => false,
'indent' => 0,
'text' => 'Criteria',
]],
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'ticket_status_id' => $boardStatus->id,
@@ -3,6 +3,7 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\AcceptanceCriterion;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\RepositoryConnection;
@@ -56,10 +57,7 @@ class ProjectTicketControllerTest extends TestCase
'position' => 1,
]);
$legacyAcceptanceCriteria = "Review checklist\n- [ ] User can sign in\n - [x] Nested verification";
$ticket = Ticket::factory()->create([
'acceptance_criteria' => $legacyAcceptanceCriteria,
'assignee_user_id' => $assignee->id,
'author_user_id' => $author->id,
'project_id' => $project->id,
@@ -70,6 +68,20 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $this->nonParentType($project)->id,
'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([
'author_user_id' => $author->id,
'project_id' => $project->id,
@@ -110,14 +122,12 @@ class ProjectTicketControllerTest extends TestCase
->where('project.show_author', false)
->where('ticket.ticket_key', 'CORE-1')
->where('ticket.story_points', 5)
->where('ticket.acceptance_criteria.summary', 'Review checklist')
->where('ticket.acceptance_criteria.items.0.text', 'User can sign in')
->where('ticket.acceptance_criteria.items.0.checked', false)
->where('ticket.acceptance_criteria.items.0.indent', 0)
->where('ticket.acceptance_criteria.items.1.text', 'Nested verification')
->where('ticket.acceptance_criteria.items.1.checked', true)
->where('ticket.acceptance_criteria.items.1.indent', 1)
->where('ticket.acceptance_criteria_text', $legacyAcceptanceCriteria)
->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.author', null)
->where('ticket.author_user_id', null)
@@ -350,7 +360,11 @@ class ProjectTicketControllerTest extends TestCase
$this->actingAs($editor)
->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,
'description' => 'Created from the dedicated page.',
'story_points' => 5,
@@ -448,7 +462,6 @@ class ProjectTicketControllerTest extends TestCase
]);
$ticket = Ticket::factory()->create([
'acceptance_criteria' => 'Old acceptance criteria',
'author_user_id' => $author->id,
'description' => 'Old description',
'project_id' => $project->id,
@@ -475,18 +488,15 @@ class ProjectTicketControllerTest extends TestCase
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
$updatedAcceptanceCriteria = [
'summary' => 'Updated acceptance criteria',
'items' => [
[
'text' => 'Shows success banner',
'checked' => false,
'indent' => 0,
],
[
'text' => 'Includes nested confirmations',
'checked' => true,
'indent' => 1,
],
[
'text' => 'Shows success banner',
'checked' => false,
'indent' => 0,
],
[
'text' => 'Includes nested confirmations',
'checked' => true,
'indent' => 1,
],
];
@@ -520,16 +530,37 @@ class ProjectTicketControllerTest extends TestCase
'id' => $ticket->id,
'title' => 'Updated title',
'description' => 'Updated description',
'acceptance_criteria' => "Updated acceptance criteria\n\n- [ ] Shows success banner\n - [x] Includes nested confirmations",
'assignee_user_id' => $assignee->id,
'story_points' => 8,
'ticket_type_id' => $this->parentType($project)->id,
'ticket_priority_id' => $highPriority->id,
]);
$ticket->refresh();
$ticket->refresh()->load('acceptanceCriteria');
$this->assertEquals($updatedAcceptanceCriteria, $ticket->acceptance_criteria_data);
$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', [
'ticket_id' => $ticket->id,
'ticket_field_definition_id' => $reviewerField->id,
@@ -548,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.
*/
@@ -948,7 +1061,9 @@ class ProjectTicketControllerTest extends TestCase
private function updatePayload(Ticket $ticket, array $overrides = []): array
{
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,
'author_user_id' => $ticket->author_user_id,
'blocked_comment' => $ticket->blocked_comment,