import type { AcceptanceCriteriaItemModel, AcceptanceCriteriaModel, } from '@/types/models/acceptance-criteria-model'; const MAX_INDENT_LEVEL = 8; 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 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 = ( 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))); };