Update checklist sync without hard resets
This commit is contained in:
@@ -16,6 +16,162 @@ class AcceptanceCriteriaChecklistMapper
|
||||
* @return array<int, array{checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
public static function fromModels(iterable $criteria): array
|
||||
{
|
||||
return self::flattenModels($criteria, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<AcceptanceCriterion> $criteria
|
||||
* @return array<int, array{id: int, checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
public static function fromModelsWithIds(iterable $criteria): array
|
||||
{
|
||||
return self::flattenModels($criteria, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
public static function normalize(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return self::normalizeFromArray($value);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Ticket $ticket
|
||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||
*/
|
||||
public static function syncTicket(Ticket $ticket, array $items): void
|
||||
{
|
||||
$normalized = self::normalize($items);
|
||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||
|
||||
if ($normalized === []) {
|
||||
$ticket->acceptanceCriteria()->delete();
|
||||
$ticket->unsetRelation('acceptanceCriteria');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$keptCriterionIds = [];
|
||||
$parentStack = [];
|
||||
$positionsByParent = [];
|
||||
$fallbackCriteria = $existingCriteria->values();
|
||||
$fallbackIndex = 0;
|
||||
|
||||
foreach ($normalized as $item) {
|
||||
$indent = min($item['indent'], count($parentStack));
|
||||
$parentId = $indent === 0 ? null : $parentStack[$indent - 1];
|
||||
$positionKey = $parentId ?? 'root';
|
||||
$position = $positionsByParent[$positionKey] ?? 0;
|
||||
$criterionId = $item['id'] ?? null;
|
||||
$criterion = $criterionId === null
|
||||
? null
|
||||
: $existingCriteria->get($criterionId);
|
||||
|
||||
while (
|
||||
$criterion === null
|
||||
&& $fallbackIndex < $fallbackCriteria->count()
|
||||
) {
|
||||
/** @var AcceptanceCriterion $candidate */
|
||||
$candidate = $fallbackCriteria[$fallbackIndex];
|
||||
$fallbackIndex++;
|
||||
|
||||
if (in_array($candidate->id, $keptCriterionIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$criterion = $candidate;
|
||||
}
|
||||
|
||||
if ($criterion === null) {
|
||||
$criterion = $ticket->acceptanceCriteria()->create([
|
||||
'parent_acceptance_criterion_id' => $parentId,
|
||||
'position' => $position,
|
||||
'text' => $item['text'],
|
||||
'is_checked' => $item['checked'],
|
||||
]);
|
||||
} else {
|
||||
$criterion->fill([
|
||||
'parent_acceptance_criterion_id' => $parentId,
|
||||
'position' => $position,
|
||||
'text' => $item['text'],
|
||||
'is_checked' => $item['checked'],
|
||||
]);
|
||||
|
||||
if ($criterion->isDirty()) {
|
||||
$criterion->save();
|
||||
}
|
||||
}
|
||||
|
||||
$keptCriterionIds[] = $criterion->id;
|
||||
$positionsByParent[$positionKey] = $position + 1;
|
||||
$parentStack[$indent] = $criterion->id;
|
||||
$parentStack = array_slice($parentStack, 0, $indent + 1);
|
||||
}
|
||||
|
||||
$idsToDelete = $existingCriteria->keys()->diff($keptCriterionIds);
|
||||
|
||||
if ($idsToDelete->isNotEmpty()) {
|
||||
$ticket->acceptanceCriteria()
|
||||
->whereIn('id', $idsToDelete->all())
|
||||
->delete();
|
||||
}
|
||||
|
||||
$ticket->unsetRelation('acceptanceCriteria');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $value
|
||||
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
private static function normalizeFromArray(array $value): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($value as $rawItem) {
|
||||
if (! is_array($rawItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = trim((string) Arr::get($rawItem, 'text', ''));
|
||||
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedItem = [
|
||||
'checked' => filter_var(Arr::get($rawItem, 'checked', false), FILTER_VALIDATE_BOOLEAN),
|
||||
'indent' => self::clampIndent((int) Arr::get($rawItem, 'indent', 0)),
|
||||
'text' => $text,
|
||||
];
|
||||
|
||||
$id = filter_var(Arr::get($rawItem, 'id'), FILTER_VALIDATE_INT);
|
||||
|
||||
if (is_int($id) && $id > 0) {
|
||||
$normalizedItem['id'] = $id;
|
||||
}
|
||||
|
||||
$items[] = $normalizedItem;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function clampIndent(int $indent): int
|
||||
{
|
||||
return max(0, min(self::MAX_INDENT_LEVEL, $indent));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<AcceptanceCriterion> $criteria
|
||||
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
private static function flattenModels(iterable $criteria, bool $includeIds): array
|
||||
{
|
||||
$models = ($criteria instanceof EloquentCollection ? $criteria : collect($criteria))
|
||||
->sort(function (AcceptanceCriterion $left, AcceptanceCriterion $right): int {
|
||||
@@ -39,108 +195,31 @@ class AcceptanceCriteriaChecklistMapper
|
||||
$grouped = $models->groupBy('parent_acceptance_criterion_id')->all();
|
||||
$items = [];
|
||||
|
||||
self::flattenItems($items, $grouped, null, 0);
|
||||
self::flattenItems($items, $grouped, null, 0, $includeIds);
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
public static function normalize(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return self::normalizeFromArray($value);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Ticket $ticket
|
||||
* @param array<int, array{checked: bool, indent: int, text: string}> $items
|
||||
*/
|
||||
public static function syncTicket(Ticket $ticket, array $items): void
|
||||
{
|
||||
$normalized = self::normalize($items);
|
||||
|
||||
$ticket->acceptanceCriteria()->delete();
|
||||
|
||||
if ($normalized === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentStack = [];
|
||||
$positionsByParent = [];
|
||||
|
||||
foreach ($normalized as $item) {
|
||||
$indent = min($item['indent'], count($parentStack));
|
||||
$parentId = $indent === 0 ? null : $parentStack[$indent - 1];
|
||||
$positionKey = $parentId ?? 'root';
|
||||
$position = $positionsByParent[$positionKey] ?? 0;
|
||||
|
||||
$criterion = $ticket->acceptanceCriteria()->create([
|
||||
'parent_acceptance_criterion_id' => $parentId,
|
||||
'position' => $position,
|
||||
'text' => $item['text'],
|
||||
'is_checked' => $item['checked'],
|
||||
]);
|
||||
|
||||
$positionsByParent[$positionKey] = $position + 1;
|
||||
$parentStack[$indent] = $criterion->id;
|
||||
$parentStack = array_slice($parentStack, 0, $indent + 1);
|
||||
}
|
||||
|
||||
$ticket->unsetRelation('acceptanceCriteria');
|
||||
}
|
||||
/**
|
||||
* @param array<int, array{checked: bool, indent: int, text: string}> $items
|
||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||
* @param array<int|string, iterable<AcceptanceCriterion>> $grouped
|
||||
*/
|
||||
private static function flattenItems(array &$items, array $grouped, ?int $parentId, int $indent): void
|
||||
private static function flattenItems(array &$items, array $grouped, ?int $parentId, int $indent, bool $includeIds): void
|
||||
{
|
||||
foreach ($grouped[$parentId] ?? [] as $criterion) {
|
||||
$items[] = [
|
||||
$item = [
|
||||
'checked' => $criterion->is_checked,
|
||||
'indent' => $indent,
|
||||
'text' => $criterion->text,
|
||||
];
|
||||
|
||||
self::flattenItems($items, $grouped, $criterion->id, $indent + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $value
|
||||
* @return array<int, array{checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
private static function normalizeFromArray(array $value): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($value as $rawItem) {
|
||||
if (! is_array($rawItem)) {
|
||||
continue;
|
||||
if ($includeIds) {
|
||||
$item['id'] = $criterion->id;
|
||||
}
|
||||
|
||||
$text = trim((string) Arr::get($rawItem, 'text', ''));
|
||||
$items[] = $item;
|
||||
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'checked' => filter_var(Arr::get($rawItem, 'checked', false), FILTER_VALIDATE_BOOLEAN),
|
||||
'indent' => self::clampIndent((int) Arr::get($rawItem, 'indent', 0)),
|
||||
'text' => $text,
|
||||
];
|
||||
self::flattenItems($items, $grouped, $criterion->id, $indent + 1, $includeIds);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function clampIndent(int $indent): int
|
||||
{
|
||||
return max(0, min(self::MAX_INDENT_LEVEL, $indent));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user