diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index 7ea665a..a2c26a5 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -4,6 +4,7 @@ namespace App\Actions\Tickets; use App\Actions\Notifications\BuildTicketCreatedPayload; use App\Actions\Notifications\TicketCreated; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Enums\TicketWorklogEntryType; use App\Models\Project; use App\Models\Ticket; @@ -22,7 +23,11 @@ class CreateProjectTicket /** * @param array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array, * assignee_user_id?: int|null, * description: string|null, * story_points?: int|null, @@ -35,6 +40,9 @@ class CreateProjectTicket public function handle(Project $project, array $attributes, ?User $actor = null): Ticket { return DB::transaction(function () use ($actor, $attributes, $project): Ticket { + $acceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize( + Arr::get($attributes, 'acceptance_criteria'), + ); $nextSequenceNumber = (int) Ticket::query() ->where('project_id', $project->id) ->max('sequence_number') + 1; @@ -43,7 +51,6 @@ class CreateProjectTicket ->findOrFail(Arr::get($attributes, 'ticket_status_id')); $ticket = Ticket::query()->create([ - 'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'), 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), 'author_user_id' => $actor?->id, 'description' => Arr::get($attributes, 'description'), @@ -57,6 +64,8 @@ class CreateProjectTicket 'story_points' => Arr::get($attributes, 'story_points'), ]); + AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria); + $occurredAt = CarbonImmutable::now(); if ($actor !== null) { diff --git a/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php b/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php new file mode 100644 index 0000000..5d08552 --- /dev/null +++ b/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php @@ -0,0 +1,225 @@ + $criteria + * @return array + */ + public static function fromModels(iterable $criteria): array + { + return self::flattenModels($criteria, false); + } + + /** + * @param iterable $criteria + * @return array + */ + public static function fromModelsWithIds(iterable $criteria): array + { + return self::flattenModels($criteria, true); + } + + /** + * @return array + */ + public static function normalize(mixed $value): array + { + if (is_array($value)) { + return self::normalizeFromArray($value); + } + + return []; + } + + /** + * @param Ticket $ticket + * @param array $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 $value + * @return array + */ + 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 $criteria + * @return array + */ + 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> $grouped */ + $grouped = $models->groupBy('parent_acceptance_criterion_id')->all(); + $items = []; + + self::flattenItems($items, $grouped, null, 0, $includeIds); + + return $items; + } + + /** + * @param array $items + * @param array> $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); + } + } +} diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php index 6cd6f95..d4e8a2a 100644 --- a/app/Actions/Tickets/UpdateProjectTicket.php +++ b/app/Actions/Tickets/UpdateProjectTicket.php @@ -2,6 +2,7 @@ namespace App\Actions\Tickets; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Enums\TicketWorklogEntryType; use App\Models\Ticket; use App\Models\TicketFieldDefinition; @@ -20,7 +21,11 @@ class UpdateProjectTicket /** * @param array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array, * assignee_user_id?: int|null, * author_user_id?: int|null, * blocked_comment: string|null, @@ -44,9 +49,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; } @@ -54,6 +62,7 @@ class UpdateProjectTicket $ticket->loadMissing([ 'assignee', 'author', + 'acceptanceCriteria', 'fieldValues.ticketFieldDefinition', 'fieldValues.userValue', 'parentTicket', @@ -77,6 +86,7 @@ class UpdateProjectTicket /** @var Collection $existingFieldValues */ $existingFieldValues = $ticket->fieldValues ->keyBy('ticket_field_definition_id'); + $currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria); $changes = []; $summaryLabels = []; @@ -97,13 +107,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, @@ -184,6 +192,10 @@ class UpdateProjectTicket $ticket->save(); } + if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) { + AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria); + } + $fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle( $ticket, $fieldDefinitions, @@ -217,7 +229,11 @@ class UpdateProjectTicket /** * @param array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array, * assignee_user_id?: int|null, * author_user_id?: int|null, * blocked_comment: string|null, @@ -230,7 +246,6 @@ class UpdateProjectTicket * title: string * } $validated * @return array{ - * acceptance_criteria: string|null, * assignee_user_id: int|null, * author_user_id: int|null, * blocked_comment: string|null, @@ -246,7 +261,6 @@ class UpdateProjectTicket private function buildTicketAttributes(Ticket $ticket, array $validated): array { return [ - 'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'), 'assignee_user_id' => Arr::exists($validated, 'assignee_user_id') ? Arr::get($validated, 'assignee_user_id') : $ticket->assignee_user_id, @@ -266,6 +280,29 @@ class UpdateProjectTicket ]; } + /** + * @param array $changes + * @param array $summaryLabels + * @param array $currentValue + * @param array $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 $changes * @param array $summaryLabels diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index be0545f..505a267 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -17,6 +18,24 @@ class StoreProjectTicketRequest extends FormRequest return $this->user() !== null; } + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $acceptanceCriteria = $this->input('acceptance_criteria'); + + if ($acceptanceCriteria !== null && ! is_array($acceptanceCriteria)) { + return; + } + + $this->merge([ + 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::normalize( + $acceptanceCriteria, + ), + ]); + } + /** * Get the validation rules that apply to the request. * @@ -28,7 +47,12 @@ class StoreProjectTicketRequest extends FormRequest $project = $this->route('project'); return [ - 'acceptance_criteria' => ['nullable', 'string'], + 'acceptance_criteria' => ['nullable', 'array'], + 'acceptance_criteria.*' => ['array'], + 'acceptance_criteria.*.id' => ['nullable', 'integer'], + 'acceptance_criteria.*.text' => ['required', 'string'], + 'acceptance_criteria.*.checked' => ['required', 'boolean'], + 'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'description' => ['nullable', 'string'], 'ticket_priority_id' => [ diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index fe705ff..1db69e5 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Actions\Permissions\PermissionService; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Enums\Permission; use App\Models\Project; use App\Models\Ticket; @@ -23,6 +24,24 @@ class UpdateProjectTicketRequest extends FormRequest return $this->user() !== null; } + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $acceptanceCriteria = $this->input('acceptance_criteria'); + + if ($acceptanceCriteria !== null && ! is_array($acceptanceCriteria)) { + return; + } + + $this->merge([ + 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::normalize( + $acceptanceCriteria, + ), + ]); + } + /** * Get the validation rules that apply to the request. * @@ -34,7 +53,12 @@ class UpdateProjectTicketRequest extends FormRequest $project = $this->route('project'); return [ - 'acceptance_criteria' => ['nullable', 'string'], + 'acceptance_criteria' => ['nullable', 'array'], + 'acceptance_criteria.*' => ['array'], + 'acceptance_criteria.*.id' => ['nullable', 'integer'], + 'acceptance_criteria.*.text' => ['required', 'string'], + 'acceptance_criteria.*.checked' => ['required', 'boolean'], + 'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'blocked_comment' => ['nullable', 'string'], diff --git a/app/Http/Resources/TicketResource.php b/app/Http/Resources/TicketResource.php index ff7be1b..faacda9 100644 --- a/app/Http/Resources/TicketResource.php +++ b/app/Http/Resources/TicketResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; 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' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds( + $this->relationLoaded('acceptanceCriteria') + ? $this->acceptanceCriteria + : $this->acceptanceCriteria()->get(), + ), 'assignee' => $this->whenLoaded( 'assignee', fn (): ?array => $this->assignee === null diff --git a/app/Mcp/Support/KanbanGitMcp.php b/app/Mcp/Support/KanbanGitMcp.php index 5f562bb..c57cbd4 100644 --- a/app/Mcp/Support/KanbanGitMcp.php +++ b/app/Mcp/Support/KanbanGitMcp.php @@ -3,6 +3,7 @@ namespace App\Mcp\Support; use App\Actions\Permissions\PermissionService; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Enums\Permission; use App\Http\Resources\ProjectResource; use App\Http\Resources\TicketResource; @@ -207,7 +208,7 @@ class KanbanGitMcp /** * @return array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array, * assignee_user_id: int|null, * author_user_id: int|null, * blocked_comment: string|null, @@ -223,7 +224,11 @@ class KanbanGitMcp public function updatePayload(Ticket $ticket): array { return [ - 'acceptance_criteria' => $ticket->acceptance_criteria, + 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModels( + $ticket->relationLoaded('acceptanceCriteria') + ? $ticket->acceptanceCriteria + : $ticket->acceptanceCriteria()->get(), + ), 'assignee_user_id' => $ticket->assignee_user_id, 'author_user_id' => $ticket->author_user_id, 'blocked_comment' => $ticket->blocked_comment, diff --git a/app/Models/AcceptanceCriterion.php b/app/Models/AcceptanceCriterion.php new file mode 100644 index 0000000..e1b112d --- /dev/null +++ b/app/Models/AcceptanceCriterion.php @@ -0,0 +1,45 @@ + + */ + 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); + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 7c1726c..692faca 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ - 'acceptance_criteria', 'assignee_user_id', 'author_user_id', 'blocked_comment', @@ -41,6 +40,13 @@ class Ticket extends Model ]; } + public function acceptanceCriteria(): HasMany + { + return $this->hasMany(AcceptanceCriterion::class) + ->orderBy('position') + ->orderBy('id'); + } + public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assignee_user_id'); diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index 3dd26b8..401d387 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -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, diff --git a/database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php b/database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php new file mode 100644 index 0000000..d073fdc --- /dev/null +++ b/database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php @@ -0,0 +1,39 @@ +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'); + } +}; diff --git a/database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php b/database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php new file mode 100644 index 0000000..fc5a47b --- /dev/null +++ b/database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php @@ -0,0 +1,36 @@ +dropColumn('acceptance_criteria'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('tickets', 'acceptance_criteria')) { + return; + } + + Schema::table('tickets', function (Blueprint $table): void { + $table->text('acceptance_criteria')->nullable()->after('description'); + }); + } +}; diff --git a/database/seeders/TicketSeeder.php b/database/seeders/TicketSeeder.php index 2528980..54ecfc4 100644 --- a/database/seeders/TicketSeeder.php +++ b/database/seeders/TicketSeeder.php @@ -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, diff --git a/resources/js/lib/acceptance-criteria.ts b/resources/js/lib/acceptance-criteria.ts new file mode 100644 index 0000000..897562b --- /dev/null +++ b/resources/js/lib/acceptance-criteria.ts @@ -0,0 +1,132 @@ +import type { + AcceptanceCriteriaItemModel, + AcceptanceCriteriaModel, +} from '@/types/models/acceptance-criteria-model'; + +const MAX_INDENT_LEVEL = 8; + +export type AcceptanceCriteriaEditorState = { + checklist: string; +}; + +export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => []; + +export const normalizeAcceptanceCriteria = ( + value: AcceptanceCriteriaModel | null | undefined, +): AcceptanceCriteriaModel => { + const items = Array.isArray(value) + ? value + .map((item) => ({ + ...(typeof item.id === 'number' && item.id > 0 + ? { id: item.id } + : {}), + checked: item.checked === true, + indent: clampIndent(item.indent), + text: item.text.trim(), + })) + .filter((item) => item.text !== '') + : []; + + return items; +}; + +export const parseChecklistLines = ( + checklistInput: string, +): AcceptanceCriteriaItemModel[] => { + if (checklistInput.trim() === '') { + return []; + } + + return checklistInput + .split(/\r?\n/u) + .map((line) => parseChecklistLine(line)) + .filter( + (parsedLine): parsedLine is AcceptanceCriteriaItemModel => + parsedLine !== null, + ); +}; + +export const formatChecklistLines = ( + items: AcceptanceCriteriaItemModel[], +): string => { + return items + .map((item) => { + const indentation = ' '.repeat(clampIndent(item.indent)); + const marker = item.checked ? '[x]' : '[ ]'; + + return `${indentation}${marker} ${item.text}`; + }) + .join('\n'); +}; + +export const buildAcceptanceCriteria = ( + checklistInput: string, +): AcceptanceCriteriaModel => { + return normalizeAcceptanceCriteria(parseChecklistLines(checklistInput)); +}; + +export const acceptanceCriteriaToEditorState = ( + value: AcceptanceCriteriaModel | null | undefined, +): AcceptanceCriteriaEditorState => { + const normalized = normalizeAcceptanceCriteria(value); + + return { + checklist: formatChecklistLines(normalized), + }; +}; + +const parseChecklistLine = ( + line: string, +): AcceptanceCriteriaItemModel | null => { + const normalizedLine = line.replaceAll('\t', ' ').trimEnd(); + + if (normalizedLine.trim() === '') { + return null; + } + + const match = /^(?\s*)(?.+)$/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+)?\[(?[xX ])\]\s*(?.+)$/u.exec(content); + + if (checkboxMatch?.groups !== undefined) { + checked = checkboxMatch.groups.state.toLowerCase() === 'x'; + content = checkboxMatch.groups.text.trim(); + } else { + const bulletMatch = /^[-*+]\s+(?.+)$/u.exec(content); + + if (bulletMatch?.groups !== undefined) { + content = bulletMatch.groups.text.trim(); + } else { + return null; + } + } + + if (content === '') { + return null; + } + + return { + checked, + indent: indentLevel, + text: content, + }; +}; + +const clampIndent = (indent: number): number => { + if (!Number.isFinite(indent)) { + return 0; + } + + return Math.max(0, Math.min(MAX_INDENT_LEVEL, Math.trunc(indent))); +}; diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue index f8420b5..35b7118 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -2,6 +2,13 @@ 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, + formatChecklistLines, + normalizeAcceptanceCriteria, + 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 +110,18 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; +const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState( + props.ticket.acceptance_criteria, +); +const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist); +const savedAcceptanceCriteria = ref( + normalizeAcceptanceCriteria(props.ticket.acceptance_criteria), +); +const isPersistingAcceptanceCriteriaToggle = ref(false); +const acceptanceCriteriaToggleError = ref(''); + const form = useForm({ - acceptance_criteria: props.ticket.acceptance_criteria ?? '', + 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 ?? '', @@ -130,6 +147,26 @@ const form = useForm({ story_points: props.ticket.story_points, }); +const acceptanceCriteriaPreviewItems = computed(() => + savedAcceptanceCriteria.value, +); + +const hasUnsavedAcceptanceCriteriaDraft = computed( + () => + acceptanceCriteriaChecklist.value !== + formatChecklistLines(savedAcceptanceCriteria.value), +); + +const acceptanceCriteriaError = computed(() => { + return ( + form.errors.acceptance_criteria ?? + Object.entries(form.errors).find(([key]) => + key.startsWith('acceptance_criteria.'), + )?.[1] ?? + '' + ); +}); + const isCreateBranchDialogOpen = ref(false); const isCheckingBranchName = ref(false); const branchNameValidation = ref(null); @@ -313,11 +350,118 @@ const submitBranchCreation = (): void => { ); }; +const buildAcceptanceCriteriaForSubmit = () => { + const draftItems = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value); + + return draftItems.map((item, index) => ({ + ...item, + ...(savedAcceptanceCriteria.value[index]?.id !== undefined + ? { id: savedAcceptanceCriteria.value[index].id } + : {}), + })); +}; + +const buildSavedTicketPayload = (acceptanceCriteria = savedAcceptanceCriteria.value) => ({ + acceptance_criteria: acceptanceCriteria, + assignee_user_id: props.ticket.assignee_user_id, + author_user_id: props.ticket.author_user_id, + blocked_comment: props.ticket.blocked_comment, + description: props.ticket.description, + field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({ + ticket_field_definition_id: fieldValue.ticket_field_definition_id, + value_number: fieldValue.value_number, + value_user_id: fieldValue.value_user_id, + })), + is_blocked: props.ticket.is_blocked, + parent_ticket_id: props.ticket.parent_ticket_id, + story_points: props.ticket.story_points, + ticket_priority_id: props.ticket.ticket_priority_id, + ticket_type_id: props.ticket.ticket_type_id, + title: props.ticket.title, +}); + +const persistAcceptanceCriteriaToggle = async ( + index: number, + checked: boolean, +): Promise => { + 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 = buildAcceptanceCriteriaForSubmit(); + form.submit( ProjectTicketController.update({ project: props.project.id, @@ -589,22 +733,77 @@ const formatDateTime = (value: string): string => { /> -
- -