feat(KAN-32): add hierarchical acceptance criteria checklist
CI/CD / preconditions (pull_request) Successful in 1m4s
CI/CD / test_and_check_app (pull_request) Failing after 9m1s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-09-14 10:18:00 +02:00
parent 5c47453d4d
commit b54cb68b22
13 changed files with 855 additions and 256 deletions
@@ -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>