$criteria * @return array */ public static function fromModels(iterable $criteria): array { return self::flattenModels($criteria, false); } /** * @param iterable $criteria * @return array */ public static function fromModelsWithIds(iterable $criteria): array { return self::flattenModels($criteria, true); } /** * @return array */ public static function normalize(mixed $value): array { if (is_array($value)) { return self::normalizeFromArray($value); } return []; } /** * @param array $items */ public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): 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 ( $reuseUnidentifiedCriteria && $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 $value * @return array */ 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 $criteria * @return array */ private static function flattenModels(iterable $criteria, bool $includeIds): array { $models = ($criteria instanceof EloquentCollection ? $criteria : collect($criteria)) ->sort(function (AcceptanceCriterion $left, AcceptanceCriterion $right): int { return [ $left->parent_acceptance_criterion_id ?? 0, $left->position, $left->id, ] <=> [ $right->parent_acceptance_criterion_id ?? 0, $right->position, $right->id, ]; }) ->values(); if ($models->isEmpty()) { return []; } /** @var array> $grouped */ $grouped = $models->groupBy('parent_acceptance_criterion_id')->all(); $items = []; self::flattenItems($items, $grouped, null, 0, $includeIds); return $items; } /** * @param array $items * @param array> $grouped */ private static function flattenItems(array &$items, array $grouped, ?int $parentId, int $indent, bool $includeIds): void { foreach ($grouped[$parentId] ?? [] as $criterion) { $item = [ 'checked' => $criterion->is_checked, 'indent' => $indent, 'text' => $criterion->text, ]; if ($includeIds) { $item['id'] = $criterion->id; } $items[] = $item; self::flattenItems($items, $grouped, $criterion->id, $indent + 1, $includeIds); } } }