Merge pull request 'feat(KAN-32): add hierarchical acceptance criteria checklist' (#127) from feature/KAN-32-akzeptanzkriterien-checkliste into main
Reviewed-on: #127
This commit was merged in pull request #127.
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 { 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.acceptance_criteria = nextAcceptanceCriteria;
|
||||||
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
||||||
}
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
acceptanceCriteriaToggleError.value = t(
|
acceptanceCriteriaToggleError.value = t('criteria.saveError');
|
||||||
'ticketDetail.checklistSaveFailed',
|
|
||||||
);
|
|
||||||
} 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"
|
||||||
>
|
|
||||||
<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="
|
:disabled="
|
||||||
!canEditTicket ||
|
|
||||||
form.processing ||
|
form.processing ||
|
||||||
isPersistingAcceptanceCriteriaToggle ||
|
isPersistingAcceptanceCriteriaToggle ||
|
||||||
hasUnsavedAcceptanceCriteriaDraft
|
hasUnsavedAcceptanceCriteriaDraft
|
||||||
"
|
"
|
||||||
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
|
:saving="
|
||||||
@change="
|
form.processing || isPersistingAcceptanceCriteriaToggle
|
||||||
onAcceptanceCriteriaCheckboxChange(
|
|
||||||
index,
|
|
||||||
$event,
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
|
:allow-editing="false"
|
||||||
|
persist-toggles
|
||||||
|
:dirty="hasUnsavedAcceptanceCriteriaDraft"
|
||||||
|
:error="acceptanceCriteriaToggleError"
|
||||||
|
@toggle="persistAcceptanceCriteriaToggle"
|
||||||
/>
|
/>
|
||||||
<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>
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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"`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user