Add checklist acceptance criteria
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue';
|
||||
@@ -23,6 +24,11 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
acceptanceCriteriaToEditorState,
|
||||
buildAcceptanceCriteria,
|
||||
parseChecklistLines,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import {
|
||||
fieldLabelClass,
|
||||
frostedCardClass,
|
||||
@@ -53,8 +59,17 @@ const props = defineProps<{
|
||||
users: UserModel[];
|
||||
}>();
|
||||
|
||||
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
||||
props.ticket.acceptance_criteria,
|
||||
);
|
||||
const acceptanceCriteriaSummary = ref(acceptanceCriteriaEditorState.summary);
|
||||
const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist);
|
||||
|
||||
const form = useForm({
|
||||
acceptance_criteria: props.ticket.acceptance_criteria ?? '',
|
||||
acceptance_criteria: buildAcceptanceCriteria(
|
||||
acceptanceCriteriaSummary.value,
|
||||
acceptanceCriteriaChecklist.value,
|
||||
),
|
||||
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
||||
author_user_id: props.ticket.author_user_id ?? '',
|
||||
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||
@@ -80,11 +95,32 @@ const form = useForm({
|
||||
story_points: props.ticket.story_points,
|
||||
});
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||
);
|
||||
|
||||
const acceptanceCriteriaError = computed(() => {
|
||||
return (
|
||||
form.errors.acceptance_criteria ??
|
||||
form.errors['acceptance_criteria.summary'] ??
|
||||
form.errors['acceptance_criteria.items'] ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('acceptance_criteria.items.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
if (!props.canEditTicket) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaSummary.value,
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
|
||||
form.submit(
|
||||
ProjectTicketController.update({
|
||||
project: props.project.id,
|
||||
@@ -286,17 +322,92 @@ const submit = (): void => {
|
||||
<InputError :message="form.errors.description" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="acceptance_criteria" :class="fieldLabelClass">
|
||||
Acceptance criteria
|
||||
</Label>
|
||||
<Textarea
|
||||
id="acceptance_criteria"
|
||||
v-model="form.acceptance_criteria"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
:class="textareaClass"
|
||||
/>
|
||||
<InputError :message="form.errors.acceptance_criteria" />
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="acceptance_criteria_summary"
|
||||
:class="fieldLabelClass"
|
||||
>
|
||||
Acceptance criteria summary
|
||||
</Label>
|
||||
<Input
|
||||
id="acceptance_criteria_summary"
|
||||
v-model="acceptanceCriteriaSummary"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
placeholder="In one sentence, what defines done?"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="acceptance_criteria_checklist"
|
||||
:class="fieldLabelClass"
|
||||
>
|
||||
Acceptance checklist
|
||||
</Label>
|
||||
<Textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
:disabled="!canEditTicket || form.processing"
|
||||
placeholder="[ ] User can sign in\n[ ] Errors are shown inline\n [x] Nested checks use two-space indent"
|
||||
:class="textareaClass"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs leading-5">
|
||||
Each line is a checklist item. Prefix with
|
||||
<span class="font-semibold">[x]</span> for checked
|
||||
items and indent with two spaces for nested items.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InputError :message="acceptanceCriteriaError" />
|
||||
|
||||
<div
|
||||
class="dark:bg-muted/25 grid gap-3 rounded-[1.25rem] border border-dashed border-[#adb3b5]/35 bg-[#f8f9fa]/70 p-4 dark:border-white/10"
|
||||
>
|
||||
<p
|
||||
class="text-[0.72rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
|
||||
>
|
||||
Checklist preview
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="acceptanceCriteriaPreviewItems.length === 0"
|
||||
class="text-muted-foreground rounded-xl border border-dashed px-3 py-3 text-sm"
|
||||
>
|
||||
Add checklist lines to preview nested items.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(
|
||||
checklistItem,
|
||||
checklistItemIndex
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="checklistItemIndex"
|
||||
class="flex items-start gap-3"
|
||||
:style="{
|
||||
marginLeft: `${checklistItem.indent * 1.25}rem`,
|
||||
}"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="checklistItem.checked"
|
||||
disabled
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="
|
||||
checklistItem.checked
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
{{ checklistItem.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type {
|
||||
AcceptanceCriteriaItemModel,
|
||||
AcceptanceCriteriaModel,
|
||||
} from '@/types/models/acceptance-criteria-model';
|
||||
|
||||
const MAX_INDENT_LEVEL = 8;
|
||||
|
||||
export type AcceptanceCriteriaEditorState = {
|
||||
checklist: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => ({
|
||||
summary: null,
|
||||
items: [],
|
||||
});
|
||||
|
||||
export const normalizeAcceptanceCriteria = (
|
||||
value: AcceptanceCriteriaModel | null | undefined,
|
||||
): AcceptanceCriteriaModel => {
|
||||
if (value === null || value === undefined) {
|
||||
return emptyAcceptanceCriteria();
|
||||
}
|
||||
|
||||
const summary = value.summary?.trim() ?? null;
|
||||
const items = Array.isArray(value.items)
|
||||
? value.items
|
||||
.map((item) => ({
|
||||
checked: item.checked === true,
|
||||
indent: clampIndent(item.indent),
|
||||
text: item.text.trim(),
|
||||
}))
|
||||
.filter((item) => item.text !== '')
|
||||
: [];
|
||||
|
||||
return {
|
||||
summary: summary === '' ? null : summary,
|
||||
items,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseChecklistLines = (
|
||||
checklistInput: string,
|
||||
): AcceptanceCriteriaItemModel[] => {
|
||||
if (checklistInput.trim() === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return checklistInput
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => parseChecklistLine(line))
|
||||
.filter(
|
||||
(parsedLine): parsedLine is AcceptanceCriteriaItemModel =>
|
||||
parsedLine !== null,
|
||||
);
|
||||
};
|
||||
|
||||
export const formatChecklistLines = (
|
||||
items: AcceptanceCriteriaItemModel[],
|
||||
): string => {
|
||||
return items
|
||||
.map((item) => {
|
||||
const indentation = ' '.repeat(clampIndent(item.indent));
|
||||
const marker = item.checked ? '[x]' : '[ ]';
|
||||
|
||||
return `${indentation}${marker} ${item.text}`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
export const buildAcceptanceCriteria = (
|
||||
summaryInput: string,
|
||||
checklistInput: string,
|
||||
): AcceptanceCriteriaModel => {
|
||||
const summary = summaryInput.trim();
|
||||
|
||||
return normalizeAcceptanceCriteria({
|
||||
summary: summary === '' ? null : summary,
|
||||
items: parseChecklistLines(checklistInput),
|
||||
});
|
||||
};
|
||||
|
||||
export const acceptanceCriteriaToEditorState = (
|
||||
value: AcceptanceCriteriaModel | null | undefined,
|
||||
): AcceptanceCriteriaEditorState => {
|
||||
const normalized = normalizeAcceptanceCriteria(value);
|
||||
|
||||
return {
|
||||
checklist: formatChecklistLines(normalized.items),
|
||||
summary: normalized.summary ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
const parseChecklistLine = (
|
||||
line: string,
|
||||
): AcceptanceCriteriaItemModel | null => {
|
||||
const normalizedLine = line.replaceAll('\t', ' ').trimEnd();
|
||||
|
||||
if (normalizedLine.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /^(?<indent>\s*)(?<content>.+)$/u.exec(normalizedLine);
|
||||
|
||||
if (match === null || match.groups === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const indentLevel = clampIndent(
|
||||
Math.floor(match.groups.indent.length / 2),
|
||||
);
|
||||
let content = match.groups.content.trim();
|
||||
let checked = false;
|
||||
|
||||
const checkboxMatch =
|
||||
/^(?:[-*+]\s+)?\[(?<state>[xX ])\]\s*(?<text>.+)$/u.exec(content);
|
||||
|
||||
if (checkboxMatch?.groups !== undefined) {
|
||||
checked = checkboxMatch.groups.state.toLowerCase() === 'x';
|
||||
content = checkboxMatch.groups.text.trim();
|
||||
} else {
|
||||
const bulletMatch = /^[-*+]\s+(?<text>.+)$/u.exec(content);
|
||||
|
||||
if (bulletMatch?.groups !== undefined) {
|
||||
content = bulletMatch.groups.text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (content === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
checked,
|
||||
indent: indentLevel,
|
||||
text: content,
|
||||
};
|
||||
};
|
||||
|
||||
const clampIndent = (indent: number): number => {
|
||||
if (!Number.isFinite(indent)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(MAX_INDENT_LEVEL, Math.trunc(indent)));
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue';
|
||||
import TicketTypeIcon from '@/components/TicketTypeIcon.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
buildAcceptanceCriteria,
|
||||
parseChecklistLines,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
@@ -79,8 +84,14 @@ const hasTicketMetadata =
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0;
|
||||
|
||||
const acceptanceCriteriaSummary = ref('');
|
||||
const acceptanceCriteriaChecklist = ref('');
|
||||
|
||||
const form = useForm({
|
||||
acceptance_criteria: '',
|
||||
acceptance_criteria: buildAcceptanceCriteria(
|
||||
acceptanceCriteriaSummary.value,
|
||||
acceptanceCriteriaChecklist.value,
|
||||
),
|
||||
assignee_user_id: '',
|
||||
description: '',
|
||||
story_points: '',
|
||||
@@ -92,6 +103,22 @@ const form = useForm({
|
||||
title: '',
|
||||
});
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||
);
|
||||
|
||||
const acceptanceCriteriaError = computed(() => {
|
||||
return (
|
||||
form.errors.acceptance_criteria ??
|
||||
form.errors['acceptance_criteria.summary'] ??
|
||||
form.errors['acceptance_criteria.items'] ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('acceptance_criteria.items.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
const selectedTicketType = computed(
|
||||
() =>
|
||||
props.ticketTypes.find(
|
||||
@@ -121,6 +148,11 @@ const selectedAssignee = computed(
|
||||
);
|
||||
|
||||
const submit = (): void => {
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
||||
acceptanceCriteriaSummary.value,
|
||||
acceptanceCriteriaChecklist.value,
|
||||
);
|
||||
|
||||
form.post(`/projects/${props.project.id}/tickets`, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
@@ -298,25 +330,106 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="acceptance_criteria"
|
||||
:class="fieldLabelClass"
|
||||
>
|
||||
Acceptance criteria
|
||||
</Label>
|
||||
<Textarea
|
||||
id="acceptance_criteria"
|
||||
v-model="form.acceptance_criteria"
|
||||
:disabled="form.processing"
|
||||
placeholder="List the conditions that must be true before this ticket is done."
|
||||
:class="textareaClass"
|
||||
/>
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="acceptance_criteria_summary"
|
||||
:class="fieldLabelClass"
|
||||
>
|
||||
Acceptance criteria summary
|
||||
</Label>
|
||||
<Input
|
||||
id="acceptance_criteria_summary"
|
||||
v-model="acceptanceCriteriaSummary"
|
||||
:disabled="form.processing"
|
||||
placeholder="In one sentence, what defines done?"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="acceptance_criteria_checklist"
|
||||
:class="fieldLabelClass"
|
||||
>
|
||||
Acceptance checklist
|
||||
</Label>
|
||||
<Textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
:disabled="form.processing"
|
||||
placeholder="[ ] User can sign in\n[ ] Errors are shown inline\n [x] Nested checks use two-space indent"
|
||||
:class="textareaClass"
|
||||
/>
|
||||
<p
|
||||
class="text-muted-foreground text-xs leading-5"
|
||||
>
|
||||
Each line is a checklist item.
|
||||
Prefix with
|
||||
<span class="font-semibold"
|
||||
>[x]</span
|
||||
>
|
||||
for checked items and indent with
|
||||
two spaces for nested items.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InputError
|
||||
:message="
|
||||
form.errors.acceptance_criteria
|
||||
"
|
||||
:message="acceptanceCriteriaError"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="dark:bg-muted/25 grid gap-3 rounded-[1.25rem] border border-dashed border-[#adb3b5]/35 bg-[#f8f9fa]/70 p-4 dark:border-white/10"
|
||||
>
|
||||
<p
|
||||
class="text-[0.72rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
|
||||
>
|
||||
Checklist preview
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
acceptanceCriteriaPreviewItems.length ===
|
||||
0
|
||||
"
|
||||
class="text-muted-foreground rounded-xl border border-dashed px-3 py-3 text-sm"
|
||||
>
|
||||
Add checklist lines to preview
|
||||
nested items.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(
|
||||
checklistItem,
|
||||
checklistItemIndex
|
||||
) in acceptanceCriteriaPreviewItems"
|
||||
:key="checklistItemIndex"
|
||||
class="flex items-start gap-3"
|
||||
:style="{
|
||||
marginLeft: `${checklistItem.indent * 1.25}rem`,
|
||||
}"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="
|
||||
checklistItem.checked
|
||||
"
|
||||
disabled
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="
|
||||
checklistItem.checked
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
{{ checklistItem.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export type AcceptanceCriteriaItemModel = {
|
||||
checked: boolean;
|
||||
indent: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AcceptanceCriteriaModel = {
|
||||
summary: string | null;
|
||||
items: AcceptanceCriteriaItemModel[];
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||
import type { TicketFieldValueModel } from '@/types/models/ticket-field-value-model';
|
||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
@@ -7,7 +8,8 @@ import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
export type TicketModel = {
|
||||
id: number;
|
||||
acceptance_criteria: string | null;
|
||||
acceptance_criteria: AcceptanceCriteriaModel;
|
||||
acceptance_criteria_text: string | null;
|
||||
assignee: UserModel | null;
|
||||
assignee_user_id: number | null;
|
||||
author: UserModel | null;
|
||||
|
||||
Reference in New Issue
Block a user