Redesign ticket details with inline editing
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Actions\Permissions\PermissionService;
|
|||||||
use App\Actions\Projects\BuildProjectTicketDetailPayload;
|
use App\Actions\Projects\BuildProjectTicketDetailPayload;
|
||||||
use App\Actions\Projects\BuildProjectTicketOptionsPayload;
|
use App\Actions\Projects\BuildProjectTicketOptionsPayload;
|
||||||
use App\Actions\Tickets\CreateProjectTicket;
|
use App\Actions\Tickets\CreateProjectTicket;
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\StoreProjectTicketRequest;
|
use App\Http\Requests\StoreProjectTicketRequest;
|
||||||
@@ -115,6 +116,9 @@ class ProjectTicketController extends Controller
|
|||||||
|
|
||||||
if ($request->expectsJson()) {
|
if ($request->expectsJson()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds(
|
||||||
|
$ticket->acceptanceCriteria()->get(),
|
||||||
|
),
|
||||||
'message' => $statusMessage,
|
'message' => $statusMessage,
|
||||||
'updated' => $wasUpdated,
|
'updated' => $wasUpdated,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, CircleAlert, LoaderCircle } from 'lucide-vue-next';
|
||||||
|
import type { InlineSaveStatusProps } from '@/types';
|
||||||
|
|
||||||
|
withDefaults(defineProps<InlineSaveStatusProps>(), {
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="state !== 'idle' || error"
|
||||||
|
class="flex items-center gap-1.5 pt-1 text-[11px]"
|
||||||
|
>
|
||||||
|
<template v-if="state === 'saving'">
|
||||||
|
<LoaderCircle class="text-muted-foreground size-3 animate-spin" />
|
||||||
|
<span class="text-muted-foreground">Saving</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="state === 'saved'">
|
||||||
|
<Check class="text-primary size-3" />
|
||||||
|
<span class="text-primary">Saved</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="state === 'error' || error">
|
||||||
|
<CircleAlert class="text-destructive size-3" />
|
||||||
|
<span class="text-destructive">{{
|
||||||
|
error || 'Could not save.'
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, CircleAlert, LoaderCircle, Pencil } from 'lucide-vue-next';
|
||||||
|
import { nextTick, ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
InlineTextEditorEmits,
|
||||||
|
InlineTextEditorProps,
|
||||||
|
InlineTextEditorValue,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<InlineTextEditorProps>(), {
|
||||||
|
disabled: false,
|
||||||
|
error: '',
|
||||||
|
inputType: 'text',
|
||||||
|
multiline: false,
|
||||||
|
saveState: 'idle',
|
||||||
|
variant: 'body',
|
||||||
|
});
|
||||||
|
const emit = defineEmits<InlineTextEditorEmits>();
|
||||||
|
|
||||||
|
const isEditing = ref(false);
|
||||||
|
const originalValue = ref<InlineTextEditorValue>(null);
|
||||||
|
const skipNextBlur = ref(false);
|
||||||
|
const editor = ref<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const startEditing = (): void => {
|
||||||
|
if (!props.editable || props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
originalValue.value = props.value;
|
||||||
|
isEditing.value = true;
|
||||||
|
|
||||||
|
void nextTick(() => {
|
||||||
|
editor.value?.focus();
|
||||||
|
|
||||||
|
if (editor.value instanceof HTMLInputElement) {
|
||||||
|
editor.value.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateValue = (event: Event): void => {
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(target instanceof HTMLInputElement) &&
|
||||||
|
!(target instanceof HTMLTextAreaElement)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:value', target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishEditing = (): void => {
|
||||||
|
if (skipNextBlur.value) {
|
||||||
|
skipNextBlur.value = false;
|
||||||
|
isEditing.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEditing.value = false;
|
||||||
|
|
||||||
|
if (props.value !== originalValue.value || props.error !== '') {
|
||||||
|
emit('commit');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = (): void => {
|
||||||
|
skipNextBlur.value = true;
|
||||||
|
emit('update:value', originalValue.value);
|
||||||
|
editor.value?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelEditing();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!props.multiline && event.key === 'Enter') ||
|
||||||
|
(props.multiline &&
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
(event.metaKey || event.ctrlKey))
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
editor.value?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = (): string => {
|
||||||
|
if (props.value === null || props.value === '') {
|
||||||
|
return props.placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(props.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div v-if="isEditing" class="space-y-1.5">
|
||||||
|
<textarea
|
||||||
|
v-if="multiline"
|
||||||
|
ref="editor"
|
||||||
|
:value="value ?? ''"
|
||||||
|
rows="5"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-32 w-full resize-y rounded border px-3 py-2 text-sm leading-6 outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
@input="updateValue"
|
||||||
|
@blur="finishEditing"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="editor"
|
||||||
|
:value="value ?? ''"
|
||||||
|
:type="inputType"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-3 py-2 text-sm outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
@input="updateValue"
|
||||||
|
@blur="finishEditing"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-[11px]">
|
||||||
|
{{
|
||||||
|
multiline
|
||||||
|
? 'Cmd/Ctrl+Enter to save · Escape to cancel'
|
||||||
|
: 'Enter to save · Escape to cancel'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else-if="editable"
|
||||||
|
type="button"
|
||||||
|
class="group/inline hover:bg-primary/5 focus-visible:ring-ring relative -m-1 flex w-[calc(100%+0.5rem)] min-w-0 items-start gap-2 rounded p-1 text-left outline-none transition focus-visible:ring-2"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="startEditing"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="min-w-0 flex-1 whitespace-pre-wrap"
|
||||||
|
:class="[
|
||||||
|
variant === 'title'
|
||||||
|
? 'text-2xl font-semibold leading-tight tracking-tight sm:text-[1.75rem]'
|
||||||
|
: variant === 'property'
|
||||||
|
? 'text-sm font-medium'
|
||||||
|
: 'text-sm leading-6',
|
||||||
|
value === null || value === ''
|
||||||
|
? 'text-muted-foreground italic'
|
||||||
|
: 'text-foreground',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ displayValue() }}
|
||||||
|
</span>
|
||||||
|
<Pencil
|
||||||
|
class="text-muted-foreground mt-0.5 size-3.5 shrink-0 opacity-0 transition group-hover/inline:opacity-100 group-focus-visible/inline:opacity-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<p
|
||||||
|
class="whitespace-pre-wrap"
|
||||||
|
:class="[
|
||||||
|
variant === 'title'
|
||||||
|
? 'text-2xl font-semibold leading-tight tracking-tight sm:text-[1.75rem]'
|
||||||
|
: variant === 'property'
|
||||||
|
? 'text-sm font-medium'
|
||||||
|
: 'text-sm leading-6',
|
||||||
|
value === null || value === ''
|
||||||
|
? 'text-muted-foreground italic'
|
||||||
|
: 'text-foreground',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ displayValue() }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="saveState !== 'idle' || error"
|
||||||
|
class="mt-1 flex items-center gap-1.5 text-[11px]"
|
||||||
|
>
|
||||||
|
<template v-if="saveState === 'saving'">
|
||||||
|
<LoaderCircle
|
||||||
|
class="text-muted-foreground size-3 animate-spin"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground">Saving</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="saveState === 'saved'">
|
||||||
|
<Check class="text-primary size-3" />
|
||||||
|
<span class="text-primary">Saved</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="saveState === 'error' || error">
|
||||||
|
<CircleAlert class="text-destructive size-3" />
|
||||||
|
<span class="text-destructive">{{
|
||||||
|
error || 'Could not save.'
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -96,6 +96,226 @@ export const syncAcceptanceCriteriaDescendantCheckedState = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const acceptanceCriteriaSubtreeEnd = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): number => {
|
||||||
|
const indent = items[index]?.indent;
|
||||||
|
|
||||||
|
if (indent === undefined) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = index + 1;
|
||||||
|
|
||||||
|
while (end < items.length && items[end].indent > indent) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return end;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const previousAcceptanceCriteriaSiblingIndex = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): number | null => {
|
||||||
|
const indent = items[index]?.indent;
|
||||||
|
|
||||||
|
if (indent === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
let candidateIndex = index - 1;
|
||||||
|
candidateIndex >= 0;
|
||||||
|
candidateIndex--
|
||||||
|
) {
|
||||||
|
const candidateIndent = items[candidateIndex].indent;
|
||||||
|
|
||||||
|
if (candidateIndent < indent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateIndent === indent) {
|
||||||
|
return candidateIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nextAcceptanceCriteriaSiblingIndex = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): number | null => {
|
||||||
|
const indent = items[index]?.indent;
|
||||||
|
const candidateIndex = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
|
||||||
|
if (
|
||||||
|
indent === undefined ||
|
||||||
|
candidateIndex >= items.length ||
|
||||||
|
items[candidateIndex].indent !== indent
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidateIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addAcceptanceCriterionAfter = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number | null,
|
||||||
|
): { index: number; items: AcceptanceCriteriaModel } => {
|
||||||
|
const nextItems = items.map((item) => ({ ...item }));
|
||||||
|
const insertionIndex =
|
||||||
|
index === null
|
||||||
|
? nextItems.length
|
||||||
|
: acceptanceCriteriaSubtreeEnd(nextItems, index);
|
||||||
|
const indent = index === null ? 0 : (nextItems[index]?.indent ?? 0);
|
||||||
|
|
||||||
|
nextItems.splice(insertionIndex, 0, {
|
||||||
|
checked: false,
|
||||||
|
indent,
|
||||||
|
text: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: insertionIndex,
|
||||||
|
items: nextItems,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const indentAcceptanceCriterionSubtree = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
const previousSiblingIndex = previousAcceptanceCriteriaSiblingIndex(
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
const item = items[index];
|
||||||
|
|
||||||
|
if (
|
||||||
|
item === undefined ||
|
||||||
|
item.indent >= MAX_INDENT_LEVEL ||
|
||||||
|
previousSiblingIndex === null
|
||||||
|
) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
|
||||||
|
return items.map((criterion, criterionIndex) => ({
|
||||||
|
...criterion,
|
||||||
|
indent:
|
||||||
|
criterionIndex >= index && criterionIndex < subtreeEnd
|
||||||
|
? criterion.indent + 1
|
||||||
|
: criterion.indent,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const outdentAcceptanceCriterionSubtree = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
const item = items[index];
|
||||||
|
|
||||||
|
if (item === undefined || item.indent === 0) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
|
||||||
|
return items.map((criterion, criterionIndex) => ({
|
||||||
|
...criterion,
|
||||||
|
indent:
|
||||||
|
criterionIndex >= index && criterionIndex < subtreeEnd
|
||||||
|
? criterion.indent - 1
|
||||||
|
: criterion.indent,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
const item = items[index];
|
||||||
|
|
||||||
|
if (item === undefined) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
|
||||||
|
return items
|
||||||
|
.filter((_criterion, criterionIndex) => criterionIndex !== index)
|
||||||
|
.map((criterion, criterionIndex) => {
|
||||||
|
const originalIndex =
|
||||||
|
criterionIndex >= index ? criterionIndex + 1 : criterionIndex;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...criterion,
|
||||||
|
indent:
|
||||||
|
originalIndex > index && originalIndex < subtreeEnd
|
||||||
|
? criterion.indent - 1
|
||||||
|
: criterion.indent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const moveAcceptanceCriterionSubtree = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
direction: 'down' | 'up',
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
const currentSubtree = items
|
||||||
|
.slice(index, subtreeEnd)
|
||||||
|
.map((item) => ({ ...item }));
|
||||||
|
|
||||||
|
if (direction === 'up') {
|
||||||
|
const previousSiblingIndex = previousAcceptanceCriteriaSiblingIndex(
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previousSiblingIndex === null) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...items
|
||||||
|
.slice(0, previousSiblingIndex)
|
||||||
|
.map((item) => ({ ...item })),
|
||||||
|
...currentSubtree,
|
||||||
|
...items
|
||||||
|
.slice(previousSiblingIndex, index)
|
||||||
|
.map((item) => ({ ...item })),
|
||||||
|
...items.slice(subtreeEnd).map((item) => ({ ...item })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSiblingIndex = nextAcceptanceCriteriaSiblingIndex(items, index);
|
||||||
|
|
||||||
|
if (nextSiblingIndex === null) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSubtreeEnd = acceptanceCriteriaSubtreeEnd(
|
||||||
|
items,
|
||||||
|
nextSiblingIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...items.slice(0, index).map((item) => ({ ...item })),
|
||||||
|
...items
|
||||||
|
.slice(nextSiblingIndex, nextSubtreeEnd)
|
||||||
|
.map((item) => ({ ...item })),
|
||||||
|
...currentSubtree,
|
||||||
|
...items.slice(nextSubtreeEnd).map((item) => ({ ...item })),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
export const acceptanceCriteriaToEditorState = (
|
export const acceptanceCriteriaToEditorState = (
|
||||||
value: AcceptanceCriteriaModel | null | undefined,
|
value: AcceptanceCriteriaModel | null | undefined,
|
||||||
): AcceptanceCriteriaEditorState => {
|
): AcceptanceCriteriaEditorState => {
|
||||||
@@ -121,9 +341,7 @@ const parseChecklistLine = (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const indentLevel = clampIndent(
|
const indentLevel = clampIndent(Math.floor(match.groups.indent.length / 2));
|
||||||
Math.floor(match.groups.indent.length / 2),
|
|
||||||
);
|
|
||||||
let content = match.groups.content.trim();
|
let content = match.groups.content.trim();
|
||||||
let checked = false;
|
let checked = false;
|
||||||
|
|
||||||
|
|||||||
+1571
-1165
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { computed, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import AcceptanceCriteriaEditor from '@/components/projects/AcceptanceCriteriaEditor.vue';
|
||||||
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -17,10 +18,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import { emptyAcceptanceCriteria } from '@/lib/acceptance-criteria';
|
||||||
buildAcceptanceCriteria,
|
|
||||||
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 } from '@/routes/projects/index';
|
import { index } from '@/routes/projects/index';
|
||||||
@@ -63,12 +61,8 @@ const hasTicketMetadata =
|
|||||||
props.ticketPriorities.length > 0 &&
|
props.ticketPriorities.length > 0 &&
|
||||||
props.ticketStatusOptions.length > 0;
|
props.ticketStatusOptions.length > 0;
|
||||||
|
|
||||||
const acceptanceCriteriaChecklist = ref('');
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
acceptance_criteria: buildAcceptanceCriteria(
|
acceptance_criteria: emptyAcceptanceCriteria(),
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
),
|
|
||||||
assignee_user_id: '',
|
assignee_user_id: '',
|
||||||
description: '',
|
description: '',
|
||||||
parent_ticket_id: '',
|
parent_ticket_id: '',
|
||||||
@@ -81,10 +75,6 @@ const form = useForm({
|
|||||||
title: '',
|
title: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
|
||||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
|
||||||
);
|
|
||||||
|
|
||||||
const acceptanceCriteriaError = computed(() => {
|
const acceptanceCriteriaError = computed(() => {
|
||||||
return (
|
return (
|
||||||
form.errors.acceptance_criteria ??
|
form.errors.acceptance_criteria ??
|
||||||
@@ -96,10 +86,6 @@ const acceptanceCriteriaError = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
form.submit(ProjectTicketController.store({ project: props.project.id }), {
|
form.submit(ProjectTicketController.store({ project: props.project.id }), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
});
|
});
|
||||||
@@ -200,64 +186,14 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-2">
|
||||||
<div class="grid gap-2">
|
<Label>Acceptance criteria</Label>
|
||||||
<Label
|
<AcceptanceCriteriaEditor
|
||||||
for="acceptance_criteria_checklist"
|
v-model="form.acceptance_criteria"
|
||||||
>
|
editable
|
||||||
Acceptance criteria checklist
|
:disabled="form.processing"
|
||||||
</Label>
|
:error="acceptanceCriteriaError"
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
|
|
||||||
|
export type AcceptanceCriteriaCommitSource =
|
||||||
|
| 'check'
|
||||||
|
| 'delete'
|
||||||
|
| 'indent'
|
||||||
|
| 'move'
|
||||||
|
| 'text';
|
||||||
|
|
||||||
|
export type AcceptanceCriteriaEditorCommit = {
|
||||||
|
items: AcceptanceCriteriaModel;
|
||||||
|
source: AcceptanceCriteriaCommitSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AcceptanceCriteriaSaveState = 'error' | 'idle' | 'saved' | 'saving';
|
||||||
|
|
||||||
|
export type AcceptanceCriteriaEditorProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
error?: string;
|
||||||
|
modelValue: AcceptanceCriteriaModel;
|
||||||
|
saveState?: AcceptanceCriteriaSaveState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AcceptanceCriteriaEditorEmits = {
|
||||||
|
(event: 'commit', value: AcceptanceCriteriaEditorCommit): void;
|
||||||
|
(event: 'update:modelValue', value: AcceptanceCriteriaModel): void;
|
||||||
|
};
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
|
export * from './acceptance-criteria-editor';
|
||||||
export * from './layout';
|
export * from './layout';
|
||||||
export * from './navigation';
|
export * from './navigation';
|
||||||
export * from './notification-settings';
|
export * from './notification-settings';
|
||||||
export * from './page-props';
|
export * from './page-props';
|
||||||
export * from './project-navigation';
|
export * from './project-navigation';
|
||||||
|
export * from './ticket-inline-edit';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
|
|
||||||
|
export type InlineTextEditorValue = number | string | null;
|
||||||
|
|
||||||
|
export type InlineTextEditorProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
error?: string;
|
||||||
|
inputType?: 'number' | 'text';
|
||||||
|
multiline?: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
saveState?: 'error' | 'idle' | 'saved' | 'saving';
|
||||||
|
value: InlineTextEditorValue;
|
||||||
|
variant?: 'body' | 'property' | 'title';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InlineTextEditorEmits = {
|
||||||
|
(event: 'commit'): void;
|
||||||
|
(event: 'update:value', value: InlineTextEditorValue): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InlineSaveStatusProps = {
|
||||||
|
error?: string;
|
||||||
|
state: 'error' | 'idle' | 'saved' | 'saving';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketEditableField =
|
||||||
|
| 'acceptance_criteria'
|
||||||
|
| 'assignee_user_id'
|
||||||
|
| 'author_user_id'
|
||||||
|
| 'blocked_comment'
|
||||||
|
| 'description'
|
||||||
|
| 'is_blocked'
|
||||||
|
| 'parent_ticket_id'
|
||||||
|
| 'story_points'
|
||||||
|
| 'ticket_priority_id'
|
||||||
|
| 'ticket_type_id'
|
||||||
|
| 'title'
|
||||||
|
| `field-${number}`;
|
||||||
|
|
||||||
|
export type ProjectTicketFieldValuePayload = {
|
||||||
|
ticket_field_definition_id: number;
|
||||||
|
value_number: number | string | null;
|
||||||
|
value_user_id: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTicketUpdatePayload = {
|
||||||
|
acceptance_criteria: AcceptanceCriteriaModel;
|
||||||
|
assignee_user_id: number | string | null;
|
||||||
|
author_user_id: number | string | null;
|
||||||
|
blocked_comment: string;
|
||||||
|
description: string;
|
||||||
|
field_values: ProjectTicketFieldValuePayload[];
|
||||||
|
is_blocked: boolean;
|
||||||
|
parent_ticket_id: number | string | null;
|
||||||
|
story_points: number | string | null;
|
||||||
|
ticket_priority_id: number | string;
|
||||||
|
ticket_type_id: number | string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketAutosaveResponse = {
|
||||||
|
acceptance_criteria: AcceptanceCriteriaModel;
|
||||||
|
message: string;
|
||||||
|
updated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketAutosaveErrorResponse = {
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueuedTicketAutosave = {
|
||||||
|
field: TicketEditableField;
|
||||||
|
payload: ProjectTicketUpdatePayload;
|
||||||
|
};
|
||||||
@@ -868,11 +868,84 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJson([
|
->assertJson([
|
||||||
|
'acceptance_criteria' => [],
|
||||||
'message' => 'No ticket changes were detected.',
|
'message' => 'No ticket changes were detected.',
|
||||||
'updated' => false,
|
'updated' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_ticket_update_json_returns_stable_acceptance_criteria_ids(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = $project->ticketPriorities()->firstOrFail();
|
||||||
|
$ticketType = $this->nonParentType($project);
|
||||||
|
$ticket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $ticketType->id,
|
||||||
|
'title' => 'Acceptance criteria inline editor test',
|
||||||
|
]);
|
||||||
|
$existingCriterion = AcceptanceCriterion::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_acceptance_criterion_id' => null,
|
||||||
|
'position' => 0,
|
||||||
|
'text' => 'Existing criterion',
|
||||||
|
'is_checked' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($editor)
|
||||||
|
->withHeaders([
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
'X-Requested-With' => 'XMLHttpRequest',
|
||||||
|
])
|
||||||
|
->patch(
|
||||||
|
route('projects.tickets.update', [$project, $ticket]),
|
||||||
|
$this->updatePayload($ticket, [
|
||||||
|
'acceptance_criteria' => [
|
||||||
|
[
|
||||||
|
'id' => $existingCriterion->id,
|
||||||
|
'text' => 'Existing criterion',
|
||||||
|
'checked' => true,
|
||||||
|
'indent' => 0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'text' => 'New nested criterion',
|
||||||
|
'checked' => false,
|
||||||
|
'indent' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('acceptance_criteria.0.id', $existingCriterion->id)
|
||||||
|
->assertJsonPath('acceptance_criteria.0.checked', true)
|
||||||
|
->assertJsonPath('acceptance_criteria.0.indent', 0)
|
||||||
|
->assertJsonPath('acceptance_criteria.0.text', 'Existing criterion')
|
||||||
|
->assertJsonPath('acceptance_criteria.1.checked', false)
|
||||||
|
->assertJsonPath('acceptance_criteria.1.indent', 1)
|
||||||
|
->assertJsonPath('acceptance_criteria.1.text', 'New nested criterion')
|
||||||
|
->assertJsonPath('message', 'CORE-1 was updated.')
|
||||||
|
->assertJsonPath('updated', true);
|
||||||
|
|
||||||
|
$newCriterionId = AcceptanceCriterion::query()
|
||||||
|
->where('ticket_id', $ticket->id)
|
||||||
|
->where('text', 'New nested criterion')
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
$this->assertNotNull($newCriterionId);
|
||||||
|
$response->assertJsonPath('acceptance_criteria.1.id', $newCriterionId);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_ticket_update_persists_explicit_child_acceptance_criteria_toggle_states(): void
|
public function test_ticket_update_persists_explicit_child_acceptance_criteria_toggle_states(): void
|
||||||
{
|
{
|
||||||
$editor = User::factory()->create();
|
$editor = User::factory()->create();
|
||||||
|
|||||||
Reference in New Issue
Block a user