feat(KAN-32): add hierarchical acceptance criteria checklist
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClipboardCheck,
|
||||
IndentDecrease,
|
||||
IndentIncrease,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, useId } 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;
|
||||
dirty?: boolean;
|
||||
error?: string;
|
||||
}>(),
|
||||
{
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
persistToggles: false,
|
||||
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));
|
||||
};
|
||||
const changeText = (index: number, event: Event): void => {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
const text = target.value;
|
||||
target.setCustomValidity(text.trim() === '' ? t('criteria.required') : '');
|
||||
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 && !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"
|
||||
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="disabled" class="text-muted-foreground text-sm" role="status">
|
||||
{{ t('criteria.saving') }}
|
||||
</p>
|
||||
<InputError :id="`${id}-error`" :message="error" role="alert" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -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.',
|
||||
|
||||
@@ -10,9 +10,6 @@ import {
|
||||
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||
import { computed, ref, toRef, unref, watch } from 'vue';
|
||||
import {
|
||||
acceptanceCriteriaToEditorState,
|
||||
buildAcceptanceCriteria,
|
||||
formatChecklistLines,
|
||||
normalizeAcceptanceCriteria,
|
||||
syncAcceptanceCriteriaDescendantCheckedState,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
@@ -20,6 +17,7 @@ import ProjectTicketBranchController from '@/actions/App/Http/Controllers/Projec
|
||||
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
@@ -131,18 +129,12 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
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),
|
||||
);
|
||||
@@ -150,8 +142,8 @@ const isPersistingAcceptanceCriteriaToggle = ref(false);
|
||||
const acceptanceCriteriaToggleError = ref('');
|
||||
|
||||
const form = useForm(draftKey, {
|
||||
acceptance_criteria: buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
acceptance_criteria: normalizeAcceptanceCriteria(
|
||||
props.ticket.acceptance_criteria,
|
||||
),
|
||||
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
||||
author_user_id: props.ticket.author_user_id ?? '',
|
||||
@@ -178,14 +170,10 @@ const form = useForm(draftKey, {
|
||||
story_points: props.ticket.story_points,
|
||||
});
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(
|
||||
() => savedAcceptanceCriteria.value,
|
||||
);
|
||||
|
||||
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||
() =>
|
||||
acceptanceCriteriaChecklist.value !==
|
||||
formatChecklistLines(savedAcceptanceCriteria.value),
|
||||
JSON.stringify(acceptanceCriteriaItems.value) !==
|
||||
JSON.stringify(savedAcceptanceCriteria.value),
|
||||
);
|
||||
|
||||
const protection = useFormProtection(form, {
|
||||
@@ -399,19 +387,6 @@ const submitBranchCreation = (): void => {
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
) => ({
|
||||
@@ -439,6 +414,7 @@ const persistAcceptanceCriteriaToggle = async (
|
||||
): Promise<void> => {
|
||||
if (
|
||||
!props.canEditTicket ||
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle.value ||
|
||||
hasUnsavedAcceptanceCriteriaDraft.value
|
||||
) {
|
||||
@@ -450,10 +426,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');
|
||||
@@ -488,36 +460,22 @@ const persistAcceptanceCriteriaToggle = async (
|
||||
|
||||
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
|
||||
|
||||
if (acceptanceCriteriaChecklist.value === previousChecklist) {
|
||||
acceptanceCriteriaChecklist.value = nextChecklist;
|
||||
}
|
||||
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||
nextAcceptanceCriteria,
|
||||
);
|
||||
} catch (_error) {
|
||||
acceptanceCriteriaToggleError.value =
|
||||
'Checklist status could not be saved right now.';
|
||||
acceptanceCriteriaToggleError.value = t('criteria.saveError');
|
||||
} finally {
|
||||
isPersistingAcceptanceCriteriaToggle.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onAcceptanceCriteriaCheckboxChange = (
|
||||
index: number,
|
||||
event: Event,
|
||||
): void => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void persistAcceptanceCriteriaToggle(index, target.checked);
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (!props.canEditTicket) {
|
||||
if (!props.canEditTicket || isPersistingAcceptanceCriteriaToggle.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
||||
form.acceptance_criteria = acceptanceCriteriaItems.value;
|
||||
|
||||
protection.submit(
|
||||
ProjectTicketController.update({
|
||||
@@ -530,7 +488,7 @@ const submit = (): void => {
|
||||
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
||||
props.ticket.acceptance_criteria,
|
||||
);
|
||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
||||
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||
savedAcceptanceCriteria.value,
|
||||
);
|
||||
},
|
||||
@@ -807,92 +765,21 @@ const formatDateTime = (value: string): string => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="acceptance_criteria_checklist">
|
||||
Acceptance criteria checklist
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
rows="5"
|
||||
:disabled="
|
||||
!canEditTicket || form.processing
|
||||
"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Use <code>[ ]</code> /
|
||||
<code>[x]</code> for checklist items.
|
||||
Indent with two spaces for nested steps.
|
||||
Text changes are saved with the main
|
||||
ticket update.
|
||||
</p>
|
||||
<p
|
||||
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Save checklist text changes before
|
||||
toggling completion directly.
|
||||
</p>
|
||||
<InputError
|
||||
:message="acceptanceCriteriaError"
|
||||
/>
|
||||
<InputError
|
||||
:message="acceptanceCriteriaToggleError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
acceptanceCriteriaPreviewItems.length >
|
||||
0
|
||||
"
|
||||
class="border-border rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<p class="mb-3 font-medium">
|
||||
Saved checklist
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(
|
||||
item, index
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="item.id ?? index"
|
||||
:style="{
|
||||
marginLeft: `${item.indent * 1.25}rem`,
|
||||
}"
|
||||
class="flex 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-0.5 size-4 rounded"
|
||||
@change="
|
||||
onAcceptanceCriteriaCheckboxChange(
|
||||
index,
|
||||
$event,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<label
|
||||
:for="`acceptance-criteria-${item.id ?? index}`"
|
||||
class="flex-1"
|
||||
>
|
||||
{{ item.text }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<AcceptanceCriteriaChecklist
|
||||
v-model="acceptanceCriteriaItems"
|
||||
:readonly="!canEditTicket"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
persist-toggles
|
||||
:dirty="hasUnsavedAcceptanceCriteriaDraft"
|
||||
:error="
|
||||
acceptanceCriteriaError ||
|
||||
acceptanceCriteriaToggleError
|
||||
"
|
||||
@toggle="persistAcceptanceCriteriaToggle"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="border-border grid gap-3 rounded-xl border p-4"
|
||||
@@ -1026,7 +913,10 @@ const formatDateTime = (value: string): string => {
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle
|
||||
"
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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 { computed } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
@@ -18,10 +18,8 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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';
|
||||
@@ -65,19 +63,8 @@ const hasTicketMetadata =
|
||||
props.ticketStatusOptions.length > 0;
|
||||
|
||||
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,
|
||||
),
|
||||
acceptance_criteria: [] as AcceptanceCriteriaModel,
|
||||
assignee_user_id: '',
|
||||
description: '',
|
||||
parent_ticket_id: '',
|
||||
@@ -90,13 +77,7 @@ const form = useForm(draftKey, {
|
||||
title: '',
|
||||
});
|
||||
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
||||
});
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||
);
|
||||
const protection = useFormProtection(form);
|
||||
|
||||
const acceptanceCriteriaError = computed(() => {
|
||||
return (
|
||||
@@ -109,10 +90,6 @@ const acceptanceCriteriaError = computed(() => {
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
|
||||
protection.submit(
|
||||
ProjectTicketController.store({ project: props.project.id }),
|
||||
{
|
||||
@@ -223,65 +200,11 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="acceptance_criteria_checklist"
|
||||
>
|
||||
Acceptance criteria checklist
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="
|
||||
acceptanceCriteriaChecklist
|
||||
"
|
||||
rows="6"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<p
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Use <code>[ ]</code> /
|
||||
<code>[x]</code> for checklist
|
||||
items. Indent with two spaces for
|
||||
nested steps.
|
||||
</p>
|
||||
<InputError
|
||||
:message="acceptanceCriteriaError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
acceptanceCriteriaPreviewItems.length >
|
||||
0
|
||||
"
|
||||
class="border-border rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<p class="mb-3 font-medium">
|
||||
Checklist preview
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(
|
||||
item, index
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="index"
|
||||
:style="{
|
||||
marginLeft: `${item.indent * 1.25}rem`,
|
||||
}"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="mt-0.5">{{
|
||||
item.checked ? '☑' : '☐'
|
||||
}}</span>
|
||||
<span>{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<AcceptanceCriteriaChecklist
|
||||
v-model="form.acceptance_criteria"
|
||||
:disabled="form.processing"
|
||||
:error="acceptanceCriteriaError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid h-fit gap-6">
|
||||
|
||||
@@ -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,174 @@
|
||||
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.getByRole('region', { name: 'Acceptance criteria' });
|
||||
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 checklist().waitFor();
|
||||
const boxes = checklist().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 page.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 button('Edit checklist').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 button('Edit checklist').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);
|
||||
assert.equal(await button('Preview checklist').isDisabled(), true);
|
||||
await field(1).fill('Edited child');
|
||||
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 checklist().waitFor();
|
||||
assert.equal(await checklist().getByRole('textbox').count(), 0);
|
||||
assert.equal(await checklist().getByRole('button').count(), 0);
|
||||
assert.equal(
|
||||
await checklist().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,102 @@
|
||||
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,
|
||||
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.locator('#title').fill('New ticket draft');
|
||||
await page.locator('#description').fill('Keep this description');
|
||||
await page.getByRole('button', { name: 'Add criterion' }).click();
|
||||
await page
|
||||
.locator('#acceptance_criteria_checklist')
|
||||
.fill('[ ] Keep this checklist');
|
||||
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||
.fill('Keep this checklist');
|
||||
await setResult('validation');
|
||||
await page.locator('form button[type="submit"]').first().click();
|
||||
await state('validation');
|
||||
assert.equal(
|
||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
||||
'[ ] Keep this checklist',
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||
.inputValue(),
|
||||
'Keep this checklist',
|
||||
);
|
||||
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
|
||||
await page.locator('form button[type="submit"]').first().click();
|
||||
@@ -349,8 +352,10 @@ try {
|
||||
await page.locator('#title').waitFor();
|
||||
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
|
||||
assert.equal(
|
||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
||||
'[ ] Keep this checklist',
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||
.inputValue(),
|
||||
'Keep this checklist',
|
||||
);
|
||||
await setResult('success');
|
||||
await page.locator('form button[type="submit"]').first().click();
|
||||
@@ -358,9 +363,11 @@ try {
|
||||
|
||||
await page.goto(`${base}/projects/7/tickets/31`);
|
||||
await page.locator('#title').fill('Updated ticket draft');
|
||||
await page.getByRole('button', { name: 'Edit checklist' }).click();
|
||||
await page.getByRole('button', { name: 'Add criterion' }).click();
|
||||
await page
|
||||
.locator('#acceptance_criteria_checklist')
|
||||
.fill('[ ] Edited checklist');
|
||||
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||
.fill('Edited checklist');
|
||||
await setResult('server');
|
||||
await page.locator('form button[type="submit"]').first().click();
|
||||
await state('server');
|
||||
@@ -369,8 +376,10 @@ try {
|
||||
'Updated ticket draft',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
||||
'[ ] Edited checklist',
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||
.inputValue(),
|
||||
'Edited checklist',
|
||||
);
|
||||
await setResult('success');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import process from 'node:process';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
@@ -36,7 +37,19 @@ let ticket = {
|
||||
ticket_status: status,
|
||||
is_blocked: false,
|
||||
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: [],
|
||||
worklog_entries: [],
|
||||
};
|
||||
@@ -123,7 +136,7 @@ server.middlewares.use(async (request, response, next) => {
|
||||
ticketStatusOptions: [status],
|
||||
users: [],
|
||||
parentTicketOptions: [],
|
||||
canEditTicket: true,
|
||||
canEditTicket: !url.searchParams.has('readonly'),
|
||||
canManageProject: true,
|
||||
childTicketTree: [],
|
||||
linkedTickets: [],
|
||||
|
||||
Reference in New Issue
Block a user