assertNotFrozen($schema); $data['form_schema_id'] = $schema->id; $data['sort_order'] ??= $this->nextSortOrder($schema); $this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null); /** @var FormField $field */ $field = FormField::create($data); $this->schemaService->bumpVersion($schema); $field->logFieldChange('field.created'); if ($field->is_filterable) { BackfillFormValueIndexedJob::dispatch($field->id)->onQueue('default'); } return $field->refresh(); } public function update(FormField $field, array $data, bool $forceBindingChange = false): FormField { $schema = $field->schema; $this->assertNotFrozenForStructural($schema, $data); if (array_key_exists('binding', $data) && $data['binding'] !== $field->binding) { $this->assertBindingChangeAllowed($field, $forceBindingChange); } if (array_key_exists('conditional_logic', $data)) { $this->assertNoConditionalCycle($schema, $field, $data['conditional_logic'], $data['slug'] ?? $field->slug); } $before = [ 'binding' => $field->binding, 'is_filterable' => $field->is_filterable, 'is_pii' => $field->is_pii, 'field_type' => $field->field_type, ]; $field->fill($data); $field->save(); $this->schemaService->bumpVersion($schema); $field->logFieldChange('field.updated', [ 'old' => $before, 'new' => [ 'binding' => $field->binding, 'is_filterable' => $field->is_filterable, 'is_pii' => $field->is_pii, 'field_type' => $field->field_type, ], ]); if ($before['is_filterable'] !== $field->is_filterable) { BackfillFormValueIndexedJob::dispatch($field->id)->onQueue('default'); } return $field->refresh(); } public function delete(FormField $field, ?string $confirmedName = null): void { $schema = $field->schema; $this->assertNotFrozen($schema); $hasValues = FormValue::query()->where('form_field_id', $field->id)->exists(); if ($hasValues && $confirmedName !== $field->label) { throw DestructiveConfirmationRequiredException::forName($field->label); } DB::transaction(function () use ($field, $schema): void { $field->logFieldChange('field.deleted'); $field->delete(); $this->schemaService->bumpVersion($schema); }); } /** * @param array $orderedFieldIds */ public function reorder(FormSchema $schema, array $orderedFieldIds): void { DB::transaction(function () use ($schema, $orderedFieldIds): void { foreach ($orderedFieldIds as $index => $fieldId) { FormField::query() ->where('form_schema_id', $schema->id) ->whereKey($fieldId) ->update(['sort_order' => $index]); } $this->schemaService->bumpVersion($schema); }); } public function insertFromLibrary(FormSchema $schema, FormFieldLibrary $library, array $overrides = []): FormField { $this->assertNotFrozen($schema); $data = array_merge([ 'form_schema_id' => $schema->id, 'library_field_id' => $library->id, 'field_type' => $library->field_type, 'slug' => $this->ensureUniqueSlug($schema, $library->slug), 'label' => $library->label, 'help_text' => $library->help_text, 'options' => $library->options, 'validation_rules' => $library->validation_rules, 'is_required' => (bool) $library->default_is_required, 'is_filterable' => (bool) $library->default_is_filterable, 'binding' => $library->default_binding, 'translations' => $library->translations, 'sort_order' => $this->nextSortOrder($schema), ], $overrides); if (! isset($data['slug']) || $data['slug'] === '') { $data['slug'] = $this->ensureUniqueSlug($schema, $library->slug); } else { $data['slug'] = $this->ensureUniqueSlug($schema, $data['slug']); } /** @var FormField $field */ $field = FormField::create($data); FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count'); $this->schemaService->bumpVersion($schema); $field->logFieldChange('field.inserted_from_library', ['library_field_id' => $library->id]); if ($field->is_filterable) { BackfillFormValueIndexedJob::dispatch($field->id)->onQueue('default'); } return $field->refresh(); } private function assertBindingChangeAllowed(FormField $field, bool $forceBindingChange): void { $submittedCount = FormSubmission::query() ->where('form_schema_id', $field->form_schema_id) ->where('status', 'submitted') ->count(); if ($submittedCount > 0 && ! $forceBindingChange) { throw BindingChangeBlockedException::forField($field->id, $submittedCount); } } private function assertNotFrozen(FormSchema $schema): void { if ($schema->freeze_on_submit && $this->schemaService->hasSubmittedSubmissions($schema)) { throw FrozenSchemaException::forSchema($schema->id); } } private function assertNotFrozenForStructural(FormSchema $schema, array $data): void { $structuralKeys = ['field_type', 'binding', 'options', 'validation_rules', 'is_required', 'slug']; foreach ($structuralKeys as $key) { if (array_key_exists($key, $data)) { $this->assertNotFrozen($schema); return; } } } private function assertNoConditionalCycle(FormSchema $schema, ?FormField $subject, mixed $conditionalLogic, ?string $subjectSlug): void { if ($conditionalLogic === null || $subjectSlug === null) { return; } $dependsOn = $this->extractConditionSlugs($conditionalLogic); if ($dependsOn === []) { return; } $adjacency = $this->buildConditionalAdjacency($schema, $subject, $subjectSlug, $dependsOn); $visiting = []; $visited = []; $walk = function (string $node) use (&$walk, &$adjacency, &$visiting, &$visited, $subjectSlug): void { if (isset($visited[$node])) { return; } if (isset($visiting[$node])) { throw CyclicDependencyException::forField($subjectSlug); } $visiting[$node] = true; foreach ($adjacency[$node] ?? [] as $next) { $walk($next); } unset($visiting[$node]); $visited[$node] = true; }; $walk($subjectSlug); } /** * @return array */ private function extractConditionSlugs(mixed $logic): array { if (! is_array($logic)) { return []; } $slugs = []; $walk = function ($node) use (&$walk, &$slugs): void { if (! is_array($node)) { return; } if (isset($node['field_slug'])) { $slugs[] = (string) $node['field_slug']; } foreach ($node as $child) { if (is_array($child)) { $walk($child); } } }; $walk($logic); return array_values(array_unique($slugs)); } /** * @param array $seedDeps * @return array> */ private function buildConditionalAdjacency(FormSchema $schema, ?FormField $subject, string $subjectSlug, array $seedDeps): array { $fields = FormField::query() ->where('form_schema_id', $schema->id) ->get(['id', 'slug', 'conditional_logic']); $adjacency = []; foreach ($fields as $f) { if ($subject !== null && $f->id === $subject->id) { continue; } $deps = $this->extractConditionSlugs($f->conditional_logic); if ($deps !== []) { $adjacency[$f->slug] = $deps; } } $adjacency[$subjectSlug] = $seedDeps; return $adjacency; } /** * @return array */ public function detectSectionCycle(FormSchema $schema, FormSchemaSection $section, ?string $dependsOnId): void { if ($dependsOnId === null) { return; } $chain = []; $current = $dependsOnId; $safety = 100; while ($current !== null && $safety-- > 0) { if ($current === $section->id) { throw CyclicDependencyException::forSection($section->id); } $chain[] = $current; $parent = FormSchemaSection::query() ->whereKey($current) ->value('depends_on_section_id'); $current = $parent !== null ? (string) $parent : null; } } private function nextSortOrder(FormSchema $schema): int { $max = (int) FormField::query() ->where('form_schema_id', $schema->id) ->max('sort_order'); return $max + 1; } private function ensureUniqueSlug(FormSchema $schema, string $slug): string { $base = \Illuminate\Support\Str::slug($slug) ?: 'veld'; $candidate = $base; $i = 2; while (FormField::query() ->where('form_schema_id', $schema->id) ->where('slug', $candidate) ->exists() ) { $candidate = $base.'-'.$i; $i++; } return $candidate; } }