feat(KAN-32): add hierarchical acceptance criteria checklist #127

Merged
Twyco merged 1 commits from feature/KAN-32-akzeptanzkriterien-checkliste into main 2026-09-14 09:45:39 +00:00
13 changed files with 855 additions and 256 deletions
@@ -0,0 +1,320 @@
<script setup lang="ts">
import {
ClipboardCheck,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from 'lucide-vue-next';
import { computed, nextTick, ref, useId, watch } from 'vue';
import { Button } from '@/components/ui/button';
import InputError from '@/components/InputError.vue';
import { useLocalization } from '@/composables/useLocalization';
import {
canIndentAcceptanceCriterion,
indentAcceptanceCriterion,
removeAcceptanceCriterion,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
const props = withDefaults(
defineProps<{
modelValue: AcceptanceCriteriaModel;
readonly?: boolean;
disabled?: boolean;
persistToggles?: boolean;
allowEditing?: boolean;
saving?: boolean;
dirty?: boolean;
error?: string;
}>(),
{
readonly: false,
disabled: false,
persistToggles: false,
allowEditing: true,
saving: undefined,
dirty: false,
error: '',
},
);
const emit = defineEmits<{
'update:modelValue': [items: AcceptanceCriteriaModel];
toggle: [index: number, checked: boolean];
}>();
const { t } = useLocalization();
const id = useId();
const root = ref<HTMLElement>();
const editing = ref(!props.persistToggles);
const completed = computed(
() => props.modelValue.filter((item) => item.checked).length,
);
const locked = computed(() => props.readonly || props.disabled);
const update = (items: AcceptanceCriteriaModel): void => {
if (!locked.value) {
emit('update:modelValue', items);
}
};
const focusRow = async (index: number): Promise<void> => {
await nextTick();
const inputs =
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
if (inputs?.[index]) {
inputs[index].focus();
} else {
root.value
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
?.focus();
}
};
const add = (): void => {
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
void focusRow(props.modelValue.length);
};
const remove = (index: number): void => {
update(removeAcceptanceCriterion(props.modelValue, index));
void focusRow(Math.max(0, index - 1));
};
watch(
() => props.modelValue,
async () => {
await nextTick();
root.value
?.querySelectorAll<HTMLTextAreaElement>('textarea')
.forEach((input) => {
input.setCustomValidity(
input.value.trim() === '' ? t('criteria.required') : '',
);
});
},
);
const changeText = (index: number, event: Event): void => {
const text = (event.target as HTMLTextAreaElement).value;
update(
props.modelValue.map((item, position) =>
position === index ? { ...item, text } : item,
),
);
};
const toggle = (index: number, event: Event): void => {
const target = event.target as HTMLInputElement;
const checked = target.checked;
target.checked = props.modelValue[index].checked;
if (locked.value) {
return;
}
if (props.persistToggles && !editing.value && !props.dirty) {
emit('toggle', index, checked);
} else {
update(
syncAcceptanceCriteriaDescendantCheckedState(
props.modelValue,
index,
checked,
),
);
}
};
const onKeydown = (index: number, event: KeyboardEvent): void => {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
return;
}
event.preventDefault();
update(
indentAcceptanceCriterion(
props.modelValue,
index,
event.key === 'ArrowRight' ? 1 : -1,
),
);
};
</script>
<template>
<section
ref="root"
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
:aria-labelledby="`${id}-title`"
:aria-busy="disabled"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3
:id="`${id}-title`"
class="flex items-center gap-2 font-semibold"
>
<ClipboardCheck
class="text-primary size-5"
aria-hidden="true"
/>
{{ t('criteria.title') }}
</h3>
<span class="text-muted-foreground text-sm" aria-live="polite">{{
t('criteria.progress', { completed, total: modelValue.length })
}}</span>
<Button
v-if="persistToggles && allowEditing && !readonly"
type="button"
variant="outline"
:disabled="
disabled ||
(editing &&
modelValue.some((item) => item.text.trim() === ''))
"
:aria-pressed="editing"
@click="editing = !editing"
>
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
</Button>
</div>
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
{{ t('criteria.empty') }}
</p>
<ol v-else class="space-y-3">
<li
v-for="(item, index) in modelValue"
:key="item.id ?? `new-${index}`"
:data-indent="item.indent"
class="border-border min-w-0"
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
>
<div class="flex min-w-0 items-start gap-2">
<label
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
:class="{ 'cursor-not-allowed opacity-60': locked }"
>
<input
:checked="item.checked"
type="checkbox"
:disabled="locked"
:aria-label="
t('criteria.complete', {
number: index + 1,
text: item.text,
})
"
class="border-input accent-primary size-5 rounded"
@change="toggle(index, $event)"
/>
</label>
<div class="min-w-0 flex-1">
<textarea
v-if="editing && !readonly"
:value="item.text"
:aria-label="
t('criteria.item', { number: index + 1 })
"
:aria-describedby="`${id}-hint ${id}-error`"
:disabled="disabled"
:aria-invalid="Boolean(error)"
required
rows="2"
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
@input="changeText(index, $event)"
@keydown="onKeydown(index, $event)"
/>
<p
v-else
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
:class="{
'text-muted-foreground line-through':
item.checked,
}"
>
{{ item.text }}
</p>
<div
v-if="editing && !readonly"
class="flex flex-wrap gap-1"
>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled || item.indent === 0"
:aria-label="
t('criteria.outdent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
-1,
),
)
"
><IndentDecrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="
disabled ||
!canIndentAcceptanceCriterion(
modelValue,
index,
)
"
:aria-label="
t('criteria.indent', { number: index + 1 })
"
@click="
update(
indentAcceptanceCriterion(
modelValue,
index,
1,
),
)
"
><IndentIncrease aria-hidden="true"
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="disabled"
:aria-label="
t('criteria.remove', { number: index + 1 })
"
@click="remove(index)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</div>
</div>
</li>
</ol>
<template v-if="editing && !readonly">
<Button
data-add-criterion
type="button"
variant="outline"
:disabled="disabled"
@click="add"
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
>
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
{{ t('criteria.hint') }}
</p>
</template>
<p
v-if="persistToggles && dirty"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.unsaved') }}
</p>
<p
v-if="saving ?? disabled"
class="text-muted-foreground text-sm"
role="status"
>
{{ t('criteria.saving') }}
</p>
<InputError :id="`${id}-error`" :message="error" role="alert" />
</section>
</template>
@@ -6,6 +6,7 @@ import { useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref, toRef, unref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
@@ -13,9 +14,6 @@ import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
@@ -28,18 +26,12 @@ const canEditAuthor = computed(
const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject,
);
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: acceptanceCriteriaEditorState.checklist },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
`${draftKey}:criteria`,
);
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
@@ -47,8 +39,8 @@ const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const savedFormData = () => ({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
acceptance_criteria: normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
@@ -77,14 +69,10 @@ const savedFormData = () => ({
const form = useForm(draftKey, savedFormData());
const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
);
const hasUnsavedAcceptanceCriteriaDraft = computed(
() =>
acceptanceCriteriaChecklist.value !==
formatChecklistLines(savedAcceptanceCriteria.value),
JSON.stringify(acceptanceCriteriaItems.value) !==
JSON.stringify(savedAcceptanceCriteria.value),
);
const isDirty = computed(
@@ -103,9 +91,10 @@ const reset = (): void => {
}
form.reset();
form.clearErrors();
acceptanceCriteriaChecklist.value = formatChecklistLines(
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
acceptanceCriteriaToggleError.value = '';
protection.clearFeedback();
};
@@ -119,19 +108,6 @@ const acceptanceCriteriaError = computed(() => {
);
});
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,
) => ({
@@ -171,10 +147,6 @@ const persistAcceptanceCriteriaToggle = async (
index,
checked,
);
const previousChecklist = formatChecklistLines(
savedAcceptanceCriteria.value,
);
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
@@ -209,34 +181,18 @@ const persistAcceptanceCriteriaToggle = async (
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
if (acceptanceCriteriaChecklist.value === previousChecklist) {
acceptanceCriteriaChecklist.value = nextChecklist;
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
}
} catch (_error) {
acceptanceCriteriaToggleError.value = t(
'ticketDetail.checklistSaveFailed',
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
nextAcceptanceCriteria,
);
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
} catch (_error) {
acceptanceCriteriaToggleError.value = t('criteria.saveError');
} finally {
isPersistingAcceptanceCriteriaToggle.value = false;
}
};
const onAcceptanceCriteriaCheckboxChange = async (
index: number,
event: Event,
): Promise<void> => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
await persistAcceptanceCriteriaToggle(index, target.checked);
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
};
const submit = (): void => {
if (
!props.canEditTicket ||
@@ -246,7 +202,7 @@ const submit = (): void => {
return;
}
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
form.acceptance_criteria = acceptanceCriteriaItems.value;
protection.submit(
ProjectTicketController.update({
@@ -259,7 +215,7 @@ const submit = (): void => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
);
acceptanceCriteriaChecklist.value = formatChecklistLines(
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
Object.assign(form, savedFormData());
@@ -301,82 +257,25 @@ const submit = (): void => {
</CardContent>
</Card>
</section>
<section
id="ticket-checklist"
aria-labelledby="ticket-checklist-title"
class="scroll-mt-6"
>
<Card>
<CardHeader
><h2
id="ticket-checklist-title"
class="text-lg font-semibold"
>
{{ t('ticketDetail.checklist') }}
</h2></CardHeader
>
<CardContent class="space-y-3">
<div
v-if="acceptanceCriteriaPreviewItems.length > 0"
class="border-border rounded-xl border p-4 text-sm"
>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="item.id ?? index"
:style="{
marginInlineStart: `${item.indent * 0.75}rem`,
}"
class="flex min-w-0 items-start gap-2"
>
<input
:id="`acceptance-criteria-${item.id ?? index}`"
:checked="item.checked"
type="checkbox"
:disabled="
!canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
@change="
onAcceptanceCriteriaCheckboxChange(
index,
$event,
)
"
/>
<label
:for="`acceptance-criteria-${item.id ?? index}`"
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
>
{{ item.text }}
</label>
</li>
</ul>
</div>
<p v-else class="text-muted-foreground text-sm">
{{ t('ticketDetail.noChecklist') }}
</p>
<p
v-if="hasUnsavedAcceptanceCriteriaDraft"
class="text-muted-foreground text-sm"
>
{{ t('ticketDetail.saveChecklistFirst') }}
</p>
<p
v-if="acceptanceCriteriaToggleError"
role="alert"
class="text-destructive text-sm"
>
{{ acceptanceCriteriaToggleError }}
</p>
</CardContent>
</Card>
</section>
<div id="ticket-checklist" class="scroll-mt-6">
<AcceptanceCriteriaChecklist
:model-value="savedAcceptanceCriteria"
:readonly="!canEditTicket"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
:saving="
form.processing || isPersistingAcceptanceCriteriaToggle
"
:allow-editing="false"
persist-toggles
:dirty="hasUnsavedAcceptanceCriteriaDraft"
:error="acceptanceCriteriaToggleError"
@toggle="persistAcceptanceCriteriaToggle"
/>
</div>
<details
v-if="canEditTicket"
id="ticket-edit"
@@ -416,20 +315,13 @@ const submit = (): void => {
"
>
<template #content>
<TicketFormField
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
kind="textarea"
:label="t('ticketDetail.checklistEditor')"
:hint="t('ticketForm.checklistHelp')"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
:error="acceptanceCriteriaError"
<AcceptanceCriteriaChecklist
v-model="acceptanceCriteriaItems"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
:error="acceptanceCriteriaError"
/>
</template>
<template #metadata>
+70
View File
@@ -5,6 +5,76 @@ import type {
const MAX_INDENT_LEVEL = 8;
export const canIndentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): boolean => {
const item = items[index];
const previous = items[index - 1];
if (!item || !previous || item.indent > previous.indent) {
return false;
}
return items
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
};
const acceptanceCriteriaSubtreeEnd = (
items: AcceptanceCriteriaModel,
index: number,
): number => {
let end = index + 1;
while (end < items.length && items[end].indent > items[index].indent) {
end++;
}
return end;
};
export const indentAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
direction: 1 | -1,
): AcceptanceCriteriaModel => {
if (
!items[index] ||
(direction === 1
? !canIndentAcceptanceCriterion(items, index)
: items[index].indent === 0)
) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.map((item, position) =>
position >= index && position < end
? { ...item, indent: item.indent + direction }
: item,
);
};
/** Remove only the selected criterion, promoting its children without losing them. */
export const removeAcceptanceCriterion = (
items: AcceptanceCriteriaModel,
index: number,
): AcceptanceCriteriaModel => {
if (!items[index]) {
return items;
}
const end = acceptanceCriteriaSubtreeEnd(items, index);
return items.flatMap((item, position) =>
position === index
? []
: [
{
...item,
indent:
position > index && position < end
? item.indent - 1
: item.indent,
},
],
);
};
export type AcceptanceCriteriaEditorState = {
checklist: string;
};
+19
View File
@@ -1,4 +1,23 @@
export const en = {
'criteria.title': 'Acceptance criteria',
'criteria.progress': '{completed} of {total} complete',
'criteria.empty': 'No acceptance criteria yet.',
'criteria.required': 'Enter a criterion or remove this row.',
'criteria.edit': 'Edit checklist',
'criteria.preview': 'Preview checklist',
'criteria.item': 'Criterion {number}',
'criteria.complete': 'Complete criterion {number}: {text}',
'criteria.indent': 'Indent criterion {number}',
'criteria.outdent': 'Outdent criterion {number}',
'criteria.remove': 'Remove criterion {number}',
'criteria.add': 'Add criterion',
'criteria.hint':
'Use the indent buttons or Alt + Left/Right in a text field to nest criteria. Checking a parent also checks its children. Removing a parent keeps its children.',
'criteria.unsaved': 'Checklist changes will be saved with the ticket.',
'criteria.saving': 'Saving checklist…',
'criteria.saveError':
'Checklist status could not be saved. Please try again.',
'board.title': '{name} board',
'board.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
+19 -71
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
import { computed, toRef, unref } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { useSessionStorage } from '@vueuse/core';
import { computed } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { useLocalization } from '@/composables/useLocalization';
import FormFeedback from '@/components/FormFeedback.vue';
@@ -17,10 +17,8 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import {
buildAcceptanceCriteria,
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index';
@@ -71,19 +69,9 @@ const hasTicketMetadata = computed(
);
const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: '' },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
const form = useForm(`${draftKey}:${draftGeneration.value}`, {
acceptance_criteria: [] as AcceptanceCriteriaModel,
assignee_user_id: '',
description: '',
parent_ticket_id: '',
@@ -96,24 +84,17 @@ const form = useForm(draftKey, {
title: '',
});
const isDirty = computed(
() => form.isDirty || acceptanceCriteriaChecklist.value !== '',
);
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
const isDirty = computed(() => form.isDirty);
const protection = useFormProtection(form);
const reset = (): void => {
if (form.processing || !protection.confirmLeave()) {
return;
}
form.reset();
form.clearErrors();
acceptanceCriteriaChecklist.value = '';
protection.clearFeedback();
};
const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value),
);
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
@@ -128,17 +109,19 @@ const submit = (): void => {
if (!hasTicketMetadata.value || form.processing) {
return;
}
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
protection.submit(
ProjectTicketController.store({ project: props.project.id }),
{
preserveScroll: true,
onSuccess: () => {
// The source page is unmounted before this callback, so its storage watcher has stopped.
window.sessionStorage.setItem(
`${draftKey}:generation`,
String(draftGeneration.value + 1),
);
draftGeneration.value++;
form.reset();
acceptanceCriteriaChecklist.value = '';
},
},
);
@@ -224,46 +207,11 @@ const submit = (): void => {
autofocus
>
<template #content>
<TicketFormField
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
kind="textarea"
:label="t('ticketDetail.checklistEditor')"
:hint="t('ticketForm.checklistHelp')"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
:error="acceptanceCriteriaError"
<AcceptanceCriteriaChecklist
v-model="form.acceptance_criteria"
:disabled="form.processing"
:error="acceptanceCriteriaError"
/>
<div
v-if="acceptanceCriteriaPreviewItems.length"
class="bg-muted/30 min-w-0 rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
{{ t('ticketForm.checklistPreview') }}
</p>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="index"
:style="{
marginInlineStart: `${item.indent * 0.75}rem`,
}"
class="flex min-w-0 items-start gap-2"
>
<span aria-hidden="true">{{
item.checked ? '☑' : '☐'
}}</span>
<span
class="wrap-anywhere min-w-0"
>{{ item.text }}</span
>
</li>
</ul>
</div>
</template>
</TicketFormFields>
<div
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class AcceptanceCriteriaChecklistTest extends TestCase
{
public function test_checklist_preserves_hierarchy_identity_and_accessible_controls(): void
{
$process = new Process(['node', 'tests/Fixtures/acceptance-criteria.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Acceptance criteria behavior checks passed.', $process->getOutput());
}
}
@@ -0,0 +1,182 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4176';
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
hasTouch: true,
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
page.on('dialog', (dialog) => dialog.accept());
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const checklist = () =>
page
.locator('form')
.first()
.getByRole('region', { name: 'Acceptance criteria' });
const savedChecklist = () => page.locator('#ticket-checklist');
const field = (number) =>
page.getByRole('textbox', { name: `Criterion ${number}`, exact: true });
const button = (name) => page.getByRole('button', { name, exact: true });
const setResult = (value) =>
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
const waitState = (value) =>
page.locator(`[data-form-state="${value}"]`).first().waitFor();
const save = async () => {
await button('Save changes').click();
await waitState('success');
};
try {
await page.goto(`${base}/projects/7/tickets/31`);
await savedChecklist().waitFor();
const boxes = savedChecklist().getByRole('checkbox');
await setResult('server');
await boxes.first().focus();
await page.keyboard.press('Space');
await page
.getByText('Checklist status could not be saved. Please try again.')
.waitFor();
assert.equal(
await boxes.first().isChecked(),
false,
'Failure must restore saved checkbox',
);
await setResult('success');
let payload;
page.on('request', (request) => {
if (request.method() === 'PATCH') {
payload = request.postDataJSON();
}
});
await boxes.first().tap();
await page.waitForFunction(
() =>
document.querySelectorAll('section[aria-busy="true"]').length === 0,
);
await savedChecklist().getByText('3 of 4 complete').waitFor();
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.checked),
[true, true, true, false],
);
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.id),
[11, 12, 13, 14],
);
await page.locator('#ticket-edit > summary').click();
await field(2).fill('Edited child');
await field(2).press('Alt+ArrowLeft');
assert.deepEqual(
await checklist()
.locator('li')
.evaluateAll((items) =>
items.map((item) => Number(item.dataset.indent)),
),
[0, 0, 1, 0],
);
await button('Indent criterion 2').tap();
await button('Remove criterion 1').tap();
assert.equal(await field(1).inputValue(), 'Edited child');
assert.equal(
await field(1).evaluate((el) => el === document.activeElement),
true,
);
await button('Add criterion').focus();
await page.keyboard.press('Enter');
assert.equal(
await field(4).evaluate((el) => el === document.activeElement),
true,
);
await field(4).fill('New criterion');
await button('Indent criterion 4').click();
await setResult('validation');
await button('Save changes').click();
await waitState('validation');
assert.equal(await field(1).inputValue(), 'Edited child');
await setResult('success');
await save();
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.id),
[12, 13, 14, undefined],
);
assert.deepEqual(
payload.acceptance_criteria.map((item) => item.indent),
[0, 1, 0, 1],
);
await page.reload();
await page.locator('#ticket-edit > summary').click();
assert.equal(await field(1).inputValue(), 'Edited child');
assert.equal(await field(4).inputValue(), 'New criterion');
// Blank/whitespace text cannot silently delete a persisted criterion.
await field(1).fill(' ');
assert.equal(await field(1).evaluate((el) => el.checkValidity()), false);
const beforeInvalidSave = payload;
await button('Save changes').click();
assert.equal(payload, beforeInvalidSave);
await button('Discard changes').click();
assert.equal(await field(1).inputValue(), 'Edited child');
assert.equal(await field(1).evaluate((el) => el.checkValidity()), true);
for (const width of [1280, 390, 320]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await page.evaluate(
(theme) =>
document.documentElement.classList.toggle(
'dark',
theme === 'dark',
),
theme,
);
assert.equal(
await checklist().evaluate(
(el) => el.scrollWidth <= el.clientWidth,
),
true,
);
await checklist().screenshot({
path: `/private/tmp/kan32-checklist-${width}-${theme}.png`,
});
await button('Outdent criterion 4').tap();
await button('Indent criterion 4').tap();
}
}
await page.goto(`${base}/projects/7/tickets/31?readonly=1`);
await savedChecklist().waitFor();
assert.equal(await savedChecklist().getByRole('textbox').count(), 0);
assert.equal(await savedChecklist().getByRole('button').count(), 0);
assert.equal(
await savedChecklist().getByRole('checkbox').first().isDisabled(),
true,
);
await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('Created with criteria');
for (const text of ['First', 'Second']) {
await button('Add criterion').click();
await page.locator('textarea').last().fill(text);
}
await button('Indent criterion 2').tap();
await checklist().getByRole('checkbox').first().focus();
await page.keyboard.press('Space');
const request = page.waitForRequest(
(request) =>
request.method() === 'POST' && request.url().endsWith('/tickets'),
);
await button('Create ticket').click();
const created = (await request).postDataJSON();
assert.deepEqual(created.acceptance_criteria, [
{ text: 'First', checked: true, indent: 0 },
{ text: 'Second', checked: true, indent: 1 },
]);
await page.getByRole('heading', { name: 'Destination' }).waitFor();
assert.deepEqual(errors, []);
console.log(
'Acceptance criteria browser checks passed: persistence, errors, keyboard, touch, readonly, creation, mobile and themes.',
);
} finally {
await browser.close();
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/acceptance-criteria-fixture',
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
});
try {
const {
canIndentAcceptanceCriterion: canIndent,
indentAcceptanceCriterion: indent,
removeAcceptanceCriterion: remove,
syncAcceptanceCriteriaDescendantCheckedState: toggle,
normalizeAcceptanceCriteria: normalize,
} = await server.ssrLoadModule('/resources/js/lib/acceptance-criteria.ts');
const items = [
{ id: 11, text: 'Parent', indent: 0, checked: false },
{ id: 12, text: 'Child', indent: 1, checked: true },
{ id: 13, text: 'Grandchild', indent: 2, checked: false },
{ id: 14, text: 'Next', indent: 0, checked: false },
];
const original = structuredClone(items);
assert.equal(canIndent(items, 0), false);
assert.equal(canIndent(items, 1), false);
assert.equal(canIndent(items, 3), true);
assert.deepEqual(
indent(items, 1, -1).map((item) => item.indent),
[0, 0, 1, 0],
);
assert.deepEqual(indent(indent(items, 1, -1), 1, 1), items);
assert.deepEqual(indent(items, 0, -1), items);
assert.deepEqual(indent(items, -1, 1), items);
assert.deepEqual(remove(items, 0), [
{ ...items[1], indent: 0 },
{ ...items[2], indent: 1 },
items[3],
]);
assert.deepEqual(remove(items, 1), [
items[0],
{ ...items[2], indent: 1 },
items[3],
]);
assert.deepEqual(remove(items, 99), items);
assert.deepEqual(
toggle(items, 0, true).map((item) => item.checked),
[true, true, true, false],
);
assert.deepEqual(
toggle(items, 1, false).map((item) => item.checked),
[false, false, false, false],
);
const deep = Array.from({ length: 9 }, (_, indent) => ({
text: 'Deep',
checked: false,
indent,
}));
assert.equal(
canIndent([{ text: 'Before', indent: 0, checked: false }, ...deep], 1),
false,
);
assert.deepEqual(
normalize(remove(items, 0)).map((item) => item.id),
[12, 13, 14],
);
assert.deepEqual(items, original, 'Editing must not mutate saved criteria');
const { default: Checklist } = await server.ssrLoadModule(
'/resources/js/components/tickets/AcceptanceCriteriaChecklist.vue',
);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const render = (props) =>
renderToString(
createSSRApp({ render: () => h(Checklist, props) }).use(
createLocalization(),
),
);
const editable = await render({ modelValue: items });
assert.equal((editable.match(/<textarea/g) ?? []).length, 4);
assert.match(editable, /Indent criterion 4/);
assert.match(editable, /1 of 4 complete/);
const readonly = await render({ modelValue: items, readonly: true });
assert.doesNotMatch(readonly, /<textarea|Add criterion|Edit checklist/);
assert.equal((readonly.match(/type="checkbox" disabled/g) ?? []).length, 4);
assert.match(
await render({ modelValue: [], error: 'Server validation' }),
/Server validation/,
);
console.log('Acceptance criteria behavior checks passed.');
} finally {
await server.close();
}
+18 -10
View File
@@ -320,15 +320,18 @@ try {
await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('New ticket draft');
await page.locator('#description').fill('Keep this description');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Keep this checklist');
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.fill('Keep this checklist');
await setResult('validation');
await page.locator('form button[type="submit"]').first().click();
await state('validation');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
await page
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Keep this checklist',
);
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
await page.locator('form button[type="submit"]').first().click();
@@ -349,8 +352,10 @@ try {
await page.locator('#title').waitFor();
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
await page
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Keep this checklist',
);
await setResult('success');
await page.locator('form button[type="submit"]').first().click();
@@ -359,9 +364,10 @@ try {
await page.goto(`${base}/projects/7/tickets/31`);
await page.locator('#ticket-edit > summary').click();
await page.locator('#title').fill('Updated ticket draft');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Edited checklist');
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.fill('Edited checklist');
await setResult('server');
await page.locator('form button[type="submit"]').first().click();
await state('server');
@@ -370,8 +376,10 @@ try {
'Updated ticket draft',
);
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Edited checklist',
await page
.getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Edited checklist',
);
await setResult('success');
await page.getByRole('button', { name: 'Try again' }).click();
+15 -2
View File
@@ -1,4 +1,5 @@
import { fileURLToPath } from 'node:url';
import process from 'node:process';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
@@ -36,7 +37,19 @@ let ticket = {
ticket_status: status,
is_blocked: false,
story_points: 0,
acceptance_criteria: [],
acceptance_criteria: process.env.CRITERIA_FIXTURE
? [
{ id: 11, text: 'Parent criterion', indent: 0, checked: false },
{ id: 12, text: 'Nested criterion', indent: 1, checked: true },
{ id: 13, text: 'Deep criterion', indent: 2, checked: false },
{
id: 14,
text: 'Independent criterion',
indent: 0,
checked: false,
},
]
: [],
field_values: [],
worklog_entries: [],
created_at: '2026-09-10T12:00:00Z',
@@ -125,7 +138,7 @@ server.middlewares.use(async (request, response, next) => {
ticketStatusOptions: [status],
users: [],
parentTicketOptions: [],
canEditTicket: true,
canEditTicket: !url.searchParams.has('readonly'),
canManageProject: true,
childTicketTree: [],
linkedTickets: [],
+24 -13
View File
@@ -105,22 +105,30 @@ try {
await page.keyboard.press('Enter');
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
await page
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
.check();
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page.waitForFunction(
() => document.querySelector('#acceptance-criteria-11').checked,
() =>
document.querySelectorAll(
'#ticket-checklist input[type=checkbox]',
)[1].checked,
);
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft',
);
await page
.getByLabel('Acceptance criteria checklist', { exact: true })
.fill('[ ] A draft checklist item');
.getByLabel('Criterion 1', { exact: true })
.fill('A draft checklist item');
assert.equal(
await page
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Cache requests safely',
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.isDisabled(),
@@ -155,10 +163,8 @@ try {
'Unsaved title draft',
);
assert.equal(
await page
.getByLabel('Acceptance criteria checklist', { exact: true })
.inputValue(),
'[ ] A draft checklist item',
await page.getByLabel('Criterion 1', { exact: true }).inputValue(),
'A draft checklist item',
);
await visit('?reject');
await edit().click();
@@ -196,16 +202,21 @@ try {
await page.getByText('Ticket updated.', { exact: true }).waitFor();
await visit('?rejectToggle');
await page
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
.check();
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page
.getByRole('alert')
.filter({ hasText: 'Checklist status could not be saved' })
.waitFor();
assert.equal(
await page
.locator('#ticket-checklist')
.getByRole('checkbox', {
name: 'Cache requests safely',
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.isChecked(),
+7 -3
View File
@@ -478,7 +478,6 @@ if (serve) {
'story_points',
'parent_ticket_id',
'description',
'acceptance_criteria_checklist',
'is_blocked',
'blocked_comment',
'field-1',
@@ -489,6 +488,8 @@ if (serve) {
`Missing editor: ${field}`,
);
}
assert.match(html, /aria-label="Criterion 1"/);
assert.match(html, /Add criterion/);
assert.match(html, /Create branch/);
assert.match(html, /Save link/);
assert.match(html, /Remove/);
@@ -505,7 +506,7 @@ if (serve) {
const empty = await render('?empty');
for (const text of [
'No description has been added yet.',
'No acceptance criteria have been added yet.',
'No acceptance criteria yet.',
'No status',
'Unassigned',
'No author',
@@ -540,6 +541,10 @@ if (serve) {
assert.match(unavailable, /Ticket creation needs at least one type/);
for (const creating of [false, true]) {
const invalid = await render('?rejected&allErrors', creating);
assert.match(
invalid,
/aria-describedby="[^"]+-hint [^"]+-error"|No acceptance criteria yet/,
);
for (const id of [
'title',
'description',
@@ -548,7 +553,6 @@ if (serve) {
'assignee_user_id',
'parent_ticket_id',
'story_points',
'acceptance_criteria_checklist',
]) {
assert.ok(invalid.includes(`id="${id}-error"`));
}
+19 -9
View File
@@ -10,7 +10,7 @@ page.setDefaultTimeout(10000);
const base = 'http://127.0.0.1:4185/projects/7/tickets';
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const field = (label) => page.getByLabel(label, { exact: true });
const field = (label) => form().getByLabel(label, { exact: true });
const form = () => page.locator('form').first();
const state = (value) =>
form().locator(`[data-form-state="${value}"]`).waitFor();
@@ -29,9 +29,19 @@ async function fill(create) {
await field('Assignee').selectOption('2');
await field('Parent ticket').selectOption('40');
await field('Estimated story points').fill('0');
await field('Acceptance criteria checklist').fill(
'[ ] Main criterion\n [x] Nested criterion',
);
if (create) {
await button('Add criterion').click();
await button('Add criterion').click();
await button('Indent criterion 2').click();
}
await field('Criterion 1').fill('Main criterion');
await field('Criterion 2').fill('Nested criterion');
await form()
.getByRole('checkbox', {
name: 'Complete criterion 2: Nested criterion',
exact: true,
})
.check();
if (create) {
await field('Status').selectOption('4');
} else {
@@ -154,7 +164,7 @@ try {
'Assignee',
'Parent ticket',
'Estimated story points',
'Acceptance criteria checklist',
'Criterion 1',
...(create
? ['Status']
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
@@ -231,12 +241,12 @@ try {
.waitFor();
if (create) {
await page.goBack();
await page
.getByRole('heading', { name: 'Create ticket', exact: true })
.waitFor();
await field('Title').waitFor();
assert.equal(await field('Title').inputValue(), '');
assert.equal(
await field('Acceptance criteria checklist').inputValue(),
'',
);
assert.equal(await field('Criterion 1').count(), 0);
} else {
await form()
.getByText('All changes saved', { exact: true })