Add checklist acceptance criteria

This commit is contained in:
2026-04-24 20:48:59 +00:00
parent 68bc5d8915
commit af6fc66e09
16 changed files with 961 additions and 51 deletions
+14 -2
View File
@@ -4,6 +4,7 @@ namespace App\Actions\Tickets;
use App\Actions\Notifications\BuildTicketCreatedPayload; use App\Actions\Notifications\BuildTicketCreatedPayload;
use App\Actions\Notifications\TicketCreated; use App\Actions\Notifications\TicketCreated;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Enums\TicketWorklogEntryType; use App\Enums\TicketWorklogEntryType;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
@@ -22,7 +23,14 @@ class CreateProjectTicket
/** /**
* @param array{ * @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, * assignee_user_id?: int|null,
* description: string|null, * description: string|null,
* story_points?: int|null, * story_points?: int|null,
@@ -35,6 +43,9 @@ class CreateProjectTicket
public function handle(Project $project, array $attributes, ?User $actor = null): Ticket public function handle(Project $project, array $attributes, ?User $actor = null): Ticket
{ {
return DB::transaction(function () use ($actor, $attributes, $project): Ticket { return DB::transaction(function () use ($actor, $attributes, $project): Ticket {
$acceptanceCriteria = TicketAcceptanceCriteriaData::normalize(
Arr::get($attributes, 'acceptance_criteria'),
);
$nextSequenceNumber = (int) Ticket::query() $nextSequenceNumber = (int) Ticket::query()
->where('project_id', $project->id) ->where('project_id', $project->id)
->max('sequence_number') + 1; ->max('sequence_number') + 1;
@@ -43,7 +54,8 @@ class CreateProjectTicket
->findOrFail(Arr::get($attributes, 'ticket_status_id')); ->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$ticket = Ticket::query()->create([ $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'), 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
'author_user_id' => $actor?->id, 'author_user_id' => $actor?->id,
'description' => Arr::get($attributes, 'description'), '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));
}
}
+71 -8
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Tickets; namespace App\Actions\Tickets;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Enums\TicketWorklogEntryType; use App\Enums\TicketWorklogEntryType;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketFieldDefinition; use App\Models\TicketFieldDefinition;
@@ -20,7 +21,14 @@ class UpdateProjectTicket
/** /**
* @param array{ * @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, * assignee_user_id?: int|null,
* author_user_id?: int|null, * author_user_id?: int|null,
* blocked_comment: string|null, * blocked_comment: string|null,
@@ -77,6 +85,14 @@ class UpdateProjectTicket
/** @var Collection<int, TicketFieldValue> $existingFieldValues */ /** @var Collection<int, TicketFieldValue> $existingFieldValues */
$existingFieldValues = $ticket->fieldValues $existingFieldValues = $ticket->fieldValues
->keyBy('ticket_field_definition_id'); ->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 = []; $changes = [];
$summaryLabels = []; $summaryLabels = [];
@@ -97,13 +113,11 @@ class UpdateProjectTicket
$ticket->description, $ticket->description,
$ticketAttributes['description'], $ticketAttributes['description'],
); );
$this->trackScalarChange( $this->trackAcceptanceCriteriaChange(
$changes, $changes,
$summaryLabels, $summaryLabels,
'acceptance_criteria', $currentAcceptanceCriteria,
'acceptance criteria', $nextAcceptanceCriteria,
$ticket->acceptance_criteria,
$ticketAttributes['acceptance_criteria'],
); );
$this->trackScalarChange( $this->trackScalarChange(
$changes, $changes,
@@ -217,7 +231,14 @@ class UpdateProjectTicket
/** /**
* @param array{ * @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, * assignee_user_id?: int|null,
* author_user_id?: int|null, * author_user_id?: int|null,
* blocked_comment: string|null, * blocked_comment: string|null,
@@ -231,6 +252,14 @@ class UpdateProjectTicket
* } $validated * } $validated
* @return array{ * @return array{
* acceptance_criteria: string|null, * 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, * assignee_user_id: int|null,
* author_user_id: int|null, * author_user_id: int|null,
* blocked_comment: string|null, * blocked_comment: string|null,
@@ -245,8 +274,13 @@ class UpdateProjectTicket
*/ */
private function buildTicketAttributes(Ticket $ticket, array $validated): array private function buildTicketAttributes(Ticket $ticket, array $validated): array
{ {
$acceptanceCriteria = TicketAcceptanceCriteriaData::normalize(
Arr::get($validated, 'acceptance_criteria'),
);
return [ 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') 'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
? Arr::get($validated, 'assignee_user_id') ? Arr::get($validated, 'assignee_user_id')
: $ticket->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<string, mixed> $changes
* @param array<int, string> $summaryLabels * @param array<int, string> $summaryLabels
@@ -2,6 +2,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Models\Project; use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
@@ -17,6 +18,18 @@ class StoreProjectTicketRequest extends FormRequest
return $this->user() !== null; 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. * Get the validation rules that apply to the request.
* *
@@ -28,7 +41,13 @@ class StoreProjectTicketRequest extends FormRequest
$project = $this->route('project'); $project = $this->route('project');
return [ 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')], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'ticket_priority_id' => [ 'ticket_priority_id' => [
@@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Enums\Permission; use App\Enums\Permission;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket; use App\Models\Ticket;
@@ -23,6 +24,18 @@ class UpdateProjectTicketRequest extends FormRequest
return $this->user() !== null; 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. * Get the validation rules that apply to the request.
* *
@@ -34,7 +47,13 @@ class UpdateProjectTicketRequest extends FormRequest
$project = $this->route('project'); $project = $this->route('project');
return [ 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')], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'blocked_comment' => ['nullable', 'string'], 'blocked_comment' => ['nullable', 'string'],
+6 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -16,7 +17,11 @@ class TicketResource extends JsonResource
{ {
return [ return [
'id' => $this->id, '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' => $this->whenLoaded(
'assignee', 'assignee',
fn (): ?array => $this->assignee === null fn (): ?array => $this->assignee === null
+9 -2
View File
@@ -3,6 +3,7 @@
namespace App\Mcp\Support; namespace App\Mcp\Support;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\Support\TicketAcceptanceCriteriaData;
use App\Enums\Permission; use App\Enums\Permission;
use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectResource;
use App\Http\Resources\TicketResource; use App\Http\Resources\TicketResource;
@@ -207,7 +208,10 @@ class KanbanGitMcp
/** /**
* @return array{ * @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, * assignee_user_id: int|null,
* author_user_id: int|null, * author_user_id: int|null,
* blocked_comment: string|null, * blocked_comment: string|null,
@@ -223,7 +227,10 @@ class KanbanGitMcp
public function updatePayload(Ticket $ticket): array public function updatePayload(Ticket $ticket): array
{ {
return [ return [
'acceptance_criteria' => $ticket->acceptance_criteria, 'acceptance_criteria' => TicketAcceptanceCriteriaData::fromStored(
$ticket->acceptance_criteria_data,
$ticket->acceptance_criteria,
),
'assignee_user_id' => $ticket->assignee_user_id, 'assignee_user_id' => $ticket->assignee_user_id,
'author_user_id' => $ticket->author_user_id, 'author_user_id' => $ticket->author_user_id,
'blocked_comment' => $ticket->blocked_comment, 'blocked_comment' => $ticket->blocked_comment,
+2
View File
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([ #[Fillable([
'acceptance_criteria', 'acceptance_criteria',
'acceptance_criteria_data',
'assignee_user_id', 'assignee_user_id',
'author_user_id', 'author_user_id',
'blocked_comment', 'blocked_comment',
@@ -37,6 +38,7 @@ class Ticket extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'acceptance_criteria_data' => 'array',
'is_blocked' => 'boolean', 'is_blocked' => 'boolean',
]; ];
} }
@@ -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');
});
}
};
+123 -12
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue'; import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue';
@@ -23,6 +24,11 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import { import {
fieldLabelClass, fieldLabelClass,
frostedCardClass, frostedCardClass,
@@ -53,8 +59,17 @@ const props = defineProps<{
users: UserModel[]; users: UserModel[];
}>(); }>();
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const acceptanceCriteriaSummary = ref(acceptanceCriteriaEditorState.summary);
const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist);
const form = useForm({ const form = useForm({
acceptance_criteria: props.ticket.acceptance_criteria ?? '', acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '', assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '', author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '', blocked_comment: props.ticket.blocked_comment ?? '',
@@ -80,11 +95,32 @@ const form = useForm({
story_points: props.ticket.story_points, 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 submit = (): void => { const submit = (): void => {
if (!props.canEditTicket) { if (!props.canEditTicket) {
return; return;
} }
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
);
form.submit( form.submit(
ProjectTicketController.update({ ProjectTicketController.update({
project: props.project.id, project: props.project.id,
@@ -286,17 +322,92 @@ const submit = (): void => {
<InputError :message="form.errors.description" /> <InputError :message="form.errors.description" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-4">
<Label for="acceptance_criteria" :class="fieldLabelClass"> <div class="grid gap-2">
Acceptance criteria <Label
</Label> for="acceptance_criteria_summary"
<Textarea :class="fieldLabelClass"
id="acceptance_criteria" >
v-model="form.acceptance_criteria" Acceptance criteria summary
:disabled="!canEditTicket || form.processing" </Label>
:class="textareaClass" <Input
/> id="acceptance_criteria_summary"
<InputError :message="form.errors.acceptance_criteria" /> v-model="acceptanceCriteriaSummary"
:disabled="!canEditTicket || form.processing"
placeholder="In one sentence, what defines done?"
:class="inputClass"
/>
</div>
<div class="grid gap-2">
<Label
for="acceptance_criteria_checklist"
:class="fieldLabelClass"
>
Acceptance checklist
</Label>
<Textarea
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
:disabled="!canEditTicket || form.processing"
placeholder="[ ] User can sign in\n[ ] Errors are shown inline\n [x] Nested checks use two-space indent"
:class="textareaClass"
/>
<p class="text-muted-foreground text-xs leading-5">
Each line is a checklist item. Prefix with
<span class="font-semibold">[x]</span> for checked
items and indent with two spaces for nested items.
</p>
</div>
<InputError :message="acceptanceCriteriaError" />
<div
class="dark:bg-muted/25 grid gap-3 rounded-[1.25rem] border border-dashed border-[#adb3b5]/35 bg-[#f8f9fa]/70 p-4 dark:border-white/10"
>
<p
class="text-[0.72rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
>
Checklist preview
</p>
<div
v-if="acceptanceCriteriaPreviewItems.length === 0"
class="text-muted-foreground rounded-xl border border-dashed px-3 py-3 text-sm"
>
Add checklist lines to preview nested items.
</div>
<div v-else class="space-y-2">
<div
v-for="(
checklistItem,
checklistItemIndex
) in acceptanceCriteriaPreviewItems"
:key="checklistItemIndex"
class="flex items-start gap-3"
:style="{
marginLeft: `${checklistItem.indent * 1.25}rem`,
}"
>
<Checkbox
:checked="checklistItem.checked"
disabled
class="mt-0.5"
/>
<span
class="text-sm"
:class="
checklistItem.checked
? 'text-muted-foreground line-through'
: 'text-foreground'
"
>
{{ checklistItem.text }}
</span>
</div>
</div>
</div>
</div> </div>
<div <div
+146
View File
@@ -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)));
};
+132 -19
View File
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed, ref } from 'vue';
import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue'; import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue';
import TicketTypeIcon from '@/components/TicketTypeIcon.vue'; import TicketTypeIcon from '@/components/TicketTypeIcon.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
Card, Card,
CardContent, CardContent,
@@ -24,6 +25,10 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import {
buildAcceptanceCriteria,
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index'; import { index, show } from '@/routes/projects/index';
@@ -79,8 +84,14 @@ const hasTicketMetadata =
props.ticketPriorities.length > 0 && props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0; props.ticketStatusOptions.length > 0;
const acceptanceCriteriaSummary = ref('');
const acceptanceCriteriaChecklist = ref('');
const form = useForm({ const form = useForm({
acceptance_criteria: '', acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
),
assignee_user_id: '', assignee_user_id: '',
description: '', description: '',
story_points: '', story_points: '',
@@ -92,6 +103,22 @@ const form = useForm({
title: '', 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 selectedTicketType = computed( const selectedTicketType = computed(
() => () =>
props.ticketTypes.find( props.ticketTypes.find(
@@ -121,6 +148,11 @@ const selectedAssignee = computed(
); );
const submit = (): void => { const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
);
form.post(`/projects/${props.project.id}/tickets`, { form.post(`/projects/${props.project.id}/tickets`, {
preserveScroll: true, preserveScroll: true,
}); });
@@ -298,25 +330,106 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
/> />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-4">
<Label <div class="grid gap-2">
for="acceptance_criteria" <Label
:class="fieldLabelClass" for="acceptance_criteria_summary"
> :class="fieldLabelClass"
Acceptance criteria >
</Label> Acceptance criteria summary
<Textarea </Label>
id="acceptance_criteria" <Input
v-model="form.acceptance_criteria" id="acceptance_criteria_summary"
:disabled="form.processing" v-model="acceptanceCriteriaSummary"
placeholder="List the conditions that must be true before this ticket is done." :disabled="form.processing"
:class="textareaClass" placeholder="In one sentence, what defines done?"
/> :class="inputClass"
/>
</div>
<div class="grid gap-2">
<Label
for="acceptance_criteria_checklist"
:class="fieldLabelClass"
>
Acceptance checklist
</Label>
<Textarea
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
:disabled="form.processing"
placeholder="[ ] User can sign in\n[ ] Errors are shown inline\n [x] Nested checks use two-space indent"
:class="textareaClass"
/>
<p
class="text-muted-foreground text-xs leading-5"
>
Each line is a checklist item.
Prefix with
<span class="font-semibold"
>[x]</span
>
for checked items and indent with
two spaces for nested items.
</p>
</div>
<InputError <InputError
:message=" :message="acceptanceCriteriaError"
form.errors.acceptance_criteria
"
/> />
<div
class="dark:bg-muted/25 grid gap-3 rounded-[1.25rem] border border-dashed border-[#adb3b5]/35 bg-[#f8f9fa]/70 p-4 dark:border-white/10"
>
<p
class="text-[0.72rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
>
Checklist preview
</p>
<div
v-if="
acceptanceCriteriaPreviewItems.length ===
0
"
class="text-muted-foreground rounded-xl border border-dashed px-3 py-3 text-sm"
>
Add checklist lines to preview
nested items.
</div>
<div v-else class="space-y-2">
<div
v-for="(
checklistItem,
checklistItemIndex
) in acceptanceCriteriaPreviewItems"
:key="checklistItemIndex"
class="flex items-start gap-3"
:style="{
marginLeft: `${checklistItem.indent * 1.25}rem`,
}"
>
<Checkbox
:checked="
checklistItem.checked
"
disabled
class="mt-0.5"
/>
<span
class="text-sm"
:class="
checklistItem.checked
? 'text-muted-foreground line-through'
: 'text-foreground'
"
>
{{ checklistItem.text }}
</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -0,0 +1,10 @@
export type AcceptanceCriteriaItemModel = {
checked: boolean;
indent: number;
text: string;
};
export type AcceptanceCriteriaModel = {
summary: string | null;
items: AcceptanceCriteriaItemModel[];
};
+3 -1
View File
@@ -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 { TicketFieldValueModel } from '@/types/models/ticket-field-value-model';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model'; import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-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 = { export type TicketModel = {
id: number; id: number;
acceptance_criteria: string | null; acceptance_criteria: AcceptanceCriteriaModel;
acceptance_criteria_text: string | null;
assignee: UserModel | null; assignee: UserModel | null;
assignee_user_id: number | null; assignee_user_id: number | null;
author: UserModel | null; author: UserModel | null;
+28 -2
View File
@@ -43,7 +43,21 @@ class TicketApiTest extends TestCase
$createResponse = $this->withApplicationKey($user) $createResponse = $this->withApplicationKey($user)
->postJson(route('api.projects.tickets.store', $project), [ ->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', 'description' => 'Build the API-created ticket',
'story_points' => 3, 'story_points' => 3,
'ticket_priority_id' => $priority->id, 'ticket_priority_id' => $priority->id,
@@ -53,6 +67,8 @@ class TicketApiTest extends TestCase
]) ])
->assertCreated() ->assertCreated()
->assertJsonPath('data.ticket_key', 'CORE-1') ->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.'); ->assertJsonPath('meta.message', 'CORE-1 was created.');
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id')); $ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
@@ -64,7 +80,16 @@ class TicketApiTest extends TestCase
$this->withApplicationKey($user) $this->withApplicationKey($user)
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ ->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, 'blocked_comment' => null,
'description' => 'Updated through the API', 'description' => 'Updated through the API',
'is_blocked' => false, 'is_blocked' => false,
@@ -76,6 +101,7 @@ class TicketApiTest extends TestCase
]) ])
->assertOk() ->assertOk()
->assertJsonPath('data.title', 'Updated API ticket') ->assertJsonPath('data.title', 'Updated API ticket')
->assertJsonPath('data.acceptance_criteria.items.0.checked', true)
->assertJsonPath('meta.message', 'CORE-1 was updated.'); ->assertJsonPath('meta.message', 'CORE-1 was updated.');
$this->withApplicationKey($user) $this->withApplicationKey($user)
@@ -56,7 +56,10 @@ class ProjectTicketControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
$legacyAcceptanceCriteria = "Review checklist\n- [ ] User can sign in\n - [x] Nested verification";
$ticket = Ticket::factory()->create([ $ticket = Ticket::factory()->create([
'acceptance_criteria' => $legacyAcceptanceCriteria,
'assignee_user_id' => $assignee->id, 'assignee_user_id' => $assignee->id,
'author_user_id' => $author->id, 'author_user_id' => $author->id,
'project_id' => $project->id, 'project_id' => $project->id,
@@ -107,6 +110,14 @@ class ProjectTicketControllerTest extends TestCase
->where('project.show_author', false) ->where('project.show_author', false)
->where('ticket.ticket_key', 'CORE-1') ->where('ticket.ticket_key', 'CORE-1')
->where('ticket.story_points', 5) ->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.assignee.name', 'Assigned User')
->where('ticket.author', null) ->where('ticket.author', null)
->where('ticket.author_user_id', null) ->where('ticket.author_user_id', null)
@@ -463,9 +474,25 @@ class ProjectTicketControllerTest extends TestCase
$this->syncProjectPermissions($project, $editor, ['write', 'view']); $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) $this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [ ->patch(route('projects.tickets.update', [$project, $ticket]), [
'acceptance_criteria' => 'Updated acceptance criteria', 'acceptance_criteria' => $updatedAcceptanceCriteria,
'assignee_user_id' => $assignee->id, 'assignee_user_id' => $assignee->id,
'blocked_comment' => $ticket->blocked_comment, 'blocked_comment' => $ticket->blocked_comment,
'description' => 'Updated description', 'description' => 'Updated description',
@@ -493,12 +520,16 @@ class ProjectTicketControllerTest extends TestCase
'id' => $ticket->id, 'id' => $ticket->id,
'title' => 'Updated title', 'title' => 'Updated title',
'description' => 'Updated description', '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, 'assignee_user_id' => $assignee->id,
'story_points' => 8, 'story_points' => 8,
'ticket_type_id' => $this->parentType($project)->id, 'ticket_type_id' => $this->parentType($project)->id,
'ticket_priority_id' => $highPriority->id, 'ticket_priority_id' => $highPriority->id,
]); ]);
$ticket->refresh();
$this->assertEquals($updatedAcceptanceCriteria, $ticket->acceptance_criteria_data);
$this->assertDatabaseHas('ticket_field_values', [ $this->assertDatabaseHas('ticket_field_values', [
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'ticket_field_definition_id' => $reviewerField->id, 'ticket_field_definition_id' => $reviewerField->id,