1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui\Form\Control;
6:
7: use Atk4\Data\Field;
8: use Atk4\Data\Model;
9: use Atk4\Data\Model\Scope;
10: use Atk4\Data\Model\Scope\Condition;
11: use Atk4\Ui\Exception;
12: use Atk4\Ui\Form;
13: use Atk4\Ui\HtmlTemplate;
14: use Atk4\Ui\View;
15:
16: class ScopeBuilder extends Form\Control
17: {
18: public $renderLabel = false;
19:
20: public array $options = [
21: 'enum' => [
22: 'limit' => 250,
23: ],
24: 'debug' => false, // displays query output live on the page if set to true
25: ];
26: /**
27: * Max depth of nested conditions allowed.
28: * Corresponds to VueQueryBulder maxDepth.
29: * Maximum support by JS component is 10.
30: */
31: public int $maxDepth = 5;
32:
33: /** Fields to use for creating the rules. */
34: public array $fields = [];
35:
36: /** @var HtmlTemplate|null The template needed for the ScopeBuilder view. */
37: public $scopeBuilderTemplate;
38:
39: /** List of delimiters for auto-detection in order of priority. */
40: public static array $listDelimiters = [';', ','];
41:
42: /** The date, time or datetime options. */
43: public array $atkdDateOptions = [
44: 'flatpickr' => [],
45: ];
46:
47: /** AtkLookup and Fomantic-UI dropdown options. */
48: public array $atkLookupOptions = [
49: 'ui' => 'small basic button',
50: ];
51:
52: /** @var View The scopebuilder View. Assigned in init(). */
53: protected $scopeBuilderView;
54:
55: /** Definition of VueQueryBuilder rules. */
56: protected array $rules = [];
57:
58: /**
59: * Set Labels for Vue-Query-Builder
60: * see https://dabernathy89.github.io/vue-query-builder/configuration.html#labels.
61: */
62: public array $labels = [];
63:
64: /** Default VueQueryBuilder query. */
65: protected array $query = [];
66:
67: protected const OPERATOR_TEXT_EQUALS = 'equals';
68: protected const OPERATOR_TEXT_DOESNOT_EQUAL = 'does not equal';
69: protected const OPERATOR_TEXT_GREATER = 'is alphabetically after';
70: protected const OPERATOR_TEXT_GREATER_EQUAL = 'is alphabetically equal or after';
71: protected const OPERATOR_TEXT_LESS = 'is alphabetically before';
72: protected const OPERATOR_TEXT_LESS_EQUAL = 'is alphabetically equal or before';
73: protected const OPERATOR_TEXT_CONTAINS = 'contains';
74: protected const OPERATOR_TEXT_DOESNOT_CONTAIN = 'does not contain';
75: protected const OPERATOR_TEXT_BEGINS_WITH = 'begins with';
76: protected const OPERATOR_TEXT_DOESNOT_BEGIN_WITH = 'does not begin with';
77: protected const OPERATOR_TEXT_ENDS_WITH = 'ends with';
78: protected const OPERATOR_TEXT_DOESNOT_END_WITH = 'does not end with';
79: protected const OPERATOR_TEXT_MATCHES_REGEX = 'matches regular expression';
80: protected const OPERATOR_TEXT_DOESNOT_MATCH_REGEX = 'does not match regular expression';
81: protected const OPERATOR_SIGN_EQUALS = '=';
82: protected const OPERATOR_SIGN_DOESNOT_EQUAL = '<>';
83: protected const OPERATOR_SIGN_GREATER = '>';
84: protected const OPERATOR_SIGN_GREATER_EQUAL = '>=';
85: protected const OPERATOR_SIGN_LESS = '<';
86: protected const OPERATOR_SIGN_LESS_EQUAL = '<=';
87: protected const OPERATOR_TIME_EQUALS = 'is on';
88: protected const OPERATOR_TIME_DOESNOT_EQUAL = 'is not on';
89: protected const OPERATOR_TIME_GREATER = 'is after';
90: protected const OPERATOR_TIME_GREATER_EQUAL = 'is on or after';
91: protected const OPERATOR_TIME_LESS = 'is before';
92: protected const OPERATOR_TIME_LESS_EQUAL = 'is on or before';
93: protected const OPERATOR_EQUALS = 'equals';
94: protected const OPERATOR_DOESNOT_EQUAL = 'does not equal';
95: protected const OPERATOR_IN = 'is in';
96: protected const OPERATOR_NOT_IN = 'is not in';
97: protected const OPERATOR_EMPTY = 'is empty';
98: protected const OPERATOR_NOT_EMPTY = 'is not empty';
99:
100: protected const DATE_OPERATORS = [
101: self::OPERATOR_TIME_EQUALS,
102: self::OPERATOR_TIME_DOESNOT_EQUAL,
103: self::OPERATOR_TIME_GREATER,
104: self::OPERATOR_TIME_GREATER_EQUAL,
105: self::OPERATOR_TIME_LESS,
106: self::OPERATOR_TIME_LESS_EQUAL,
107: self::OPERATOR_EMPTY,
108: self::OPERATOR_NOT_EMPTY,
109: ];
110:
111: protected const ENUM_OPERATORS = [
112: self::OPERATOR_EQUALS,
113: self::OPERATOR_DOESNOT_EQUAL,
114: self::OPERATOR_EMPTY,
115: self::OPERATOR_NOT_EMPTY,
116: ];
117:
118: protected const DATE_OPERATORS_MAP = [
119: self::OPERATOR_TIME_EQUALS => Condition::OPERATOR_EQUALS,
120: self::OPERATOR_TIME_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
121: self::OPERATOR_TIME_GREATER => Condition::OPERATOR_GREATER,
122: self::OPERATOR_TIME_GREATER_EQUAL => Condition::OPERATOR_GREATER_EQUAL,
123: self::OPERATOR_TIME_LESS => Condition::OPERATOR_LESS,
124: self::OPERATOR_TIME_LESS_EQUAL => Condition::OPERATOR_LESS_EQUAL,
125: ];
126:
127: /**
128: * VueQueryBulder => Condition map of operators.
129: *
130: * Operator map supports also inputType specific operators in sub maps
131: *
132: * @var array<string, array<string, string>>
133: */
134: protected static array $operatorsMap = [
135: 'number' => [
136: self::OPERATOR_SIGN_EQUALS => Condition::OPERATOR_EQUALS,
137: self::OPERATOR_SIGN_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
138: self::OPERATOR_SIGN_GREATER => Condition::OPERATOR_GREATER,
139: self::OPERATOR_SIGN_GREATER_EQUAL => Condition::OPERATOR_GREATER_EQUAL,
140: self::OPERATOR_SIGN_LESS => Condition::OPERATOR_LESS,
141: self::OPERATOR_SIGN_LESS_EQUAL => Condition::OPERATOR_LESS_EQUAL,
142: ],
143: 'date' => self::DATE_OPERATORS_MAP,
144: 'time' => self::DATE_OPERATORS_MAP,
145: 'datetime' => self::DATE_OPERATORS_MAP,
146: 'text' => [
147: self::OPERATOR_TEXT_EQUALS => Condition::OPERATOR_EQUALS,
148: self::OPERATOR_TEXT_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
149: self::OPERATOR_TEXT_GREATER => Condition::OPERATOR_GREATER,
150: self::OPERATOR_TEXT_GREATER_EQUAL => Condition::OPERATOR_GREATER_EQUAL,
151: self::OPERATOR_TEXT_LESS => Condition::OPERATOR_LESS,
152: self::OPERATOR_TEXT_LESS_EQUAL => Condition::OPERATOR_LESS_EQUAL,
153: self::OPERATOR_TEXT_CONTAINS => Condition::OPERATOR_LIKE,
154: self::OPERATOR_TEXT_DOESNOT_CONTAIN => Condition::OPERATOR_NOT_LIKE,
155: self::OPERATOR_TEXT_BEGINS_WITH => Condition::OPERATOR_LIKE,
156: self::OPERATOR_TEXT_DOESNOT_BEGIN_WITH => Condition::OPERATOR_NOT_LIKE,
157: self::OPERATOR_TEXT_ENDS_WITH => Condition::OPERATOR_LIKE,
158: self::OPERATOR_TEXT_DOESNOT_END_WITH => Condition::OPERATOR_NOT_LIKE,
159: self::OPERATOR_IN => Condition::OPERATOR_IN,
160: self::OPERATOR_NOT_IN => Condition::OPERATOR_NOT_IN,
161: self::OPERATOR_TEXT_MATCHES_REGEX => Condition::OPERATOR_REGEXP,
162: self::OPERATOR_TEXT_DOESNOT_MATCH_REGEX => Condition::OPERATOR_NOT_REGEXP,
163: self::OPERATOR_EMPTY => Condition::OPERATOR_EQUALS,
164: self::OPERATOR_NOT_EMPTY => Condition::OPERATOR_DOESNOT_EQUAL,
165: ],
166: 'select' => [
167: self::OPERATOR_EQUALS => Condition::OPERATOR_EQUALS,
168: self::OPERATOR_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
169: ],
170: 'lookup' => [
171: self::OPERATOR_EQUALS => Condition::OPERATOR_EQUALS,
172: self::OPERATOR_DOESNOT_EQUAL => Condition::OPERATOR_DOESNOT_EQUAL,
173: ],
174: ];
175:
176: /** @var array<string, string|array<string, mixed>> Definition of rule types. */
177: protected static array $ruleTypes = [
178: 'default' => 'text',
179: 'text' => [
180: 'type' => 'text',
181: 'operators' => [
182: self::OPERATOR_TEXT_EQUALS,
183: self::OPERATOR_TEXT_DOESNOT_EQUAL,
184: self::OPERATOR_TEXT_GREATER,
185: self::OPERATOR_TEXT_GREATER_EQUAL,
186: self::OPERATOR_TEXT_LESS,
187: self::OPERATOR_TEXT_LESS_EQUAL,
188: self::OPERATOR_TEXT_CONTAINS,
189: self::OPERATOR_TEXT_DOESNOT_CONTAIN,
190: self::OPERATOR_TEXT_BEGINS_WITH,
191: self::OPERATOR_TEXT_DOESNOT_BEGIN_WITH,
192: self::OPERATOR_TEXT_ENDS_WITH,
193: self::OPERATOR_TEXT_DOESNOT_END_WITH,
194: self::OPERATOR_TEXT_MATCHES_REGEX,
195: self::OPERATOR_TEXT_DOESNOT_MATCH_REGEX,
196: self::OPERATOR_IN,
197: self::OPERATOR_NOT_IN,
198: self::OPERATOR_EMPTY,
199: self::OPERATOR_NOT_EMPTY,
200: ],
201: ],
202: 'lookup' => [
203: 'type' => 'custom-component',
204: 'inputType' => 'lookup',
205: 'component' => 'AtkLookup',
206: 'operators' => self::ENUM_OPERATORS,
207: 'componentProps' => [__CLASS__, 'getLookupProps'],
208: ],
209: 'enum' => [
210: 'type' => 'select',
211: 'inputType' => 'select',
212: 'operators' => self::ENUM_OPERATORS,
213: 'choices' => [__CLASS__, 'getChoices'],
214: ],
215: 'numeric' => [
216: 'type' => 'text',
217: 'inputType' => 'number',
218: 'operators' => [
219: self::OPERATOR_SIGN_EQUALS,
220: self::OPERATOR_SIGN_DOESNOT_EQUAL,
221: self::OPERATOR_SIGN_GREATER,
222: self::OPERATOR_SIGN_GREATER_EQUAL,
223: self::OPERATOR_SIGN_LESS,
224: self::OPERATOR_SIGN_LESS_EQUAL,
225: self::OPERATOR_EMPTY,
226: self::OPERATOR_NOT_EMPTY,
227: ],
228: ],
229: 'boolean' => [
230: 'type' => 'radio',
231: 'operators' => [],
232: 'choices' => [
233: ['label' => 'Yes', 'value' => '1'],
234: ['label' => 'No', 'value' => '0'],
235: ],
236: ],
237: 'date' => [
238: 'type' => 'custom-component',
239: 'component' => 'AtkDatePicker',
240: 'inputType' => 'date',
241: 'operators' => self::DATE_OPERATORS,
242: 'componentProps' => [__CLASS__, 'getDatePickerProps'],
243: ],
244: 'datetime' => [
245: 'type' => 'custom-component',
246: 'component' => 'AtkDatePicker',
247: 'inputType' => 'datetime',
248: 'operators' => self::DATE_OPERATORS,
249: 'componentProps' => [__CLASS__, 'getDatePickerProps'],
250: ],
251: 'time' => [
252: 'type' => 'custom-component',
253: 'component' => 'AtkDatePicker',
254: 'inputType' => 'time',
255: 'operators' => self::DATE_OPERATORS,
256: 'componentProps' => [__CLASS__, 'getDatePickerProps'],
257: ],
258: 'integer' => 'numeric',
259: 'float' => 'numeric',
260: 'atk4_money' => 'numeric',
261: 'checkbox' => 'boolean',
262: ];
263:
264: #[\Override]
265: protected function init(): void
266: {
267: parent::init();
268:
269: if (!$this->scopeBuilderTemplate) {
270: $this->scopeBuilderTemplate = new HtmlTemplate('<div {$attributes}><atk-query-builder v-bind="initData"></atk-query-builder></div>');
271: }
272:
273: $this->scopeBuilderView = View::addTo($this, ['template' => $this->scopeBuilderTemplate]);
274:
275: if ($this->form) {
276: $this->form->onHook(Form::HOOK_LOAD_POST, function (Form $form, array &$postRawData) {
277: $key = $this->entityField->getFieldName();
278: $postRawData[$key] = $this->queryToScope($this->getApp()->decodeJson($postRawData[$key]));
279: });
280: }
281: }
282:
283: /**
284: * Set the model to build scope for.
285: */
286: #[\Override]
287: public function setModel(Model $model): void
288: {
289: parent::setModel($model);
290:
291: $this->buildQuery($model);
292: }
293:
294: /**
295: * Build query from model scope.
296: */
297: protected function buildQuery(Model $model): void
298: {
299: if (!$this->fields) {
300: $this->fields = array_keys($model->getFields());
301: }
302:
303: foreach ($this->fields as $fieldName) {
304: $field = $model->getField($fieldName);
305:
306: $this->addFieldRule($field);
307:
308: $this->addReferenceRules($field);
309: }
310:
311: // build a ruleId => inputType map
312: // this is used when selecting proper operator for the inputType (see self::$operatorsMap)
313: $inputsMap = array_column($this->rules, 'inputType', 'id');
314:
315: if ($this->entityField && $this->entityField->get() !== null) {
316: $scope = $this->entityField->get();
317: } else {
318: $scope = $model->scope();
319: }
320:
321: $this->query = $this->scopeToQuery($scope, $inputsMap)['query'];
322: }
323:
324: /**
325: * Add the field rules to use in VueQueryBuilder.
326: */
327: protected function addFieldRule(Field $field): void
328: {
329: if ($field->enum || $field->values) {
330: $type = 'enum';
331: } elseif ($field->hasReference()) {
332: $type = 'lookup';
333: } else {
334: $type = $field->type;
335: }
336:
337: $rule = $this->getRule($type, array_merge([
338: 'id' => $field->shortName,
339: 'label' => $field->getCaption(),
340: 'options' => $this->options[$type] ?? [],
341: ], $field->ui['scopebuilder'] ?? []), $field);
342:
343: $this->rules[] = $rule;
344: }
345:
346: /**
347: * Set property for AtkLookup component.
348: */
349: protected function getLookupProps(Field $field): array
350: {
351: // set any of SuiDropdown props via this property
352: // will be applied globally
353: $props = $this->atkLookupOptions;
354: $items = $this->getFieldItems($field, 10);
355: foreach ($items as $value => $text) {
356: $props['options'][] = ['key' => $value, 'text' => $text, 'value' => $value];
357: }
358:
359: if ($field->hasReference()) {
360: $props['reference'] = $field->shortName;
361: $props['search'] = true;
362: }
363:
364: $props['placeholder'] ??= 'Select ' . $field->getCaption();
365:
366: return $props;
367: }
368:
369: /**
370: * Set property for AtkDatePicker component.
371: */
372: protected function getDatePickerProps(Field $field): array
373: {
374: $props = $this->atkdDateOptions['flatpickr'] ?? [];
375: $props['allowInput'] ??= true;
376:
377: $calendar = new Calendar();
378: $phpFormat = $this->getApp()->uiPersistence->{$field->type . 'Format'};
379: $props['dateFormat'] = $calendar->convertPhpDtFormatToFlatpickr($phpFormat, true);
380: if ($field->type === 'datetime' || $field->type === 'time') {
381: $props['noCalendar'] = $field->type === 'time';
382: $props['enableTime'] = true;
383: $props['time_24hr'] = $calendar->isDtFormatWith24hrTime($phpFormat);
384: $props['enableSeconds'] ??= $calendar->isDtFormatWithSeconds($phpFormat);
385: $props['formatSecondsPrecision'] ??= $calendar->isDtFormatWithMicroseconds($phpFormat) ? 6 : -1;
386: $props['disableMobile'] = true;
387: }
388:
389: return $props;
390: }
391:
392: /**
393: * Add rules on the referenced model fields.
394: */
395: protected function addReferenceRules(Field $field): void
396: {
397: if ($field->hasReference()) {
398: $reference = $field->getReference();
399:
400: // add the number of records rule
401: $this->rules[] = $this->getRule('numeric', [
402: 'id' => $reference->link . '/#',
403: 'label' => $field->getCaption() . ' number of records ',
404: ]);
405:
406: $theirModel = $reference->createTheirModel();
407:
408: // add rules on all fields of the referenced model
409: foreach ($theirModel->getFields() as $theirField) {
410: $theirField->ui['scopebuilder'] = [
411: 'id' => $reference->link . '/' . $theirField->shortName,
412: 'label' => $field->getCaption() . ' is set to record where ' . $theirField->getCaption(),
413: ];
414:
415: $this->addFieldRule($theirField);
416: }
417: }
418: }
419:
420: protected function getRule(string $type, array $defaults = [], Field $field = null): array
421: {
422: $rule = static::$ruleTypes[$type] ?? static::$ruleTypes['default'];
423:
424: // when $rule is an alias
425: if (is_string($rule)) {
426: return $this->getRule($rule, $defaults, $field);
427: }
428:
429: $options = $defaults['options'] ?? [];
430: unset($defaults['options']);
431:
432: // map all callables
433: foreach ($rule as $k => $v) {
434: if (is_array($v) && is_callable($v)) {
435: $rule[$k] = call_user_func($v, $field, $options);
436: }
437: }
438:
439: $rule = array_merge($rule, $defaults);
440:
441: return $rule;
442: }
443:
444: /**
445: * Return an array of items ID and name for a field.
446: * Return field enum, values or reference values.
447: */
448: protected function getFieldItems(Field $field, ?int $limit = 250): array
449: {
450: $items = [];
451: if ($field->enum !== null) {
452: $items = array_slice($field->enum, 0, $limit);
453: $items = array_combine($items, $items);
454: }
455: if ($field->values !== null) {
456: $items = array_slice($field->values, 0, $limit, true);
457: } elseif ($field->hasReference()) {
458: $model = $field->getReference()->refModel($this->model);
459: $model->setLimit($limit);
460:
461: foreach ($model as $item) {
462: $items[$item->get($field->getReference()->getTheirFieldName($model))] = $item->get($model->titleField);
463: }
464: }
465:
466: return $items;
467: }
468:
469: /**
470: * Returns the choices array for Select field rule.
471: */
472: protected function getChoices(Field $field, array $options = []): array
473: {
474: $choices = $this->getFieldItems($field, $options['limit'] ?? 250);
475:
476: $ret = [
477: ['label' => '[empty]', 'value' => null],
478: ];
479: foreach ($choices as $value => $label) {
480: $ret[] = ['label' => $label, 'value' => $value];
481: }
482:
483: return $ret;
484: }
485:
486: #[\Override]
487: protected function renderView(): void
488: {
489: parent::renderView();
490:
491: $this->scopeBuilderView->vue('atk-query-builder', [
492: 'data' => [
493: 'rules' => $this->rules,
494: 'maxDepth' => $this->maxDepth,
495: 'query' => $this->query,
496: 'name' => $this->shortName,
497: 'labels' => $this->labels !== [] ? $this->labels : null, // TODO do we need to really pass null for empty array?
498: 'form' => $this->form->formElement->name,
499: 'debug' => $this->options['debug'] ?? false,
500: ],
501: ]);
502: }
503:
504: /**
505: * Converts an VueQueryBuilder query array to Condition or Scope.
506: */
507: public function queryToScope(array $query): Scope\AbstractScope
508: {
509: if (!isset($query['type'])) {
510: $query = ['type' => 'query-builder-group', 'query' => $query];
511: }
512:
513: switch ($query['type']) {
514: case 'query-builder-rule':
515: $scope = $this->queryToCondition($query['query']);
516:
517: break;
518: case 'query-builder-group':
519: $components = array_map(fn ($v) => $this->queryToScope($v), $query['query']['children']);
520: $scope = new Scope($components, $query['query']['logicalOperator']);
521:
522: break;
523: }
524:
525: return $scope; // @phpstan-ignore-line
526: }
527:
528: /**
529: * Converts an VueQueryBuilder rule array to Condition or Scope.
530: */
531: public function queryToCondition(array $query): Scope\Condition
532: {
533: $key = $query['rule'];
534: $operator = $query['operator'];
535: $value = $query['value'];
536:
537: switch ($operator) {
538: case self::OPERATOR_EMPTY:
539: case self::OPERATOR_NOT_EMPTY:
540: $value = null;
541:
542: break;
543: case self::OPERATOR_TEXT_BEGINS_WITH:
544: case self::OPERATOR_TEXT_DOESNOT_BEGIN_WITH:
545: $value .= '%';
546:
547: break;
548: case self::OPERATOR_TEXT_ENDS_WITH:
549: case self::OPERATOR_TEXT_DOESNOT_END_WITH:
550: $value = '%' . $value;
551:
552: break;
553: case self::OPERATOR_TEXT_CONTAINS:
554: case self::OPERATOR_TEXT_DOESNOT_CONTAIN:
555: $value = '%' . $value . '%';
556:
557: break;
558: case self::OPERATOR_IN:
559: case self::OPERATOR_NOT_IN:
560: $value = explode($this->detectDelimiter($value), $value);
561:
562: break;
563: }
564:
565: $operatorsMap = array_merge(...array_values(static::$operatorsMap));
566:
567: $operator = $operator ? ($operatorsMap[strtolower($operator)] ?? '=') : null;
568:
569: return new Scope\Condition($key, $operator, $value);
570: }
571:
572: /**
573: * Converts Scope or Condition to VueQueryBuilder query array.
574: */
575: public function scopeToQuery(Scope\AbstractScope $scope, array $inputsMap = []): array
576: {
577: $query = [];
578: if ($scope instanceof Scope\Condition) {
579: $query = [
580: 'type' => 'query-builder-rule',
581: 'query' => $this->conditionToQuery($scope, $inputsMap),
582: ];
583: }
584:
585: if ($scope instanceof Scope) {
586: $children = [];
587: foreach ($scope->getNestedConditions() as $nestedCondition) {
588: $children[] = $this->scopeToQuery($nestedCondition, $inputsMap);
589: }
590:
591: $query = [
592: 'type' => 'query-builder-group',
593: 'query' => [
594: 'logicalOperator' => $scope->getJunction(),
595: 'children' => $children,
596: ],
597: ];
598: }
599:
600: return $query;
601: }
602:
603: /**
604: * Converts a Condition to VueQueryBuilder query array.
605: */
606: public function conditionToQuery(Scope\Condition $condition, array $inputsMap = []): array
607: {
608: if (is_string($condition->key)) {
609: $rule = $condition->key;
610: } elseif ($condition->key instanceof Field) {
611: $rule = $condition->key->shortName;
612: } else {
613: throw new Exception('Unsupported scope key: ' . gettype($condition->key));
614: }
615:
616: $operator = $condition->operator;
617: $value = $condition->value;
618:
619: $inputType = $inputsMap[$rule] ?? 'text';
620:
621: if (in_array($operator, [Condition::OPERATOR_LIKE, Condition::OPERATOR_NOT_LIKE], true)) {
622: // no %
623: $match = 0;
624: // % at the beginning
625: $match += substr($value, 0, 1) === '%' ? 1 : 0;
626: // % at the end
627: $match += substr($value, -1) === '%' ? 2 : 0;
628:
629: $map = [
630: Condition::OPERATOR_LIKE => [
631: self::OPERATOR_TEXT_EQUALS,
632: self::OPERATOR_TEXT_BEGINS_WITH,
633: self::OPERATOR_TEXT_ENDS_WITH,
634: self::OPERATOR_TEXT_CONTAINS,
635: ],
636: Condition::OPERATOR_NOT_LIKE => [
637: self::OPERATOR_TEXT_DOESNOT_EQUAL,
638: self::OPERATOR_TEXT_DOESNOT_BEGIN_WITH,
639: self::OPERATOR_TEXT_DOESNOT_END_WITH,
640: self::OPERATOR_TEXT_DOESNOT_CONTAIN,
641: ],
642: ];
643:
644: $operator = $map[strtoupper($operator)][$match];
645:
646: $value = trim($value, '%');
647: } else {
648: if (is_array($value)) {
649: $map = [
650: Condition::OPERATOR_EQUALS => Condition::OPERATOR_IN,
651: Condition::OPERATOR_DOESNOT_EQUAL => Condition::OPERATOR_NOT_IN,
652: ];
653: $value = implode(', ', $value);
654: $operator = $map[$operator] ?? Condition::OPERATOR_NOT_IN;
655: }
656:
657: $operatorsMap = array_merge(static::$operatorsMap[$inputType] ?? [], static::$operatorsMap['text']);
658: $operatorKey = array_search(strtoupper($operator), $operatorsMap, true);
659: $operator = $operatorKey !== false ? $operatorKey : self::OPERATOR_EQUALS;
660: }
661:
662: return [
663: 'rule' => $rule,
664: 'operator' => $operator,
665: 'value' => $this->getApp()->uiPersistence->typecastSaveField($this->model->getField($rule), $value),
666: 'option' => $this->getConditionOption($inputType, $value, $condition),
667: ];
668: }
669:
670: /**
671: * Return extra value option associate with certain inputType or null otherwise.
672: *
673: * @param mixed $value
674: */
675: protected function getConditionOption(string $type, $value, Condition $condition): ?array
676: {
677: $option = null;
678: switch ($type) {
679: case 'lookup':
680: $condField = $condition->getModel()->getField($condition->key);
681: $reference = $condField->getReference();
682: $model = $reference->refModel($condField->getOwner());
683: $fieldName = $reference->getTheirFieldName($model);
684: $entity = $model->tryLoadBy($fieldName, $value);
685: if ($entity !== null) {
686: $option = [
687: 'key' => $value,
688: 'text' => $entity->get($model->titleField),
689: 'value' => $value,
690: ];
691: }
692:
693: break;
694: }
695:
696: return $option;
697: }
698:
699: /**
700: * Auto-detects a string delimiter based on list of predefined values in ScopeBuilder::$listDelimiters in order of priority.
701: *
702: * @return non-empty-string
703: */
704: public function detectDelimiter(string $value): string
705: {
706: $matches = [];
707: foreach (static::$listDelimiters as $delimiter) {
708: $matches[$delimiter] = substr_count($value, $delimiter);
709: }
710:
711: $max = array_keys($matches, max($matches), true);
712:
713: return $max !== [] ? reset($max) : reset(static::$listDelimiters);
714: }
715: }
716: