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..0a9c40d --- /dev/null +++ b/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php @@ -0,0 +1,146 @@ + $criteria + * @return array + */ + public static function fromModels(iterable $criteria): 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); + + return $items; + } + + /** + * @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); + + $ticket->acceptanceCriteria()->delete(); + + if ($normalized === []) { + return; + } + + $parentStack = []; + $positionsByParent = []; + + foreach ($normalized as $item) { + $indent = min($item['indent'], count($parentStack)); + $parentId = $indent === 0 ? null : $parentStack[$indent - 1]; + $positionKey = $parentId ?? 'root'; + $position = $positionsByParent[$positionKey] ?? 0; + + $criterion = $ticket->acceptanceCriteria()->create([ + 'parent_acceptance_criterion_id' => $parentId, + 'position' => $position, + 'text' => $item['text'], + 'is_checked' => $item['checked'], + ]); + + $positionsByParent[$positionKey] = $position + 1; + $parentStack[$indent] = $criterion->id; + $parentStack = array_slice($parentStack, 0, $indent + 1); + } + + $ticket->unsetRelation('acceptanceCriteria'); + } + /** + * @param array $items + * @param array> $grouped + */ + private static function flattenItems(array &$items, array $grouped, ?int $parentId, int $indent): void + { + foreach ($grouped[$parentId] ?? [] as $criterion) { + $items[] = [ + 'checked' => $criterion->is_checked, + 'indent' => $indent, + 'text' => $criterion->text, + ]; + + self::flattenItems($items, $grouped, $criterion->id, $indent + 1); + } + } + + /** + * @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; + } + + $items[] = [ + 'checked' => filter_var(Arr::get($rawItem, 'checked', false), FILTER_VALIDATE_BOOLEAN), + 'indent' => self::clampIndent((int) Arr::get($rawItem, 'indent', 0)), + 'text' => $text, + ]; + } + + return $items; + } + + 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..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..4f6aa82 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,11 @@ class StoreProjectTicketRequest extends FormRequest $project = $this->route('project'); return [ - 'acceptance_criteria' => ['nullable', 'string'], + 'acceptance_criteria' => ['nullable', 'array'], + 'acceptance_criteria.*' => ['array'], + '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..32f7290 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,11 @@ class UpdateProjectTicketRequest extends FormRequest $project = $this->route('project'); return [ - 'acceptance_criteria' => ['nullable', 'string'], + 'acceptance_criteria' => ['nullable', 'array'], + 'acceptance_criteria.*' => ['array'], + '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..a10d8f3 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::fromModels( + $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..e08fe36 --- /dev/null +++ b/resources/js/lib/acceptance-criteria.ts @@ -0,0 +1,129 @@ +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) => ({ + 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..84753f7 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,13 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; +const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState( + props.ticket.acceptance_criteria, +); +const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist); + 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 +140,20 @@ const form = useForm({ story_points: props.ticket.story_points, }); +const acceptanceCriteriaPreviewItems = computed(() => + parseChecklistLines(acceptanceCriteriaChecklist.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); @@ -318,6 +342,8 @@ const submit = (): void => { return; } + form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value); + form.submit( ProjectTicketController.update({ project: props.project.id, @@ -589,22 +615,50 @@ const formatDateTime = (value: string): string => { /> -
- -