Simplify acceptance criteria checklist model
This commit is contained in:
@@ -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\AcceptanceCriteriaChecklist;
|
||||
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{
|
||||
* 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 = AcceptanceCriteriaChecklist::normalize(
|
||||
Arr::get($attributes, 'acceptance_criteria'),
|
||||
);
|
||||
$nextSequenceNumber = (int) Ticket::query()
|
||||
@@ -54,7 +51,7 @@ class CreateProjectTicket
|
||||
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||
|
||||
$ticket = Ticket::query()->create([
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::toLegacyText($acceptanceCriteria),
|
||||
'acceptance_criteria' => AcceptanceCriteriaChecklist::toText($acceptanceCriteria),
|
||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||
'author_user_id' => $actor?->id,
|
||||
'description' => Arr::get($attributes, 'description'),
|
||||
@@ -68,7 +65,7 @@ class CreateProjectTicket
|
||||
'story_points' => Arr::get($attributes, 'story_points'),
|
||||
]);
|
||||
|
||||
TicketAcceptanceCriteriaData::syncTicket($ticket, $acceptanceCriteria);
|
||||
AcceptanceCriteriaChecklist::syncTicket($ticket, $acceptanceCriteria);
|
||||
|
||||
$occurredAt = CarbonImmutable::now();
|
||||
|
||||
|
||||
+38
-161
@@ -7,24 +7,16 @@ use App\Models\Ticket;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class TicketAcceptanceCriteriaData
|
||||
class AcceptanceCriteriaChecklist
|
||||
{
|
||||
public const MAX_INDENT_LEVEL = 8;
|
||||
|
||||
/**
|
||||
* @param iterable<AcceptanceCriterion> $criteria
|
||||
* @return array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* }
|
||||
* @return array<int, array{checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
public static function fromModels(iterable $criteria, ?string $legacyText): array
|
||||
public static function fromModels(iterable $criteria, ?string $legacyText = null): array
|
||||
{
|
||||
$summary = self::normalize($legacyText)['summary'];
|
||||
$models = ($criteria instanceof EloquentCollection ? $criteria : collect($criteria))
|
||||
->sort(function (AcceptanceCriterion $left, AcceptanceCriterion $right): int {
|
||||
return [
|
||||
@@ -43,27 +35,17 @@ class TicketAcceptanceCriteriaData
|
||||
return self::normalize($legacyText);
|
||||
}
|
||||
|
||||
/** @var array<int|null, array<int, AcceptanceCriterion>> $grouped */
|
||||
/** @var array<int|string, iterable<AcceptanceCriterion>> $grouped */
|
||||
$grouped = $models->groupBy('parent_acceptance_criterion_id')->all();
|
||||
$items = [];
|
||||
|
||||
self::flattenItems($items, $grouped, null, 0);
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'items' => $items,
|
||||
];
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* }
|
||||
* @return array<int, array{checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
public static function normalize(mixed $value): array
|
||||
{
|
||||
@@ -75,50 +57,27 @@ class TicketAcceptanceCriteriaData
|
||||
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'] !== [];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Ticket $ticket
|
||||
* @param array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* } $acceptanceCriteria
|
||||
* @param array<int, array{checked: bool, indent: int, text: string}> $items
|
||||
*/
|
||||
public static function syncTicket(Ticket $ticket, array $acceptanceCriteria): void
|
||||
public static function syncTicket(Ticket $ticket, array $items): void
|
||||
{
|
||||
$normalized = self::normalize($acceptanceCriteria);
|
||||
$normalized = self::normalize($items);
|
||||
|
||||
$ticket->acceptanceCriteria()->delete();
|
||||
|
||||
if ($normalized['items'] === []) {
|
||||
if ($normalized === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentStack = [];
|
||||
$positionsByParent = [];
|
||||
|
||||
foreach ($normalized['items'] as $item) {
|
||||
foreach ($normalized as $item) {
|
||||
$indent = min($item['indent'], count($parentStack));
|
||||
$parentId = $indent === 0 ? null : $parentStack[$indent - 1];
|
||||
$positionKey = $parentId ?? 'root';
|
||||
@@ -140,60 +99,25 @@ class TicketAcceptanceCriteriaData
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* } $acceptanceCriteria
|
||||
* @param array<int, array{checked: bool, indent: int, text: string}> $items
|
||||
*/
|
||||
public static function toLegacyText(array $acceptanceCriteria): ?string
|
||||
public static function toText(array $items): ?string
|
||||
{
|
||||
$normalized = self::normalize($acceptanceCriteria);
|
||||
$lines = [];
|
||||
$normalized = self::normalize($items);
|
||||
|
||||
if ($normalized['summary'] !== null) {
|
||||
$lines[] = $normalized['summary'];
|
||||
if ($normalized === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($normalized['items'] !== []) {
|
||||
if ($normalized['summary'] !== null) {
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
foreach ($normalized['items'] as $item) {
|
||||
$lines[] = sprintf(
|
||||
return implode("\n", array_map(
|
||||
static fn (array $item): string => 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' => [],
|
||||
];
|
||||
),
|
||||
$normalized,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,23 +138,15 @@ class TicketAcceptanceCriteriaData
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $value
|
||||
* @return array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* }
|
||||
* @param array<int|string, mixed> $value
|
||||
* @return 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', []);
|
||||
$rawItems = Arr::isAssoc($value) ? Arr::get($value, 'items', []) : $value;
|
||||
|
||||
if (! is_array($rawItems)) {
|
||||
$rawItems = [];
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
@@ -246,78 +162,37 @@ class TicketAcceptanceCriteriaData
|
||||
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),
|
||||
'checked' => filter_var(Arr::get($rawItem, 'checked', false), FILTER_VALIDATE_BOOLEAN),
|
||||
'indent' => self::clampIndent((int) Arr::get($rawItem, 'indent', 0)),
|
||||
'text' => $text,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => $summary === '' ? null : $summary,
|
||||
'items' => $items,
|
||||
];
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* }
|
||||
* @return 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();
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = preg_split('/\R/u', $text);
|
||||
|
||||
if (! is_array($lines)) {
|
||||
return [
|
||||
'summary' => $text,
|
||||
'items' => [],
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
$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_values(array_filter(array_map(
|
||||
static fn (string $line): ?array => self::parseChecklistItem($line),
|
||||
$lines,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +223,8 @@ class TicketAcceptanceCriteriaData
|
||||
$content = trim((string) Arr::get($checkboxMatches, 'text', ''));
|
||||
} elseif (preg_match('/^[-*+]\s+(?<text>.+)$/u', $content, $bulletMatches)) {
|
||||
$content = trim((string) Arr::get($bulletMatches, 'text', ''));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($content === '') {
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
|
||||
use App\Enums\TicketWorklogEntryType;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
@@ -21,14 +21,11 @@ class UpdateProjectTicket
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* acceptance_criteria: array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* 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,
|
||||
@@ -52,7 +49,7 @@ class UpdateProjectTicket
|
||||
array $validated,
|
||||
): bool {
|
||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||
$nextAcceptanceCriteria = TicketAcceptanceCriteriaData::normalize(
|
||||
$nextAcceptanceCriteria = AcceptanceCriteriaChecklist::normalize(
|
||||
Arr::get($validated, 'acceptance_criteria'),
|
||||
);
|
||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
||||
@@ -89,7 +86,7 @@ class UpdateProjectTicket
|
||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||
$existingFieldValues = $ticket->fieldValues
|
||||
->keyBy('ticket_field_definition_id');
|
||||
$currentAcceptanceCriteria = TicketAcceptanceCriteriaData::fromModels(
|
||||
$currentAcceptanceCriteria = AcceptanceCriteriaChecklist::fromModels(
|
||||
$ticket->acceptanceCriteria,
|
||||
$ticket->acceptance_criteria,
|
||||
);
|
||||
@@ -199,7 +196,7 @@ class UpdateProjectTicket
|
||||
}
|
||||
|
||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||
TicketAcceptanceCriteriaData::syncTicket($ticket, $nextAcceptanceCriteria);
|
||||
AcceptanceCriteriaChecklist::syncTicket($ticket, $nextAcceptanceCriteria);
|
||||
}
|
||||
|
||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||
@@ -235,14 +232,11 @@ class UpdateProjectTicket
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* acceptance_criteria: array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* 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,
|
||||
@@ -270,12 +264,12 @@ class UpdateProjectTicket
|
||||
*/
|
||||
private function buildTicketAttributes(Ticket $ticket, array $validated): array
|
||||
{
|
||||
$acceptanceCriteria = TicketAcceptanceCriteriaData::normalize(
|
||||
$acceptanceCriteria = AcceptanceCriteriaChecklist::normalize(
|
||||
Arr::get($validated, 'acceptance_criteria'),
|
||||
);
|
||||
|
||||
return [
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::toLegacyText($acceptanceCriteria),
|
||||
'acceptance_criteria' => AcceptanceCriteriaChecklist::toText($acceptanceCriteria),
|
||||
'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
|
||||
? Arr::get($validated, 'assignee_user_id')
|
||||
: $ticket->assignee_user_id,
|
||||
@@ -298,14 +292,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\AcceptanceCriteriaChecklist;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -24,7 +24,7 @@ class StoreProjectTicketRequest extends FormRequest
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize(
|
||||
'acceptance_criteria' => AcceptanceCriteriaChecklist::normalize(
|
||||
$this->input('acceptance_criteria'),
|
||||
),
|
||||
]);
|
||||
@@ -42,12 +42,10 @@ 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.*.text' => ['required', 'string'],
|
||||
'acceptance_criteria.*.checked' => ['required', 'boolean'],
|
||||
'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklist::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\AcceptanceCriteriaChecklist;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
@@ -30,7 +30,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize(
|
||||
'acceptance_criteria' => AcceptanceCriteriaChecklist::normalize(
|
||||
$this->input('acceptance_criteria'),
|
||||
),
|
||||
]);
|
||||
@@ -48,12 +48,10 @@ 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.*.text' => ['required', 'string'],
|
||||
'acceptance_criteria.*.checked' => ['required', 'boolean'],
|
||||
'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklist::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'],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -17,13 +17,12 @@ class TicketResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::fromModels(
|
||||
'acceptance_criteria' => AcceptanceCriteriaChecklist::fromModels(
|
||||
$this->relationLoaded('acceptanceCriteria')
|
||||
? $this->acceptanceCriteria
|
||||
: $this->acceptanceCriteria()->get(),
|
||||
$this->acceptance_criteria,
|
||||
),
|
||||
'acceptance_criteria_text' => $this->acceptance_criteria,
|
||||
'assignee' => $this->whenLoaded(
|
||||
'assignee',
|
||||
fn (): ?array => $this->assignee === null
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Mcp\Support;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklist;
|
||||
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,7 +224,7 @@ class KanbanGitMcp
|
||||
public function updatePayload(Ticket $ticket): array
|
||||
{
|
||||
return [
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::fromModels(
|
||||
'acceptance_criteria' => AcceptanceCriteriaChecklist::fromModels(
|
||||
$ticket->relationLoaded('acceptanceCriteria')
|
||||
? $ticket->acceptanceCriteria
|
||||
: $ticket->acceptanceCriteria()->get(),
|
||||
|
||||
@@ -7,24 +7,15 @@ 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) => ({
|
||||
checked: item.checked === true,
|
||||
indent: clampIndent(item.indent),
|
||||
@@ -33,10 +24,7 @@ export const normalizeAcceptanceCriteria = (
|
||||
.filter((item) => item.text !== '')
|
||||
: [];
|
||||
|
||||
return {
|
||||
summary: summary === '' ? null : summary,
|
||||
items,
|
||||
};
|
||||
return items;
|
||||
};
|
||||
|
||||
export const parseChecklistLines = (
|
||||
@@ -69,15 +57,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 +68,7 @@ export const acceptanceCriteriaToEditorState = (
|
||||
const normalized = normalizeAcceptanceCriteria(value);
|
||||
|
||||
return {
|
||||
checklist: formatChecklistLines(normalized.items),
|
||||
summary: normalized.summary ?? '',
|
||||
checklist: formatChecklistLines(normalized),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -123,6 +104,8 @@ const parseChecklistLine = (
|
||||
|
||||
if (bulletMatch?.groups !== undefined) {
|
||||
content = bulletMatch.groups.text.trim();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,14 +111,10 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
||||
props.ticket.acceptance_criteria,
|
||||
);
|
||||
const acceptanceCriteriaSummary = ref(acceptanceCriteriaEditorState.summary);
|
||||
const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist);
|
||||
|
||||
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 ?? '',
|
||||
@@ -151,10 +147,8 @@ 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] ??
|
||||
''
|
||||
);
|
||||
@@ -348,10 +342,7 @@ const submit = (): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaSummary.value,
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
|
||||
|
||||
form.submit(
|
||||
ProjectTicketController.update({
|
||||
@@ -625,20 +616,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,4 @@ export type AcceptanceCriteriaItemModel = {
|
||||
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;
|
||||
|
||||
@@ -44,8 +44,6 @@ 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,
|
||||
@@ -57,7 +55,6 @@ class TicketApiTest extends TestCase
|
||||
'text' => 'Nested validation is supported',
|
||||
],
|
||||
],
|
||||
],
|
||||
'description' => 'Build the API-created ticket',
|
||||
'story_points' => 3,
|
||||
'ticket_priority_id' => $priority->id,
|
||||
@@ -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,15 +77,12 @@ 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',
|
||||
],
|
||||
],
|
||||
],
|
||||
'blocked_comment' => null,
|
||||
'description' => 'Updated through the API',
|
||||
'is_blocked' => false,
|
||||
@@ -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)
|
||||
|
||||
@@ -110,7 +110,7 @@ class TicketCreatedNotificationDispatchTest extends TestCase
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
'acceptance_criteria' => 'Ship it.',
|
||||
'acceptance_criteria' => '- [ ] Ship it.',
|
||||
'assignee_user_id' => null,
|
||||
'description' => 'Created from notification dispatch test.',
|
||||
'story_points' => 3,
|
||||
|
||||
@@ -84,7 +84,7 @@ class ProjectBacklogTest extends TestCase
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
'title' => 'New ticket',
|
||||
'description' => 'Description',
|
||||
'acceptance_criteria' => 'Criteria',
|
||||
'acceptance_criteria' => '- [ ] Criteria',
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_status_id' => $backlogStatus->id,
|
||||
@@ -118,7 +118,7 @@ class ProjectBacklogTest extends TestCase
|
||||
$payload = [
|
||||
'title' => 'Same title',
|
||||
'description' => 'Description',
|
||||
'acceptance_criteria' => 'Criteria',
|
||||
'acceptance_criteria' => '- [ ] Criteria',
|
||||
'assignee_user_id' => $assignee->id,
|
||||
'story_points' => 3,
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
@@ -192,7 +192,7 @@ class ProjectBacklogTest extends TestCase
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
'title' => 'New ticket',
|
||||
'description' => 'Description',
|
||||
'acceptance_criteria' => 'Criteria',
|
||||
'acceptance_criteria' => '- [ ] Criteria',
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'ticket_status_id' => $boardStatus->id,
|
||||
|
||||
@@ -56,7 +56,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$legacyAcceptanceCriteria = "Review checklist\n- [ ] User can sign in\n - [x] Nested verification";
|
||||
$legacyAcceptanceCriteria = "- [ ] User can sign in\n - [x] Nested verification";
|
||||
|
||||
$ticket = Ticket::factory()->create([
|
||||
'acceptance_criteria' => $legacyAcceptanceCriteria,
|
||||
@@ -110,14 +110,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 +348,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($editor)
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
'acceptance_criteria' => 'The ticket exists.',
|
||||
'acceptance_criteria' => '- [ ] The ticket exists.',
|
||||
'assignee_user_id' => $assignee->id,
|
||||
'description' => 'Created from the dedicated page.',
|
||||
'story_points' => 5,
|
||||
@@ -448,7 +446,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$ticket = Ticket::factory()->create([
|
||||
'acceptance_criteria' => 'Old acceptance criteria',
|
||||
'acceptance_criteria' => '- [ ] Old acceptance criteria',
|
||||
'author_user_id' => $author->id,
|
||||
'description' => 'Old description',
|
||||
'project_id' => $project->id,
|
||||
@@ -475,8 +473,6 @@ class ProjectTicketControllerTest extends TestCase
|
||||
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||
|
||||
$updatedAcceptanceCriteria = [
|
||||
'summary' => 'Updated acceptance criteria',
|
||||
'items' => [
|
||||
[
|
||||
'text' => 'Shows success banner',
|
||||
'checked' => false,
|
||||
@@ -487,7 +483,6 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'checked' => true,
|
||||
'indent' => 1,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->actingAs($editor)
|
||||
@@ -520,7 +515,7 @@ 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",
|
||||
'acceptance_criteria' => "- [ ] Shows success banner\n - [x] Includes nested confirmations",
|
||||
'assignee_user_id' => $assignee->id,
|
||||
'story_points' => 8,
|
||||
'ticket_type_id' => $this->parentType($project)->id,
|
||||
@@ -531,7 +526,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
|
||||
$this->assertEquals(
|
||||
$updatedAcceptanceCriteria,
|
||||
\App\Actions\Tickets\Support\TicketAcceptanceCriteriaData::fromModels(
|
||||
\App\Actions\Tickets\Support\AcceptanceCriteriaChecklist::fromModels(
|
||||
$ticket->acceptanceCriteria,
|
||||
$ticket->acceptance_criteria,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user