Files
KanbanGit/resources/js/lib/acceptance-criteria.ts
T
Twyco 8c70d094b8
linter / quality (pull_request) Successful in 1m23s
tests / ci (8.4) (pull_request) Successful in 1m49s
Redesign ticket details with inline editing
2026-07-18 19:50:50 +02:00

382 lines
9.7 KiB
TypeScript

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 = /^(?<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)));
};