import type { AcceptanceCriteriaItemModel, AcceptanceCriteriaModel, } from '@/types/models/acceptance-criteria-model'; 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; }; export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => []; export const normalizeAcceptanceCriteria = ( value: AcceptanceCriteriaModel | null | undefined, ): AcceptanceCriteriaModel => { const items = Array.isArray(value) ? value .map((item) => ({ ...(typeof item.id === 'number' && item.id > 0 ? { id: item.id } : {}), checked: item.checked === true, indent: clampIndent(item.indent), text: item.text.trim(), })) .filter((item) => item.text !== '') : []; return 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 = ( checklistInput: string, ): AcceptanceCriteriaModel => { return normalizeAcceptanceCriteria(parseChecklistLines(checklistInput)); }; export const syncAcceptanceCriteriaDescendantCheckedState = ( items: AcceptanceCriteriaModel, index: number, checked: boolean, ): AcceptanceCriteriaModel => { if (index < 0 || index >= items.length) { return items; } const parentIndent = items[index]?.indent ?? 0; let isWithinDescendantRange = true; return items.map((item, itemIndex) => { if (itemIndex === index) { return { ...item, checked }; } if (itemIndex < index || !isWithinDescendantRange) { return item; } if (item.indent <= parentIndent) { isWithinDescendantRange = false; return item; } return { ...item, checked }; }); }; export const acceptanceCriteriaToEditorState = ( value: AcceptanceCriteriaModel | null | undefined, ): AcceptanceCriteriaEditorState => { const normalized = normalizeAcceptanceCriteria(value); return { checklist: formatChecklistLines(normalized), }; }; const parseChecklistLine = ( line: string, ): AcceptanceCriteriaItemModel | null => { const normalizedLine = line.replaceAll('\t', ' ').trimEnd(); if (normalizedLine.trim() === '') { return null; } const match = /^(?\s*)(?.+)$/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+)?\[(?[xX ])\]\s*(?.+)$/u.exec(content); if (checkboxMatch?.groups !== undefined) { checked = checkboxMatch.groups.state.toLowerCase() === 'x'; content = checkboxMatch.groups.text.trim(); } else { const bulletMatch = /^[-*+]\s+(?.+)$/u.exec(content); if (bulletMatch?.groups !== undefined) { content = bulletMatch.groups.text.trim(); } else { return null; } } 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))); };