Files
KanbanGit/resources/js/components/projects/AcceptanceCriteriaEditor.vue
T
Twyco 8c70d094b8
linter / quality (pull_request) Successful in 1m23s
tests / ci (8.4) (pull_request) Successful in 1m49s
Redesign ticket details with inline editing
2026-07-18 19:50:50 +02:00

491 lines
16 KiB
Vue

<script setup lang="ts">
import {
ArrowDown,
ArrowUp,
Check,
CircleAlert,
IndentDecrease,
IndentIncrease,
LoaderCircle,
Plus,
Trash2,
} from 'lucide-vue-next';
import { nextTick, ref } from 'vue';
import {
addAcceptanceCriterionAfter,
indentAcceptanceCriterionSubtree,
moveAcceptanceCriterionSubtree,
nextAcceptanceCriteriaSiblingIndex,
outdentAcceptanceCriterionSubtree,
previousAcceptanceCriteriaSiblingIndex,
removeAcceptanceCriterion,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import { Button } from '@/components/ui/button';
import type {
AcceptanceCriteriaEditorEmits,
AcceptanceCriteriaEditorProps,
} from '@/types';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
const props = withDefaults(defineProps<AcceptanceCriteriaEditorProps>(), {
disabled: false,
error: '',
saveState: 'idle',
});
const emit = defineEmits<AcceptanceCriteriaEditorEmits>();
const editorRoot = ref<HTMLElement | null>(null);
const editingIndex = ref<number | null>(null);
const originalText = ref<{ index: number; text: string } | null>(null);
const skippedBlurIndex = ref<number | null>(null);
const emitUpdate = (items: AcceptanceCriteriaModel): void => {
emit('update:modelValue', items);
};
const emitCommit = (
items: AcceptanceCriteriaModel,
source: 'check' | 'delete' | 'indent' | 'move' | 'text',
): void => {
emitUpdate(items);
emit('commit', { items, source });
};
const focusRow = (index: number): void => {
editingIndex.value = index;
void nextTick(() => {
editorRoot.value
?.querySelector<HTMLInputElement>(`[data-ac-row-index="${index}"]`)
?.focus();
});
};
const startTextEdit = (index: number): void => {
if (!props.editable || props.disabled) {
return;
}
rememberText(index);
focusRow(index);
};
const addCriterion = (index: number | null): void => {
if (!props.editable || props.disabled) {
return;
}
const emptyCriterionIndex = props.modelValue.findIndex(
(criterion) => criterion.text.trim() === '',
);
if (emptyCriterionIndex !== -1) {
focusRow(emptyCriterionIndex);
return;
}
const result = addAcceptanceCriterionAfter(props.modelValue, index);
emitUpdate(result.items);
focusRow(result.index);
};
const updateText = (index: number, event: Event): void => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
const items = props.modelValue.map((item, itemIndex) => ({
...item,
text: itemIndex === index ? target.value : item.text,
}));
emitUpdate(items);
};
const rememberText = (index: number): void => {
originalText.value = {
index,
text: props.modelValue[index]?.text ?? '',
};
};
const commitText = (index: number): void => {
if (skippedBlurIndex.value === index) {
skippedBlurIndex.value = null;
editingIndex.value = null;
originalText.value = null;
return;
}
const item = props.modelValue[index];
if (item === undefined) {
return;
}
const trimmedText = item.text.trim();
if (trimmedText === '') {
const items = removeAcceptanceCriterion(props.modelValue, index);
if (item.id === undefined) {
emitUpdate(items);
} else {
emitCommit(items, 'delete');
}
originalText.value = null;
editingIndex.value = null;
return;
}
const items = props.modelValue.map((criterion, criterionIndex) => ({
...criterion,
text: criterionIndex === index ? trimmedText : criterion.text,
}));
const previousText =
originalText.value?.index === index
? originalText.value.text
: item.text;
if (trimmedText !== previousText || props.error !== '') {
emitCommit(items, 'text');
} else if (trimmedText !== item.text) {
emitUpdate(items);
}
originalText.value = null;
editingIndex.value = null;
};
const cancelTextEdit = (index: number, event: KeyboardEvent): void => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
const fallbackText =
originalText.value?.index === index ? originalText.value.text : '';
const items = props.modelValue.map((item, itemIndex) => ({
...item,
text: itemIndex === index ? fallbackText : item.text,
}));
skippedBlurIndex.value = index;
emitUpdate(items);
target.blur();
};
const insertAfterCurrent = (index: number, event: KeyboardEvent): void => {
event.preventDefault();
const item = props.modelValue[index];
const trimmedText = item?.text.trim() ?? '';
if (item === undefined || trimmedText === '') {
return;
}
const committedItems = props.modelValue.map(
(criterion, criterionIndex) => ({
...criterion,
text: criterionIndex === index ? trimmedText : criterion.text,
}),
);
const result = addAcceptanceCriterionAfter(committedItems, index);
originalText.value = null;
emitUpdate(result.items);
emit('commit', { items: committedItems, source: 'text' });
focusRow(result.index);
};
const toggleCriterion = (index: number, event: Event): void => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
emitCommit(
syncAcceptanceCriteriaDescendantCheckedState(
props.modelValue,
index,
target.checked,
),
'check',
);
};
const indentCriterion = (index: number): void => {
emitCommit(
indentAcceptanceCriterionSubtree(props.modelValue, index),
'indent',
);
};
const outdentCriterion = (index: number): void => {
emitCommit(
outdentAcceptanceCriterionSubtree(props.modelValue, index),
'indent',
);
};
const moveCriterion = (index: number, direction: 'down' | 'up'): void => {
emitCommit(
moveAcceptanceCriterionSubtree(props.modelValue, index, direction),
'move',
);
};
const deleteCriterion = (index: number): void => {
emitCommit(removeAcceptanceCriterion(props.modelValue, index), 'delete');
};
const handleIndentKeyboard = (index: number, event: KeyboardEvent): void => {
event.preventDefault();
if (event.shiftKey) {
outdentCriterion(index);
} else {
indentCriterion(index);
}
};
</script>
<template>
<div ref="editorRoot" class="space-y-3">
<div
v-if="modelValue.length === 0"
class="border-outline-variant bg-surface-container-low flex min-h-28 flex-col items-center justify-center gap-3 rounded border border-dashed px-4 py-6 text-center"
>
<p class="text-muted-foreground text-sm">
No acceptance criteria have been added yet.
</p>
<Button
v-if="editable"
type="button"
variant="outline"
size="sm"
:disabled="disabled"
@click="addCriterion(null)"
>
<Plus class="size-4" />
Add criterion
</Button>
</div>
<ol v-else class="space-y-1.5">
<li
v-for="(item, index) in modelValue"
:key="item.id ?? `draft-${index}`"
class="group/ac-row"
:style="{ paddingLeft: `${item.indent * 1.1}rem` }"
>
<div
class="border-outline-variant/80 bg-background hover:border-primary/35 focus-within:border-primary/50 focus-within:bg-primary/[0.025] flex min-h-10 flex-wrap items-start gap-2 rounded border px-2.5 py-2 transition sm:flex-nowrap"
>
<input
type="checkbox"
:checked="item.checked"
:disabled="!editable || disabled"
class="border-input text-primary focus:ring-ring mt-0.5 size-4 shrink-0 rounded"
:aria-label="`Mark ${item.text || 'criterion'} as complete`"
@change="toggleCriterion(index, $event)"
/>
<input
v-if="editable && editingIndex === index"
:data-ac-row-index="index"
:value="item.text"
type="text"
:disabled="disabled"
class="placeholder:text-muted-foreground/70 min-w-0 flex-1 border-0 bg-transparent p-0 text-sm leading-5 outline-none disabled:cursor-not-allowed"
placeholder="Describe a verifiable outcome"
@input="updateText(index, $event)"
@blur="commitText(index)"
@keydown.enter="insertAfterCurrent(index, $event)"
@keydown.esc="cancelTextEdit(index, $event)"
@keydown.tab="handleIndentKeyboard(index, $event)"
/>
<button
v-else-if="editable"
type="button"
class="placeholder:text-muted-foreground/70 hover:text-primary focus-visible:ring-ring min-w-0 flex-1 whitespace-normal rounded text-left text-sm leading-5 outline-none focus-visible:ring-2"
:disabled="disabled"
@click="startTextEdit(index)"
>
<span
:class="
item.checked
? 'text-muted-foreground line-through'
: ''
"
>
{{ item.text || 'Describe a verifiable outcome' }}
</span>
</button>
<span
v-else
class="min-w-0 flex-1 text-sm leading-5"
:class="
item.checked
? 'text-muted-foreground line-through'
: 'text-foreground'
"
>
{{ item.text }}
</span>
<div
v-if="editable"
class="flex w-full shrink-0 items-center justify-end gap-0.5 pl-6 opacity-100 transition sm:w-auto sm:pl-0 sm:opacity-0 sm:group-focus-within/ac-row:opacity-100 sm:group-hover/ac-row:opacity-100"
>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="disabled"
aria-label="Add criterion below"
title="Add below"
@mousedown.prevent
@click="addCriterion(index)"
>
<Plus class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="disabled || item.indent === 0"
aria-label="Outdent criterion"
title="Outdent (Shift+Tab)"
@mousedown.prevent
@click="outdentCriterion(index)"
>
<IndentDecrease class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="
disabled ||
previousAcceptanceCriteriaSiblingIndex(
modelValue,
index,
) === null
"
aria-label="Indent criterion"
title="Indent (Tab)"
@mousedown.prevent
@click="indentCriterion(index)"
>
<IndentIncrease class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="
disabled ||
previousAcceptanceCriteriaSiblingIndex(
modelValue,
index,
) === null
"
aria-label="Move criterion up"
title="Move up"
@mousedown.prevent
@click="moveCriterion(index, 'up')"
>
<ArrowUp class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="
disabled ||
nextAcceptanceCriteriaSiblingIndex(
modelValue,
index,
) === null
"
aria-label="Move criterion down"
title="Move down"
@mousedown.prevent
@click="moveCriterion(index, 'down')"
>
<ArrowDown class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-6"
:disabled="disabled"
aria-label="Delete criterion"
title="Delete"
@mousedown.prevent
@click="deleteCriterion(index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
</div>
</li>
</ol>
<div class="flex min-h-5 items-center justify-between gap-3">
<Button
v-if="editable && modelValue.length > 0"
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="disabled"
@click="addCriterion(null)"
>
<Plus class="size-3.5" />
Add criterion
</Button>
<span v-else />
<span
v-if="saveState === 'saving'"
class="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<LoaderCircle class="size-3.5 animate-spin" />
Saving
</span>
<span
v-else-if="saveState === 'saved'"
class="text-primary flex items-center gap-1.5 text-xs"
>
<Check class="size-3.5" />
Saved
</span>
<span
v-else-if="saveState === 'error' || error"
class="text-destructive flex items-center gap-1.5 text-xs"
>
<CircleAlert class="size-3.5" />
{{ error || 'Could not save changes.' }}
</span>
</div>
</div>
</template>