koda/recreate-67-checklist-acceptance-criteria #72
@@ -4,6 +4,7 @@ namespace App\Actions\Tickets;
|
||||
|
||||
use App\Actions\Notifications\BuildTicketCreatedPayload;
|
||||
use App\Actions\Notifications\TicketCreated;
|
||||
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
|
||||
use App\Enums\TicketWorklogEntryType;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
@@ -22,7 +23,14 @@ class CreateProjectTicket
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* acceptance_criteria: string|null,
|
||||
* acceptance_criteria: array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* },
|
||||
* assignee_user_id?: int|null,
|
||||
* description: string|null,
|
||||
* story_points?: int|null,
|
||||
@@ -35,6 +43,9 @@ 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(
|
||||
Arr::get($attributes, 'acceptance_criteria'),
|
||||
);
|
||||
$nextSequenceNumber = (int) Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->max('sequence_number') + 1;
|
||||
@@ -43,7 +54,8 @@ class CreateProjectTicket
|
||||
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||
|
||||
$ticket = Ticket::query()->create([
|
||||
'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'),
|
||||
'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'),
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
|
||||
use App\Enums\TicketWorklogEntryType;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
@@ -20,7 +21,14 @@ class UpdateProjectTicket
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* acceptance_criteria: string|null,
|
||||
* acceptance_criteria: array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* },
|
||||
* assignee_user_id?: int|null,
|
||||
* author_user_id?: int|null,
|
||||
* blocked_comment: string|null,
|
||||
@@ -77,6 +85,14 @@ 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'],
|
||||
);
|
||||
|
||||
$changes = [];
|
||||
$summaryLabels = [];
|
||||
@@ -97,13 +113,11 @@ class UpdateProjectTicket
|
||||
$ticket->description,
|
||||
$ticketAttributes['description'],
|
||||
);
|
||||
$this->trackScalarChange(
|
||||
$this->trackAcceptanceCriteriaChange(
|
||||
$changes,
|
||||
$summaryLabels,
|
||||
'acceptance_criteria',
|
||||
'acceptance criteria',
|
||||
$ticket->acceptance_criteria,
|
||||
$ticketAttributes['acceptance_criteria'],
|
||||
$currentAcceptanceCriteria,
|
||||
$nextAcceptanceCriteria,
|
||||
);
|
||||
$this->trackScalarChange(
|
||||
$changes,
|
||||
@@ -217,7 +231,14 @@ class UpdateProjectTicket
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* acceptance_criteria: string|null,
|
||||
* acceptance_criteria: array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>
|
||||
* },
|
||||
* assignee_user_id?: int|null,
|
||||
* author_user_id?: int|null,
|
||||
* blocked_comment: string|null,
|
||||
@@ -231,6 +252,14 @@ class UpdateProjectTicket
|
||||
* } $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,
|
||||
@@ -245,8 +274,13 @@ class UpdateProjectTicket
|
||||
*/
|
||||
private function buildTicketAttributes(Ticket $ticket, array $validated): array
|
||||
{
|
||||
$acceptanceCriteria = TicketAcceptanceCriteriaData::normalize(
|
||||
Arr::get($validated, 'acceptance_criteria'),
|
||||
);
|
||||
|
||||
return [
|
||||
'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'),
|
||||
'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,
|
||||
@@ -266,6 +300,35 @@ 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
|
||||
*/
|
||||
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<int, string> $summaryLabels
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -17,6 +18,18 @@ class StoreProjectTicketRequest extends FormRequest
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize(
|
||||
$this->input('acceptance_criteria'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
@@ -28,7 +41,13 @@ class StoreProjectTicketRequest extends FormRequest
|
||||
$project = $this->route('project');
|
||||
|
||||
return [
|
||||
'acceptance_criteria' => ['nullable', 'string'],
|
||||
'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],
|
||||
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'description' => ['nullable', 'string'],
|
||||
'ticket_priority_id' => [
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
@@ -23,6 +24,18 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::normalize(
|
||||
$this->input('acceptance_criteria'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
@@ -34,7 +47,13 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
$project = $this->route('project');
|
||||
|
||||
return [
|
||||
'acceptance_criteria' => ['nullable', 'string'],
|
||||
'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],
|
||||
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'blocked_comment' => ['nullable', 'string'],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -16,7 +17,11 @@ class TicketResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'acceptance_criteria' => $this->acceptance_criteria,
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::fromStored(
|
||||
$this->acceptance_criteria_data,
|
||||
$this->acceptance_criteria,
|
||||
),
|
||||
'acceptance_criteria_text' => $this->acceptance_criteria,
|
||||
'assignee' => $this->whenLoaded(
|
||||
'assignee',
|
||||
fn (): ?array => $this->assignee === null
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Mcp\Support;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\TicketResource;
|
||||
@@ -207,7 +208,10 @@ class KanbanGitMcp
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* acceptance_criteria: string|null,
|
||||
* acceptance_criteria: array{
|
||||
* summary: string|null,
|
||||
* items: array<int, array{checked: bool, indent: int, text: string}>
|
||||
* },
|
||||
* assignee_user_id: int|null,
|
||||
* author_user_id: int|null,
|
||||
* blocked_comment: string|null,
|
||||
@@ -223,7 +227,10 @@ class KanbanGitMcp
|
||||
public function updatePayload(Ticket $ticket): array
|
||||
{
|
||||
return [
|
||||
'acceptance_criteria' => $ticket->acceptance_criteria,
|
||||
'acceptance_criteria' => TicketAcceptanceCriteriaData::fromStored(
|
||||
$ticket->acceptance_criteria_data,
|
||||
$ticket->acceptance_criteria,
|
||||
),
|
||||
'assignee_user_id' => $ticket->assignee_user_id,
|
||||
'author_user_id' => $ticket->author_user_id,
|
||||
'blocked_comment' => $ticket->blocked_comment,
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'acceptance_criteria',
|
||||
'acceptance_criteria_data',
|
||||
'assignee_user_id',
|
||||
'author_user_id',
|
||||
'blocked_comment',
|
||||
@@ -37,6 +38,7 @@ class Ticket extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'acceptance_criteria_data' => 'array',
|
||||
'is_blocked' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?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::table('tickets', function (Blueprint $table): void {
|
||||
$table->json('acceptance_criteria_data')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table): void {
|
||||
$table->dropColumn('acceptance_criteria_data');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import type {
|
||||
AcceptanceCriteriaItemModel,
|
||||
AcceptanceCriteriaModel,
|
||||
} from '@/types/models/acceptance-criteria-model';
|
||||
|
||||
const MAX_INDENT_LEVEL = 8;
|
||||
|
||||
export type AcceptanceCriteriaEditorState = {
|
||||
checklist: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => ({
|
||||
summary: null,
|
||||
items: [],
|
||||
});
|
||||
|
||||
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
|
||||
.map((item) => ({
|
||||
checked: item.checked === true,
|
||||
indent: clampIndent(item.indent),
|
||||
text: item.text.trim(),
|
||||
}))
|
||||
.filter((item) => item.text !== '')
|
||||
: [];
|
||||
|
||||
return {
|
||||
summary: summary === '' ? null : summary,
|
||||
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 = (
|
||||
summaryInput: string,
|
||||
checklistInput: string,
|
||||
): AcceptanceCriteriaModel => {
|
||||
const summary = summaryInput.trim();
|
||||
|
||||
return normalizeAcceptanceCriteria({
|
||||
summary: summary === '' ? null : summary,
|
||||
items: parseChecklistLines(checklistInput),
|
||||
});
|
||||
};
|
||||
|
||||
export const acceptanceCriteriaToEditorState = (
|
||||
value: AcceptanceCriteriaModel | null | undefined,
|
||||
): AcceptanceCriteriaEditorState => {
|
||||
const normalized = normalizeAcceptanceCriteria(value);
|
||||
|
||||
return {
|
||||
checklist: formatChecklistLines(normalized.items),
|
||||
summary: normalized.summary ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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,11 @@
|
||||
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
acceptanceCriteriaToEditorState,
|
||||
buildAcceptanceCriteria,
|
||||
parseChecklistLines,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
@@ -103,8 +108,17 @@ 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: props.ticket.acceptance_criteria ?? '',
|
||||
acceptance_criteria: buildAcceptanceCriteria(
|
||||
acceptanceCriteriaSummary.value,
|
||||
acceptanceCriteriaChecklist.value,
|
||||
),
|
||||
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
||||
author_user_id: props.ticket.author_user_id ?? '',
|
||||
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||
@@ -130,6 +144,22 @@ const form = useForm({
|
||||
story_points: props.ticket.story_points,
|
||||
});
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.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.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
const isCreateBranchDialogOpen = ref(false);
|
||||
const isCheckingBranchName = ref(false);
|
||||
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||
@@ -318,6 +348,11 @@ const submit = (): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaSummary.value,
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
|
||||
form.submit(
|
||||
ProjectTicketController.update({
|
||||
project: props.project.id,
|
||||
@@ -589,24 +624,66 @@ const formatDateTime = (value: string): string => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="acceptance_criteria">
|
||||
Acceptance criteria
|
||||
<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
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria"
|
||||
v-model="form.acceptance_criteria"
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
rows="5"
|
||||
: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"
|
||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<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="form.errors.acceptance_criteria"
|
||||
: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
|
||||
class="border-border grid gap-3 rounded-xl border p-4"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
buildAcceptanceCriteria,
|
||||
parseChecklistLines,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
@@ -56,6 +61,52 @@ 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,
|
||||
),
|
||||
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 ??
|
||||
form.errors['acceptance_criteria.summary'] ??
|
||||
form.errors['acceptance_criteria.items'] ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('acceptance_criteria.items.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaSummary.value,
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
|
||||
form.submit(ProjectTicketController.store({ project: props.project.id }), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
@@ -116,21 +167,18 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="ProjectTicketController.store.form(project.id)"
|
||||
class="grid gap-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<form class="grid gap-6" @submit.prevent="submit">
|
||||
<div class="grid gap-2">
|
||||
<Label for="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
v-model="form.title"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
placeholder="Implement repository sync"
|
||||
/>
|
||||
<InputError :message="errors.title" />
|
||||
<InputError :message="form.errors.title" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
@@ -139,28 +187,67 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Label for="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
: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."
|
||||
/>
|
||||
<InputError :message="errors.description" />
|
||||
<InputError :message="form.errors.description" />
|
||||
</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">
|
||||
Acceptance criteria
|
||||
<Label for="acceptance_criteria_checklist">
|
||||
Acceptance criteria checklist
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria"
|
||||
name="acceptance_criteria"
|
||||
rows="4"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
placeholder="List the conditions that must be true before this ticket is done."
|
||||
/>
|
||||
<InputError
|
||||
:message="errors.acceptance_criteria"
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
rows="6"
|
||||
: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="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -169,20 +256,19 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Label for="ticket_type_id">Type</Label>
|
||||
<select
|
||||
id="ticket_type_id"
|
||||
name="ticket_type_id"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
v-model="form.ticket_type_id"
|
||||
: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
|
||||
v-for="ticketType in ticketTypes"
|
||||
:key="ticketType.id"
|
||||
:value="ticketType.id"
|
||||
:value="String(ticketType.id)"
|
||||
>
|
||||
{{ ticketType.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="errors.ticket_type_id"
|
||||
/>
|
||||
<InputError :message="form.errors.ticket_type_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -191,44 +277,38 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</Label>
|
||||
<select
|
||||
id="ticket_priority_id"
|
||||
name="ticket_priority_id"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
v-model="form.ticket_priority_id"
|
||||
: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
|
||||
v-for="ticketPriority in ticketPriorities"
|
||||
:key="ticketPriority.id"
|
||||
:value="ticketPriority.id"
|
||||
:value="String(ticketPriority.id)"
|
||||
>
|
||||
{{ ticketPriority.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="errors.ticket_priority_id"
|
||||
/>
|
||||
<InputError :message="form.errors.ticket_priority_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ticket_status_id">Status</Label>
|
||||
<select
|
||||
id="ticket_status_id"
|
||||
name="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]"
|
||||
v-model="form.ticket_status_id"
|
||||
: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
|
||||
v-for="ticketStatus in ticketStatusOptions"
|
||||
:key="ticketStatus.id"
|
||||
:value="ticketStatus.id"
|
||||
:selected="
|
||||
ticketStatus.id ===
|
||||
defaultTicketStatusId
|
||||
"
|
||||
:value="String(ticketStatus.id)"
|
||||
>
|
||||
{{ ticketStatusLabel(ticketStatus) }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="errors.ticket_status_id"
|
||||
/>
|
||||
<InputError :message="form.errors.ticket_status_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -237,21 +317,20 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</Label>
|
||||
<select
|
||||
id="assignee_user_id"
|
||||
name="assignee_user_id"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
v-model="form.assignee_user_id"
|
||||
: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
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
:value="String(user.id)"
|
||||
>
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="errors.assignee_user_id"
|
||||
/>
|
||||
<InputError :message="form.errors.assignee_user_id" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -263,19 +342,18 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</Label>
|
||||
<Input
|
||||
id="story_points"
|
||||
name="story_points"
|
||||
v-model="form.story_points"
|
||||
type="number"
|
||||
step="1"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<InputError
|
||||
:message="errors.story_points"
|
||||
/>
|
||||
<InputError :message="form.errors.story_points" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" :disabled="processing">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Create ticket
|
||||
</Button>
|
||||
<Button as-child variant="outline">
|
||||
@@ -284,7 +362,7 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export type AcceptanceCriteriaItemModel = {
|
||||
checked: boolean;
|
||||
indent: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AcceptanceCriteriaModel = {
|
||||
summary: string | null;
|
||||
items: 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 { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
@@ -7,7 +8,8 @@ import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
export type TicketModel = {
|
||||
id: number;
|
||||
acceptance_criteria: string | null;
|
||||
acceptance_criteria: AcceptanceCriteriaModel;
|
||||
acceptance_criteria_text: string | null;
|
||||
assignee: UserModel | null;
|
||||
assignee_user_id: number | null;
|
||||
author: UserModel | null;
|
||||
|
||||
@@ -43,7 +43,21 @@ class TicketApiTest extends TestCase
|
||||
|
||||
$createResponse = $this->withApplicationKey($user)
|
||||
->postJson(route('api.projects.tickets.store', $project), [
|
||||
'acceptance_criteria' => 'Can sign in',
|
||||
'acceptance_criteria' => [
|
||||
'summary' => 'Can sign in',
|
||||
'items' => [
|
||||
[
|
||||
'checked' => false,
|
||||
'indent' => 0,
|
||||
'text' => 'Credentials are validated',
|
||||
],
|
||||
[
|
||||
'checked' => true,
|
||||
'indent' => 1,
|
||||
'text' => 'Nested validation is supported',
|
||||
],
|
||||
],
|
||||
],
|
||||
'description' => 'Build the API-created ticket',
|
||||
'story_points' => 3,
|
||||
'ticket_priority_id' => $priority->id,
|
||||
@@ -53,6 +67,8 @@ 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('meta.message', 'CORE-1 was created.');
|
||||
|
||||
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
|
||||
@@ -64,7 +80,16 @@ class TicketApiTest extends TestCase
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
|
||||
'acceptance_criteria' => 'Can sign in from the API',
|
||||
'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,
|
||||
@@ -76,6 +101,7 @@ class TicketApiTest extends TestCase
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.title', 'Updated API ticket')
|
||||
->assertJsonPath('data.acceptance_criteria.items.0.checked', true)
|
||||
->assertJsonPath('meta.message', 'CORE-1 was updated.');
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
|
||||
@@ -56,7 +56,10 @@ 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,
|
||||
@@ -107,6 +110,14 @@ 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.assignee.name', 'Assigned User')
|
||||
->where('ticket.author', null)
|
||||
->where('ticket.author_user_id', null)
|
||||
@@ -463,9 +474,25 @@ 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,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
'acceptance_criteria' => 'Updated acceptance criteria',
|
||||
'acceptance_criteria' => $updatedAcceptanceCriteria,
|
||||
'assignee_user_id' => $assignee->id,
|
||||
'blocked_comment' => $ticket->blocked_comment,
|
||||
'description' => 'Updated description',
|
||||
@@ -493,12 +520,16 @@ class ProjectTicketControllerTest extends TestCase
|
||||
'id' => $ticket->id,
|
||||
'title' => 'Updated title',
|
||||
'description' => 'Updated description',
|
||||
'acceptance_criteria' => 'Updated acceptance criteria',
|
||||
'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();
|
||||
|
||||
$this->assertEquals($updatedAcceptanceCriteria, $ticket->acceptance_criteria_data);
|
||||
$this->assertDatabaseHas('ticket_field_values', [
|
||||
'ticket_id' => $ticket->id,
|
||||
'ticket_field_definition_id' => $reviewerField->id,
|
||||
|
||||
Reference in New Issue
Block a user