feat(KAN-32): add hierarchical acceptance criteria checklist
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user