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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user