Merge pull request 'feat(KAN-32): add hierarchical acceptance criteria checklist' (#127) from feature/KAN-32-akzeptanzkriterien-checkliste into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s

Reviewed-on: #127
This commit was merged in pull request #127.
This commit is contained in:
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 { computed, ref, toRef, unref } 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 AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue'; 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 { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria, normalizeAcceptanceCriteria,
syncAcceptanceCriteriaDescendantCheckedState, syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria'; } from '@/lib/acceptance-criteria';
@@ -28,18 +26,12 @@ const canEditAuthor = computed(
const canViewAuthor = computed( const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject, () => 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 draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember( const acceptanceCriteriaDraft = useRemember(
{ checklist: acceptanceCriteriaEditorState.checklist }, { items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
`${draftKey}:checklist`, `${draftKey}:criteria`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
); );
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
const savedAcceptanceCriteria = ref( const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria), normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
); );
@@ -47,8 +39,8 @@ const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref(''); const acceptanceCriteriaToggleError = ref('');
const savedFormData = () => ({ const savedFormData = () => ({
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: normalizeAcceptanceCriteria(
acceptanceCriteriaChecklist.value, savedAcceptanceCriteria.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 ?? '',
@@ -77,14 +69,10 @@ const savedFormData = () => ({
const form = useForm(draftKey, savedFormData()); const form = useForm(draftKey, savedFormData());
const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
);
const hasUnsavedAcceptanceCriteriaDraft = computed( const hasUnsavedAcceptanceCriteriaDraft = computed(
() => () =>
acceptanceCriteriaChecklist.value !== JSON.stringify(acceptanceCriteriaItems.value) !==
formatChecklistLines(savedAcceptanceCriteria.value), JSON.stringify(savedAcceptanceCriteria.value),
); );
const isDirty = computed( const isDirty = computed(
@@ -103,9 +91,10 @@ const reset = (): void => {
} }
form.reset(); form.reset();
form.clearErrors(); form.clearErrors();
acceptanceCriteriaChecklist.value = formatChecklistLines( acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value, savedAcceptanceCriteria.value,
); );
acceptanceCriteriaToggleError.value = '';
protection.clearFeedback(); 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 = ( const buildSavedTicketPayload = (
acceptanceCriteria = savedAcceptanceCriteria.value, acceptanceCriteria = savedAcceptanceCriteria.value,
) => ({ ) => ({
@@ -171,10 +147,6 @@ const persistAcceptanceCriteriaToggle = async (
index, index,
checked, checked,
); );
const previousChecklist = formatChecklistLines(
savedAcceptanceCriteria.value,
);
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
const csrfToken = document const csrfToken = document
.querySelector('meta[name="csrf-token"]') .querySelector('meta[name="csrf-token"]')
?.getAttribute('content'); ?.getAttribute('content');
@@ -209,34 +181,18 @@ const persistAcceptanceCriteriaToggle = async (
savedAcceptanceCriteria.value = nextAcceptanceCriteria; savedAcceptanceCriteria.value = nextAcceptanceCriteria;
if (acceptanceCriteriaChecklist.value === previousChecklist) { acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
acceptanceCriteriaChecklist.value = nextChecklist; nextAcceptanceCriteria,
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
}
} catch (_error) {
acceptanceCriteriaToggleError.value = t(
'ticketDetail.checklistSaveFailed',
); );
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
} catch (_error) {
acceptanceCriteriaToggleError.value = t('criteria.saveError');
} finally { } finally {
isPersistingAcceptanceCriteriaToggle.value = false; 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 => { const submit = (): void => {
if ( if (
!props.canEditTicket || !props.canEditTicket ||
@@ -246,7 +202,7 @@ const submit = (): void => {
return; return;
} }
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit(); form.acceptance_criteria = acceptanceCriteriaItems.value;
protection.submit( protection.submit(
ProjectTicketController.update({ ProjectTicketController.update({
@@ -259,7 +215,7 @@ const submit = (): void => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria( savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria, props.ticket.acceptance_criteria,
); );
acceptanceCriteriaChecklist.value = formatChecklistLines( acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value, savedAcceptanceCriteria.value,
); );
Object.assign(form, savedFormData()); Object.assign(form, savedFormData());
@@ -301,82 +257,25 @@ const submit = (): void => {
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
<section <div id="ticket-checklist" class="scroll-mt-6">
id="ticket-checklist" <AcceptanceCriteriaChecklist
aria-labelledby="ticket-checklist-title" :model-value="savedAcceptanceCriteria"
class="scroll-mt-6" :readonly="!canEditTicket"
> :disabled="
<Card> form.processing ||
<CardHeader isPersistingAcceptanceCriteriaToggle ||
><h2 hasUnsavedAcceptanceCriteriaDraft
id="ticket-checklist-title" "
class="text-lg font-semibold" :saving="
> form.processing || isPersistingAcceptanceCriteriaToggle
{{ t('ticketDetail.checklist') }} "
</h2></CardHeader :allow-editing="false"
> persist-toggles
<CardContent class="space-y-3"> :dirty="hasUnsavedAcceptanceCriteriaDraft"
<div :error="acceptanceCriteriaToggleError"
v-if="acceptanceCriteriaPreviewItems.length > 0" @toggle="persistAcceptanceCriteriaToggle"
class="border-border rounded-xl border p-4 text-sm" />
> </div>
<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>
<details <details
v-if="canEditTicket" v-if="canEditTicket"
id="ticket-edit" id="ticket-edit"
@@ -416,20 +315,13 @@ const submit = (): void => {
" "
> >
<template #content> <template #content>
<TicketFormField <AcceptanceCriteriaChecklist
id="acceptance_criteria_checklist" v-model="acceptanceCriteriaItems"
v-model="acceptanceCriteriaChecklist"
kind="textarea"
:label="t('ticketDetail.checklistEditor')"
:hint="t('ticketForm.checklistHelp')"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
:error="acceptanceCriteriaError"
:disabled=" :disabled="
form.processing || form.processing ||
isPersistingAcceptanceCriteriaToggle isPersistingAcceptanceCriteriaToggle
" "
:error="acceptanceCriteriaError"
/> />
</template> </template>
<template #metadata> <template #metadata>
+70
View File
@@ -5,6 +5,76 @@ import type {
const MAX_INDENT_LEVEL = 8; 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 = { export type AcceptanceCriteriaEditorState = {
checklist: string; checklist: string;
}; };
+19
View File
@@ -1,4 +1,23 @@
export const en = { 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.title': '{name} board',
'board.description': 'board.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.', '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"> <script setup lang="ts">
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed, toRef, unref } from 'vue'; import { useSessionStorage } from '@vueuse/core';
import { computed } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue'; import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
@@ -17,10 +17,8 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
buildAcceptanceCriteria, import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
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';
@@ -71,19 +69,9 @@ const hasTicketMetadata = computed(
); );
const draftKey = `projects:${props.project.id}:tickets:create`; const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember( const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
{ checklist: '' }, const form = useForm(`${draftKey}:${draftGeneration.value}`, {
`${draftKey}:checklist`, acceptance_criteria: [] as AcceptanceCriteriaModel,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
assignee_user_id: '', assignee_user_id: '',
description: '', description: '',
parent_ticket_id: '', parent_ticket_id: '',
@@ -96,24 +84,17 @@ const form = useForm(draftKey, {
title: '', title: '',
}); });
const isDirty = computed( const isDirty = computed(() => form.isDirty);
() => form.isDirty || acceptanceCriteriaChecklist.value !== '', const protection = useFormProtection(form);
);
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
const reset = (): void => { const reset = (): void => {
if (form.processing || !protection.confirmLeave()) { if (form.processing || !protection.confirmLeave()) {
return; return;
} }
form.reset(); form.reset();
form.clearErrors(); form.clearErrors();
acceptanceCriteriaChecklist.value = '';
protection.clearFeedback(); protection.clearFeedback();
}; };
const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value),
);
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
form.errors.acceptance_criteria ?? form.errors.acceptance_criteria ??
@@ -128,17 +109,19 @@ const submit = (): void => {
if (!hasTicketMetadata.value || form.processing) { if (!hasTicketMetadata.value || form.processing) {
return; return;
} }
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
protection.submit( protection.submit(
ProjectTicketController.store({ project: props.project.id }), ProjectTicketController.store({ project: props.project.id }),
{ {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { 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(); form.reset();
acceptanceCriteriaChecklist.value = '';
}, },
}, },
); );
@@ -224,46 +207,11 @@ const submit = (): void => {
autofocus autofocus
> >
<template #content> <template #content>
<TicketFormField <AcceptanceCriteriaChecklist
id="acceptance_criteria_checklist" v-model="form.acceptance_criteria"
v-model="acceptanceCriteriaChecklist"
kind="textarea"
:label="t('ticketDetail.checklistEditor')"
:hint="t('ticketForm.checklistHelp')"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
:error="acceptanceCriteriaError"
:disabled="form.processing" :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> </template>
</TicketFormFields> </TicketFormFields>
<div <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.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('New ticket draft'); await page.locator('#title').fill('New ticket draft');
await page.locator('#description').fill('Keep this description'); await page.locator('#description').fill('Keep this description');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page await page
.locator('#acceptance_criteria_checklist') .getByRole('textbox', { name: 'Criterion 1', exact: true })
.fill('[ ] Keep this checklist'); .fill('Keep this checklist');
await setResult('validation'); await setResult('validation');
await page.locator('form button[type="submit"]').first().click(); await page.locator('form button[type="submit"]').first().click();
await state('validation'); await state('validation');
assert.equal( assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(), await page
'[ ] Keep this checklist', .getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Keep this checklist',
); );
await page.route('**/projects/7/tickets', (route) => route.abort('failed')); await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
await page.locator('form button[type="submit"]').first().click(); await page.locator('form button[type="submit"]').first().click();
@@ -349,8 +352,10 @@ try {
await page.locator('#title').waitFor(); await page.locator('#title').waitFor();
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft'); assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal( assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(), await page
'[ ] Keep this checklist', .getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Keep this checklist',
); );
await setResult('success'); await setResult('success');
await page.locator('form button[type="submit"]').first().click(); await page.locator('form button[type="submit"]').first().click();
@@ -359,9 +364,10 @@ try {
await page.goto(`${base}/projects/7/tickets/31`); await page.goto(`${base}/projects/7/tickets/31`);
await page.locator('#ticket-edit > summary').click(); await page.locator('#ticket-edit > summary').click();
await page.locator('#title').fill('Updated ticket draft'); await page.locator('#title').fill('Updated ticket draft');
await page.getByRole('button', { name: 'Add criterion' }).click();
await page await page
.locator('#acceptance_criteria_checklist') .getByRole('textbox', { name: 'Criterion 1', exact: true })
.fill('[ ] Edited checklist'); .fill('Edited checklist');
await setResult('server'); await setResult('server');
await page.locator('form button[type="submit"]').first().click(); await page.locator('form button[type="submit"]').first().click();
await state('server'); await state('server');
@@ -370,8 +376,10 @@ try {
'Updated ticket draft', 'Updated ticket draft',
); );
assert.equal( assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(), await page
'[ ] Edited checklist', .getByRole('textbox', { name: 'Criterion 1', exact: true })
.inputValue(),
'Edited checklist',
); );
await setResult('success'); await setResult('success');
await page.getByRole('button', { name: 'Try again' }).click(); await page.getByRole('button', { name: 'Try again' }).click();
+15 -2
View File
@@ -1,4 +1,5 @@
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import process from 'node:process';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite'; import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite'; import { createServer } from 'vite';
@@ -36,7 +37,19 @@ let ticket = {
ticket_status: status, ticket_status: status,
is_blocked: false, is_blocked: false,
story_points: 0, 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: [], field_values: [],
worklog_entries: [], worklog_entries: [],
created_at: '2026-09-10T12:00:00Z', created_at: '2026-09-10T12:00:00Z',
@@ -125,7 +138,7 @@ server.middlewares.use(async (request, response, next) => {
ticketStatusOptions: [status], ticketStatusOptions: [status],
users: [], users: [],
parentTicketOptions: [], parentTicketOptions: [],
canEditTicket: true, canEditTicket: !url.searchParams.has('readonly'),
canManageProject: true, canManageProject: true,
childTicketTree: [], childTicketTree: [],
linkedTickets: [], linkedTickets: [],
+24 -13
View File
@@ -105,22 +105,30 @@ try {
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft'); await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
await page await page
.getByRole('checkbox', { name: 'Cache requests safely', exact: true }) .locator('#ticket-checklist')
.check(); .getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page.waitForFunction( await page.waitForFunction(
() => document.querySelector('#acceptance-criteria-11').checked, () =>
document.querySelectorAll(
'#ticket-checklist input[type=checkbox]',
)[1].checked,
); );
assert.equal( assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(), await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft', 'Unsaved title draft',
); );
await page await page
.getByLabel('Acceptance criteria checklist', { exact: true }) .getByLabel('Criterion 1', { exact: true })
.fill('[ ] A draft checklist item'); .fill('A draft checklist item');
assert.equal( assert.equal(
await page await page
.locator('#ticket-checklist')
.getByRole('checkbox', { .getByRole('checkbox', {
name: 'Cache requests safely', name: 'Complete criterion 1: Cache requests safely',
exact: true, exact: true,
}) })
.isDisabled(), .isDisabled(),
@@ -155,10 +163,8 @@ try {
'Unsaved title draft', 'Unsaved title draft',
); );
assert.equal( assert.equal(
await page await page.getByLabel('Criterion 1', { exact: true }).inputValue(),
.getByLabel('Acceptance criteria checklist', { exact: true }) 'A draft checklist item',
.inputValue(),
'[ ] A draft checklist item',
); );
await visit('?reject'); await visit('?reject');
await edit().click(); await edit().click();
@@ -196,16 +202,21 @@ try {
await page.getByText('Ticket updated.', { exact: true }).waitFor(); await page.getByText('Ticket updated.', { exact: true }).waitFor();
await visit('?rejectToggle'); await visit('?rejectToggle');
await page await page
.getByRole('checkbox', { name: 'Cache requests safely', exact: true }) .locator('#ticket-checklist')
.check(); .getByRole('checkbox', {
name: 'Complete criterion 1: Cache requests safely',
exact: true,
})
.click();
await page await page
.getByRole('alert') .getByRole('alert')
.filter({ hasText: 'Checklist status could not be saved' }) .filter({ hasText: 'Checklist status could not be saved' })
.waitFor(); .waitFor();
assert.equal( assert.equal(
await page await page
.locator('#ticket-checklist')
.getByRole('checkbox', { .getByRole('checkbox', {
name: 'Cache requests safely', name: 'Complete criterion 1: Cache requests safely',
exact: true, exact: true,
}) })
.isChecked(), .isChecked(),
+7 -3
View File
@@ -478,7 +478,6 @@ if (serve) {
'story_points', 'story_points',
'parent_ticket_id', 'parent_ticket_id',
'description', 'description',
'acceptance_criteria_checklist',
'is_blocked', 'is_blocked',
'blocked_comment', 'blocked_comment',
'field-1', 'field-1',
@@ -489,6 +488,8 @@ if (serve) {
`Missing editor: ${field}`, `Missing editor: ${field}`,
); );
} }
assert.match(html, /aria-label="Criterion 1"/);
assert.match(html, /Add criterion/);
assert.match(html, /Create branch/); assert.match(html, /Create branch/);
assert.match(html, /Save link/); assert.match(html, /Save link/);
assert.match(html, /Remove/); assert.match(html, /Remove/);
@@ -505,7 +506,7 @@ if (serve) {
const empty = await render('?empty'); const empty = await render('?empty');
for (const text of [ for (const text of [
'No description has been added yet.', 'No description has been added yet.',
'No acceptance criteria have been added yet.', 'No acceptance criteria yet.',
'No status', 'No status',
'Unassigned', 'Unassigned',
'No author', 'No author',
@@ -540,6 +541,10 @@ if (serve) {
assert.match(unavailable, /Ticket creation needs at least one type/); assert.match(unavailable, /Ticket creation needs at least one type/);
for (const creating of [false, true]) { for (const creating of [false, true]) {
const invalid = await render('?rejected&allErrors', creating); const invalid = await render('?rejected&allErrors', creating);
assert.match(
invalid,
/aria-describedby="[^"]+-hint [^"]+-error"|No acceptance criteria yet/,
);
for (const id of [ for (const id of [
'title', 'title',
'description', 'description',
@@ -548,7 +553,6 @@ if (serve) {
'assignee_user_id', 'assignee_user_id',
'parent_ticket_id', 'parent_ticket_id',
'story_points', 'story_points',
'acceptance_criteria_checklist',
]) { ]) {
assert.ok(invalid.includes(`id="${id}-error"`)); 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 base = 'http://127.0.0.1:4185/projects/7/tickets';
const errors = []; const errors = [];
page.on('pageerror', (error) => errors.push(error.message)); 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 form = () => page.locator('form').first();
const state = (value) => const state = (value) =>
form().locator(`[data-form-state="${value}"]`).waitFor(); form().locator(`[data-form-state="${value}"]`).waitFor();
@@ -29,9 +29,19 @@ async function fill(create) {
await field('Assignee').selectOption('2'); await field('Assignee').selectOption('2');
await field('Parent ticket').selectOption('40'); await field('Parent ticket').selectOption('40');
await field('Estimated story points').fill('0'); await field('Estimated story points').fill('0');
await field('Acceptance criteria checklist').fill( if (create) {
'[ ] Main criterion\n [x] Nested criterion', 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) { if (create) {
await field('Status').selectOption('4'); await field('Status').selectOption('4');
} else { } else {
@@ -154,7 +164,7 @@ try {
'Assignee', 'Assignee',
'Parent ticket', 'Parent ticket',
'Estimated story points', 'Estimated story points',
'Acceptance criteria checklist', 'Criterion 1',
...(create ...(create
? ['Status'] ? ['Status']
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']), : ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
@@ -231,12 +241,12 @@ try {
.waitFor(); .waitFor();
if (create) { if (create) {
await page.goBack(); await page.goBack();
await page
.getByRole('heading', { name: 'Create ticket', exact: true })
.waitFor();
await field('Title').waitFor(); await field('Title').waitFor();
assert.equal(await field('Title').inputValue(), ''); assert.equal(await field('Title').inputValue(), '');
assert.equal( assert.equal(await field('Criterion 1').count(), 0);
await field('Acceptance criteria checklist').inputValue(),
'',
);
} else { } else {
await form() await form()
.getByText('All changes saved', { exact: true }) .getByText('All changes saved', { exact: true })