232 lines
6.1 KiB
TypeScript
232 lines
6.1 KiB
TypeScript
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 = /^(?<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();
|
|
} 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)));
|
|
};
|