From 3ddfcc89f90cd454069daaf2d3f72f50a462864f Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 20:48:59 +0000 Subject: [PATCH 1/7] Add checklist acceptance criteria --- app/Actions/Tickets/CreateProjectTicket.php | 16 +- .../Support/TicketAcceptanceCriteriaData.php | 316 ++++++++++++++++++ app/Actions/Tickets/UpdateProjectTicket.php | 79 ++++- .../Requests/StoreProjectTicketRequest.php | 21 +- .../Requests/UpdateProjectTicketRequest.php | 21 +- app/Http/Resources/TicketResource.php | 7 +- app/Mcp/Support/KanbanGitMcp.php | 11 +- app/Models/Ticket.php | 2 + ...eptance_criteria_data_to_tickets_table.php | 28 ++ resources/js/lib/acceptance-criteria.ts | 146 ++++++++ resources/js/pages/projects/Ticket.vue | 111 +++++- resources/js/pages/projects/TicketCreate.vue | 196 +++++++---- .../types/models/acceptance-criteria-model.ts | 10 + resources/js/types/models/ticket-model.ts | 4 +- tests/Feature/Api/TicketApiTest.php | 30 +- .../Projects/ProjectTicketControllerTest.php | 35 +- 16 files changed, 937 insertions(+), 96 deletions(-) create mode 100644 app/Actions/Tickets/Support/TicketAcceptanceCriteriaData.php create mode 100644 database/migrations/2026_04_24_203500_add_acceptance_criteria_data_to_tickets_table.php create mode 100644 resources/js/lib/acceptance-criteria.ts create mode 100644 resources/js/types/models/acceptance-criteria-model.ts diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index 7ea665a..2f8c551 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\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 + * }, * 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'), diff --git a/app/Actions/Tickets/Support/TicketAcceptanceCriteriaData.php b/app/Actions/Tickets/Support/TicketAcceptanceCriteriaData.php new file mode 100644 index 0000000..9a066f3 --- /dev/null +++ b/app/Actions/Tickets/Support/TicketAcceptanceCriteriaData.php @@ -0,0 +1,316 @@ + + * } + */ + 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 + * } + */ + 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 + * } $acceptanceCriteria + */ + public static function hasContent(array $acceptanceCriteria): bool + { + return $acceptanceCriteria['summary'] !== null + || $acceptanceCriteria['items'] !== []; + } + + /** + * @param array{ + * summary: string|null, + * items: array + * } $acceptanceCriteria + * @return array{ + * summary: string|null, + * items: array + * }|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 + * } $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 + * } + */ + private static function empty(): array + { + return [ + 'summary' => null, + 'items' => [], + ]; + } + + /** + * @param array $value + * @return array{ + * summary: string|null, + * items: array + * } + */ + 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 + * } + */ + 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('/^(?\s*)(?.+)$/u', $line, $matches)) { + return null; + } + + $content = trim((string) Arr::get($matches, 'content', '')); + + if ($content === '') { + return null; + } + + $checked = false; + + if (preg_match('/^(?:[-*+]\s+)?\[(?[xX ])\]\s*(?.+)$/u', $content, $checkboxMatches)) { + $checked = strtolower((string) Arr::get($checkboxMatches, 'state', '')) === 'x'; + $content = trim((string) Arr::get($checkboxMatches, 'text', '')); + } elseif (preg_match('/^[-*+]\s+(?.+)$/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)); + } +} diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php index 6cd6f95..9b21690 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\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 + * }, * assignee_user_id?: int|null, * author_user_id?: int|null, * blocked_comment: string|null, @@ -77,6 +85,14 @@ class UpdateProjectTicket /** @var Collection $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 + * }, * 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 + * }|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 $changes + * @param array $summaryLabels + * @param array{ + * summary: string|null, + * items: array + * } $currentValue + * @param array{ + * summary: string|null, + * items: 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..cf5e886 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\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' => [ diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index fe705ff..a8d08e7 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\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'], diff --git a/app/Http/Resources/TicketResource.php b/app/Http/Resources/TicketResource.php index ff7be1b..e4dee66 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\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 diff --git a/app/Mcp/Support/KanbanGitMcp.php b/app/Mcp/Support/KanbanGitMcp.php index 5f562bb..6f0acec 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\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 + * }, * 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, diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 7c1726c..5936e56 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -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', ]; } diff --git a/database/migrations/2026_04_24_203500_add_acceptance_criteria_data_to_tickets_table.php b/database/migrations/2026_04_24_203500_add_acceptance_criteria_data_to_tickets_table.php new file mode 100644 index 0000000..d74bd70 --- /dev/null +++ b/database/migrations/2026_04_24_203500_add_acceptance_criteria_data_to_tickets_table.php @@ -0,0 +1,28 @@ +json('acceptance_criteria_data')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->dropColumn('acceptance_criteria_data'); + }); + } +}; diff --git a/resources/js/lib/acceptance-criteria.ts b/resources/js/lib/acceptance-criteria.ts new file mode 100644 index 0000000..e3b5243 --- /dev/null +++ b/resources/js/lib/acceptance-criteria.ts @@ -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 = /^(?\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(); + } + } + + 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..b03ec0a 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -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(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,22 +624,64 @@ const formatDateTime = (value: string): string => { /> -
- -