diff --git a/src/Services/Visibility/FrontendVisibilityService.php b/src/Services/Visibility/FrontendVisibilityService.php index 02a50cb2..91eb9ca5 100644 --- a/src/Services/Visibility/FrontendVisibilityService.php +++ b/src/Services/Visibility/FrontendVisibilityService.php @@ -223,6 +223,13 @@ private function buildCondition( $escapedCode = addslashes($condition->field_code); $targetField = $isModelAttribute ? null : $allFields?->firstWhere('code', $condition->field_code); + + // Option resolution (name -> id) and the option-backed choice branch both depend on the + // target field's options being loaded. Callers do not always eager-load them, so guarantee + // it here — otherwise a choice condition falls back to comparing option names against the + // selected ids, which never matches and emits quoted strings that break the class binding. + $targetField?->loadMissing('options'); + $fieldValue = $isModelAttribute ? sprintf("\$get('%s')", $escapedCode) : sprintf("\$get('custom_fields.%s')", $escapedCode); @@ -422,23 +429,21 @@ private function buildOptionExpression( } /** - * Build multi-value option condition. + * Build multi-value option condition as a single-line expression (no block-body arrow, no double + * quotes) so it embeds safely inside Filament's `x-bind:class="{ 'fi-hidden': !(…) }"` attribute. + * Both sides are stringified before comparison because option ids arrive from Livewire state as + * strings while the resolved condition ids are integers — strict includes() would otherwise miss. */ private function buildMultiValueOptionCondition( string $fieldValue, mixed $resolvedValue, string $jsValue ): string { + $selected = sprintf('(Array.isArray(%s) ? %s : []).map(v => String(v))', $fieldValue, $fieldValue); + return is_array($resolvedValue) - ? "(() => { - const fieldVal = Array.isArray({$fieldValue}) ? {$fieldValue} : []; - const conditionVal = {$jsValue}; - return conditionVal.some(id => fieldVal.includes(id)); - })()" - : "(() => { - const fieldVal = Array.isArray({$fieldValue}) ? {$fieldValue} : []; - return fieldVal.includes({$jsValue}); - })()"; + ? sprintf('(%s.map(v => String(v)).some(id => %s.includes(id)))', $jsValue, $selected) + : sprintf('(%s.includes(String(%s)))', $selected, $jsValue); } /** @@ -448,24 +453,12 @@ private function buildSingleValueOptionCondition( string $fieldValue, string $jsValue ): string { - return "(() => { - const fieldVal = {$fieldValue}; - const conditionVal = {$jsValue}; - - if (fieldVal === null || fieldVal === undefined || fieldVal === '') { - return conditionVal === null || conditionVal === undefined || conditionVal === ''; - } - - if (typeof fieldVal === 'number' && typeof conditionVal === 'number') { - return fieldVal === conditionVal; - } - - if (typeof fieldVal === 'boolean' && typeof conditionVal === 'boolean') { - return fieldVal === conditionVal; - } + $fieldEmpty = sprintf("(%s === null || %s === undefined || %s === '')", $fieldValue, $fieldValue, $fieldValue); + $conditionEmpty = sprintf("(%s === null || %s === undefined || %s === '')", $jsValue, $jsValue, $jsValue); - return String(fieldVal) === String(conditionVal); - })()"; + // Single-line, string-compared (String() subsumes the number/boolean cases) so it stays safe + // inside Filament's double-quoted x-bind:class attribute. + return sprintf('(%s ? %s : String(%s) === String(%s))', $fieldEmpty, $conditionEmpty, $fieldValue, $jsValue); } /** @@ -564,13 +557,9 @@ private function buildContainsExpression( : $value; $jsValue = $this->formatJsValue($resolvedValue); - return "(() => { - const fieldVal = {$fieldValue}; - const searchVal = {$jsValue}; - return Array.isArray(fieldVal) - ? fieldVal.some(item => String(item).toLowerCase().includes(String(searchVal).toLowerCase())) - : String(fieldVal || '').toLowerCase().includes(String(searchVal).toLowerCase()); - })()"; + return sprintf('(Array.isArray(%s) ', $fieldValue). + sprintf('? %s.some(item => String(item).toLowerCase().includes(String(%s).toLowerCase())) ', $fieldValue, $jsValue). + sprintf(": String(%s || '').toLowerCase().includes(String(%s).toLowerCase()))", $fieldValue, $jsValue); } /** @@ -613,7 +602,7 @@ private function formatJsValue(mixed $value): string is_bool($value) => $value ? 'true' : 'false', $value === 'true' => 'true', $value === 'false' => 'false', - is_string($value) => json_encode($value, JSON_UNESCAPED_UNICODE), + is_string($value) => $this->toJsString($value), is_int($value) => (string) $value, is_float($value) => number_format($value, 10, '.', ''), is_numeric($value) => str_contains($value, '.') @@ -626,10 +615,26 @@ private function formatJsValue(mixed $value): string $collection->implode(', '). ']' ), - default => json_encode((string) $value, JSON_UNESCAPED_UNICODE), + default => $this->toJsString((string) $value), }; } + /** + * Emit a single-quoted JS string literal. Single quotes (never double) keep the expression safe + * inside Filament's double-quoted `x-bind:class="…"` attribute, and control characters are + * stripped so the generated visibleJs never spans multiple lines or breaks Alpine parsing. + */ + private function toJsString(string $value): string + { + $escaped = str_replace( + ['\\', "'", "\r", "\n", "\t"], + ['\\\\', "\\'", '', ' ', ' '], + $value, + ); + + return sprintf("'%s'", $escaped); + } + /** * Export visibility logic to JavaScript format for complex integrations. * diff --git a/tests/Feature/SectionVisibilityIntegrationTest.php b/tests/Feature/SectionVisibilityIntegrationTest.php index 17c93104..9f7c9e2c 100644 --- a/tests/Feature/SectionVisibilityIntegrationTest.php +++ b/tests/Feature/SectionVisibilityIntegrationTest.php @@ -11,6 +11,7 @@ use Relaticle\CustomFields\Facades\CustomFields; use Relaticle\CustomFields\FeatureSystem\FeatureConfigurator; use Relaticle\CustomFields\Models\CustomField; +use Relaticle\CustomFields\Models\CustomFieldOption; use Relaticle\CustomFields\Models\CustomFieldSection; use Relaticle\CustomFields\Services\Visibility\BackendVisibilityService; use Relaticle\CustomFields\Services\Visibility\CoreVisibilityLogicService; @@ -134,7 +135,53 @@ expect($jsExpression)->toBeString() ->and($jsExpression)->toContain("\$get('custom_fields.plan')") - ->and($jsExpression)->toContain('"pro"'); + ->and($jsExpression)->toContain("'pro'") + ->and($jsExpression)->not->toContain('"'); + }); + + it('generates an attribute-safe, id-based expression for a multi-choice contains section condition', function (): void { + $services = CustomField::factory()->create([ + 'name' => 'Services', + 'code' => 'services', + 'type' => 'multi-select', + 'entity_type' => Post::class, + ]); + $other = CustomFieldOption::factory()->create([ + 'custom_field_id' => $services->id, + 'name' => 'Other', + 'sort_order' => 1, + ]); + + $section = CustomFieldSection::factory()->create([ + 'name' => 'Multi Choice Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'services', + 'operator' => VisibilityOperator::CONTAINS, + 'value' => ['Other'], + 'source' => ConditionSource::CustomField, + ], + ], + ], + ], + ]); + + $jsExpression = $this->frontendService->buildSectionVisibilityExpression($section, collect([$services])); + + // Same guarantees as field-level conditions: resolves the option name to its numeric id, + // string-normalizes, and stays safe inside the double-quoted x-bind:class attribute. + expect($jsExpression)->toBeString() + ->and($jsExpression)->toContain((string) $other->id) + ->and($jsExpression)->not->toContain("'Other'") + ->and($jsExpression)->toContain('String(v)') + ->and($jsExpression)->not->toContain('"') + ->and($jsExpression)->not->toContain("\n"); }); it('generates JS expression for section with model attribute condition', function (): void { diff --git a/tests/Feature/UnifiedVisibilityConsistencyTest.php b/tests/Feature/UnifiedVisibilityConsistencyTest.php index 8bdd0633..39a7f4ed 100644 --- a/tests/Feature/UnifiedVisibilityConsistencyTest.php +++ b/tests/Feature/UnifiedVisibilityConsistencyTest.php @@ -11,6 +11,7 @@ use Relaticle\CustomFields\FieldTypeSystem\BaseFieldType; use Relaticle\CustomFields\FieldTypeSystem\FieldSchema; use Relaticle\CustomFields\Models\CustomField; +use Relaticle\CustomFields\Models\CustomFieldOption; use Relaticle\CustomFields\Models\CustomFieldSection; use Relaticle\CustomFields\Services\Visibility\BackendVisibilityService; use Relaticle\CustomFields\Services\Visibility\CoreVisibilityLogicService; @@ -183,7 +184,11 @@ expect($jsExpression)->toBeString() ->and($jsExpression)->toContain("\$get('custom_fields.status')") - ->and($jsExpression)->toContain('"active"'); + ->and($jsExpression)->toContain("'active'") + // Must embed safely inside Filament's double-quoted x-bind:class attribute: never emit + // double quotes (they truncate the attribute) and stay on a single line. + ->and($jsExpression)->not->toContain('"') + ->and($jsExpression)->not->toContain("\n"); // Test always visible field returns null (no expression needed) $alwaysVisibleExpression = $this->frontendService->buildVisibilityExpression($this->alwaysVisibleField, $fields); @@ -198,6 +203,53 @@ ->and($jsData['fields']['name']['has_visibility_conditions'])->toBeFalse(); }); +test('multi-choice contains expression is attribute-safe, id-based and string-normalized', function (): void { + $services = CustomField::factory()->create([ + 'custom_field_section_id' => $this->section->id, + 'name' => 'Services', + 'code' => 'services', + 'type' => 'multi-select', + ]); + $other = CustomFieldOption::factory()->create([ + 'custom_field_id' => $services->id, + 'name' => 'Other', + 'sort_order' => 1, + ]); + + $dependent = CustomField::factory()->create([ + 'custom_field_section_id' => $this->section->id, + 'name' => 'Other details', + 'code' => 'other_details', + 'type' => 'text', + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [[ + 'field_code' => 'services', + 'operator' => VisibilityOperator::CONTAINS, + 'value' => ['Other'], + ]], + 'always_save' => false, + ], + ], + ]); + + $expr = $this->frontendService->buildVisibilityExpression($dependent, collect([$services, $dependent])); + + expect($expr)->toBeString() + // The option name is resolved to its numeric id — the expression never compares the + // selected ids against the raw option name (which would never match). + ->and($expr)->toContain((string) $other->id) + ->and($expr)->not->toContain("'Other'") + // Multi-select ids arrive from Livewire state as strings while the resolved condition id + // is an int; both sides must be stringified or strict includes() silently misses. + ->and($expr)->toContain('String(v)') + // Must embed safely inside Filament's double-quoted x-bind:class attribute. + ->and($expr)->not->toContain('"') + ->and($expr)->not->toContain("\n"); +}); + test('complex conditions work identically in backend and frontend', function (): void { // Create a more complex scenario with nested dependencies $dependentField = CustomField::factory()->create([