diff --git a/docs/filters-and-examples.md b/docs/filters-and-examples.md index 3e31a45a..6863bcc3 100644 --- a/docs/filters-and-examples.md +++ b/docs/filters-and-examples.md @@ -78,6 +78,34 @@ $searchManager->callback('category_id', [ ---------- +`Mapped` to map form values to filter conditions with support for defaults that don't +trigger `isSearch()`. Useful for filters with a default value (e.g., "show enabled by +default") where you don't want the Reset button to appear. + +```php +// Boolean example: default to enabled=true, allow showing all with -1 +$searchManager->mapped('enabled', [ + 'map' => ['' => true, '0' => false, '-1' => null], + 'default' => '', +]); + +// Enum example: default to 'pending' status, unmapped values pass through +$searchManager->mapped('status', [ + 'map' => ['' => 'pending', '-1' => null], + 'default' => '', +]); +// Values like 'active', 'completed' pass through directly as filter condition +``` + +Key features: +- Map keys are form values, map values are the condition to apply +- `null` in map means "no filter condition" (show all) +- `default` key specifies which value doesn't trigger `isSearch()` +- Non-empty values not in map pass through directly as filter condition +- Sets `alwaysRun: true` and `filterEmpty: false` by default + +---------- + ## Multi-field Search Callbacks When using callback filters that need to access values from multiple form fields, @@ -314,6 +342,21 @@ The following options are supported by all filters except `Callback` and `Finder E.g. `!` for string values or `-` for numeric values. If enabled, the filter will negate the expression for this value. +### `Mapped` + +- `map` (`array`, defaults to `[]`) An associative array mapping form values to filter + conditions. Keys are form values (strings), values are the conditions to apply. + Use `null` as a value to mean "no filter condition" (show all records). + +- `default` (`string|null`, defaults to `null`) The form value that should be treated + as the default. When the default value is used, `isSearch()` returns false (no + Reset button appears). + +Note: The `Mapped` filter sets `alwaysRun: true` and `filterEmpty: false` by default. +Any non-empty form value not found in the `map` will pass through directly as the +filter condition, making this useful for enum fields where only specific values +need special handling. + ### `Finder` - `finder` (`string`, defaults to the filter name) The [find type](https://book.cakephp.org/4/en/orm/retrieving-data-and-resultsets.html#custom-finder-methods) to use. diff --git a/src/Model/Filter/FilterMethodsTrait.php b/src/Model/Filter/FilterMethodsTrait.php index 6dd0cf6e..2c94e8b3 100644 --- a/src/Model/Filter/FilterMethodsTrait.php +++ b/src/Model/Filter/FilterMethodsTrait.php @@ -103,6 +103,23 @@ public function compare(string $name, array $config = []) return $this; } + /** + * Mapped method + * + * Maps form values to filter conditions. Useful for boolean/status filters + * with a default value that shouldn't trigger isSearch(). + * + * @param string $name Name + * @param array $config Config + * @return $this + */ + public function mapped(string $name, array $config = []) + { + $this->add($name, 'Search.Mapped', $config); + + return $this; + } + /** * Custom method * diff --git a/src/Model/Filter/Mapped.php b/src/Model/Filter/Mapped.php new file mode 100644 index 00000000..3089c148 --- /dev/null +++ b/src/Model/Filter/Mapped.php @@ -0,0 +1,108 @@ +mapped('enabled', [ + * 'map' => ['' => true, '0' => false, '-1' => null], + * 'default' => '', + * ]); + * ``` + * + * Enum example (unmapped values pass through directly): + * ``` + * $this->mapped('status', [ + * 'map' => ['' => 'pending', '-1' => null], + * 'default' => '', + * ]); + * // 'active', 'completed' etc. pass through as-is + * ``` + * + * - Map keys are form values, map values are the condition to apply + * - `null` in map means "no filter condition" (show all) + * - `default` key specifies which value doesn't trigger isSearch() + * - Non-empty values not in map pass through directly as filter condition + */ +class Mapped extends Base +{ + /** + * @var array + */ + protected array $_defaultConfig = [ + 'fields' => null, + 'map' => [], + 'default' => null, + ]; + + /** + * Constructor - sets alwaysRun and filterEmpty defaults for this filter type. + * + * @param string $name Filter name + * @param \Search\Manager $manager Search Manager + * @param array $config Config + */ + public function __construct(string $name, Manager $manager, array $config = []) + { + // Mapped filter needs alwaysRun=true to apply default, filterEmpty=false to allow empty string + $config += [ + 'alwaysRun' => true, + 'filterEmpty' => false, + ]; + + parent::__construct($name, $manager, $config); + } + + /** + * Process the mapped filter. + * + * @return bool Whether this counts as an active search + */ + public function process(): bool + { + $value = $this->value(); + $map = $this->getConfig('map'); + $default = $this->getConfig('default'); + $fields = $this->fields(); + + // Normalize null to empty string for map lookup + $lookupValue = $value ?? ''; + $isDefault = false; + $condition = null; + + if (array_key_exists($lookupValue, $map)) { + // Value is in map - use mapped condition + $condition = $map[$lookupValue]; + $isDefault = ($lookupValue === $default); + } elseif ($lookupValue !== '') { + // Non-empty value not in map - pass through directly + $condition = $lookupValue; + } elseif ($default !== null && array_key_exists($default, $map)) { + // Empty value, fall back to default + $condition = $map[$default]; + $isDefault = true; + } else { + // No value, no default - skip entirely + return false; + } + + // null in map = no filter condition + if ($condition !== null) { + foreach ($fields as $field) { + $this->getQuery()->where([$field => $condition]); + } + } + + // Return false (not a search) only when using default + return !$isDefault; + } +} diff --git a/tests/TestCase/Model/Filter/MappedTest.php b/tests/TestCase/Model/Filter/MappedTest.php new file mode 100644 index 00000000..5847fc6f --- /dev/null +++ b/tests/TestCase/Model/Filter/MappedTest.php @@ -0,0 +1,198 @@ +getTableLocator()->get('Articles'); + $manager = new Manager($articles); + $filter = new Mapped('is_active', $manager, [ + 'map' => ['' => true, '0' => false, '-1' => null], + 'default' => '', + ]); + $filter->setArgs([]); + $filter->setQuery($articles->find()); + $result = $filter->process(); + + // Default should not count as active search + $this->assertFalse($result); + $this->assertMatchesRegularExpression( + '/WHERE Articles\.is_active = \:c0$/', + $filter->getQuery()->sql(), + ); + $this->assertSame( + [true], + Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value'), + ); + } + + /** + * Test explicit value from map is applied. + * + * @return void + */ + public function testProcessWithMappedValue() + { + $articles = $this->getTableLocator()->get('Articles'); + $manager = new Manager($articles); + $filter = new Mapped('is_active', $manager, [ + 'map' => ['' => true, '0' => false, '-1' => null], + 'default' => '', + ]); + $filter->setArgs(['is_active' => '0']); + $filter->setQuery($articles->find()); + $result = $filter->process(); + + // Explicit value should count as active search + $this->assertTrue($result); + $this->assertMatchesRegularExpression( + '/WHERE Articles\.is_active = \:c0$/', + $filter->getQuery()->sql(), + ); + $this->assertSame( + [false], + Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value'), + ); + } + + /** + * Test null in map means no filter condition. + * + * @return void + */ + public function testProcessWithNullMapping() + { + $articles = $this->getTableLocator()->get('Articles'); + $manager = new Manager($articles); + $filter = new Mapped('is_active', $manager, [ + 'map' => ['' => true, '0' => false, '-1' => null], + 'default' => '', + ]); + $filter->setArgs(['is_active' => '-1']); + $filter->setQuery($articles->find()); + $result = $filter->process(); + + // Should count as active search + $this->assertTrue($result); + // But no WHERE clause should be added + $this->assertEmpty($filter->getQuery()->clause('where')); + } + + /** + * Test unmapped non-empty values pass through directly. + * + * @return void + */ + public function testProcessUnmappedValuePassthrough() + { + $articles = $this->getTableLocator()->get('Articles'); + $manager = new Manager($articles); + $filter = new Mapped('category', $manager, [ + 'map' => ['' => 'default_category', '-1' => null], + 'default' => '', + ]); + $filter->setArgs(['category' => 'custom_value']); + $filter->setQuery($articles->find()); + $result = $filter->process(); + + // Passthrough value should count as active search + $this->assertTrue($result); + $this->assertMatchesRegularExpression( + '/WHERE Articles\.category = \:c0$/', + $filter->getQuery()->sql(), + ); + $this->assertSame( + ['custom_value'], + Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value'), + ); + } + + /** + * Test mapped values take precedence over passthrough. + * + * @return void + */ + public function testProcessMappedValueTakesPrecedence() + { + $articles = $this->getTableLocator()->get('Articles'); + $manager = new Manager($articles); + $filter = new Mapped('category', $manager, [ + 'map' => ['' => 'default_category', '-1' => null, 'special' => 'mapped_special'], + 'default' => '', + ]); + // 'special' is in the map, so it should use the mapped value, not passthrough + $filter->setArgs(['category' => 'special']); + $filter->setQuery($articles->find()); + $result = $filter->process(); + + $this->assertTrue($result); + $this->assertMatchesRegularExpression( + '/WHERE Articles\.category = \:c0$/', + $filter->getQuery()->sql(), + ); + $this->assertSame( + ['mapped_special'], + Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value'), + ); + } + + /** + * Test alwaysRun is true by default. + * + * @return void + */ + public function testAlwaysRunDefault() + { + $articles = $this->getTableLocator()->get('Articles'); + $manager = new Manager($articles); + $filter = new Mapped('is_active', $manager, [ + 'map' => ['' => true], + 'default' => '', + ]); + + $this->assertTrue($filter->getConfig('alwaysRun')); + $this->assertFalse($filter->getConfig('filterEmpty')); + } + + /** + * Test with empty string explicitly provided as arg. + * + * @return void + */ + public function testProcessWithEmptyStringArg() + { + $articles = $this->getTableLocator()->get('Articles'); + $manager = new Manager($articles); + $filter = new Mapped('is_active', $manager, [ + 'map' => ['' => true, '0' => false, '-1' => null], + 'default' => '', + ]); + $filter->setArgs(['is_active' => '']); + $filter->setQuery($articles->find()); + $result = $filter->process(); + + // Empty string is the default, so not an active search + $this->assertFalse($result); + $this->assertMatchesRegularExpression( + '/WHERE Articles\.is_active = \:c0$/', + $filter->getQuery()->sql(), + ); + } +}