1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui\Form\Control;
6:
7: use Atk4\Core\Exception as CoreException;
8: use Atk4\Data\Field;
9: use Atk4\Data\Field\CallbackField;
10: use Atk4\Data\Field\SqlExpressionField;
11: use Atk4\Data\Model;
12: use Atk4\Data\Persistence;
13: use Atk4\Data\ValidationException;
14: use Atk4\Ui\Exception;
15: use Atk4\Ui\Form;
16: use Atk4\Ui\HtmlTemplate;
17: use Atk4\Ui\Js\JsExpressionable;
18: use Atk4\Ui\Js\JsFunction;
19: use Atk4\Ui\JsCallback;
20: use Atk4\Ui\View;
21:
22: /**
23: * Creates a Multiline field within a table, which allows adding/editing multiple
24: * data rows.
25: *
26: * Using hasMany reference will required to save reference data using Multiline::saveRows() method.
27: *
28: * $form = Form::addTo($app);
29: * $form->setModel($invoice, []);
30: *
31: * // add Multiline form control and set model for Invoice items
32: * $ml = $form->addControl('ml', [Multiline::class]);
33: * $ml->setReferenceModel('Items', null, ['item', 'cat', 'qty', 'price', 'total']);
34: *
35: * $form->onSubmit(function (Form $form) use ($ml) {
36: * // save Form model and then Multiline model
37: * $form->model->save(); // saving invoice record
38: * $ml->saveRows(); // saving invoice items record related to invoice
39: * return new JsToast('Saved!');
40: * });
41: *
42: * If Multiline's model contains expressions, these will be evaluated on the fly
43: * whenever data gets entered.
44: *
45: * Multiline input also has an onChange callback that will return all data rows
46: * in an array. It is also possible to fire onChange handler only for certain
47: * fields by passing them as an array to the method.
48: *
49: * Note that deleting a row will always fire the onChange callback.
50: *
51: * You can use the returned data to update other related areas of the form.
52: * For example, updating Grand Total field of all invoice items.
53: *
54: * $ml->onChange(function (array $rows) use ($form) {
55: * $grandTotal = 0;
56: * foreach ($rows as $row => $cols) {
57: * foreach ($cols as $col) {
58: * $fieldName = array_key_first($col);
59: * if ($fieldName === 'total') {
60: * $grandTotal += $col[$fieldName];
61: * }
62: * }
63: * }
64: *
65: * return $form->js(false, null, 'input[name="grand_total"]')->val($app->uiPersistence->typecastSaveField(new Field(['type' => 'atk4_money']), $grandTotal));
66: * }, ['qty', 'price']);
67: *
68: * Finally, it's also possible to use Multiline for quickly adding records to a
69: * model. Be aware that in the example below all User records will be displayed.
70: * If your model contains a lot of records, you should handle their limit somehow.
71: *
72: * $form = Form::addTo($app);
73: * $ml = $form->addControl('ml', [Form\Control\Multiline::class]);
74: * $ml->setModel($user, ['name', 'is_vip']);
75: *
76: * $form->onSubmit(function (Form $form) use ($ml) {
77: * $ml->saveRows();
78: * return new JsToast('Saved!');
79: * });
80: */
81: class Multiline extends Form\Control
82: {
83: /** @var HtmlTemplate|null The template needed for the multiline view. */
84: public $multiLineTemplate;
85:
86: /** @var View The multiline View. Assigned in init(). */
87: private $multiLine;
88:
89: // component names
90: public const INPUT = 'SuiInput';
91: public const READ_ONLY = 'AtkMultilineReadonly';
92: public const TEXT_AREA = 'AtkMultilineTextarea';
93: public const SELECT = 'SuiDropdown';
94: public const DATE = 'AtkDatePicker';
95: public const LOOKUP = 'AtkLookup';
96:
97: public const TABLE_CELL = 'SuiTableCell';
98:
99: /**
100: * Props to be applied globally for each component supported by field type.
101: * For example setting 'SuiDropdown' property globally.
102: * $componentProps = [Multiline::SELECT => ['floating' => true]].
103: *
104: * @var array
105: */
106: public $componentProps = [];
107:
108: /** @var array SuiTable component props */
109: public $tableProps = [];
110:
111: /** @var array<string, array<string, mixed>> Set Vue component to use per field type. */
112: protected $fieldMapToComponent = [
113: 'default' => [
114: 'component' => self::INPUT,
115: 'componentProps' => [__CLASS__, 'getSuiInputProps'],
116: ],
117: 'readonly' => [
118: 'component' => self::READ_ONLY,
119: 'componentProps' => [],
120: ],
121: 'textarea' => [
122: 'component' => self::TEXT_AREA,
123: 'componentProps' => [],
124: ],
125: 'select' => [
126: 'component' => self::SELECT,
127: 'componentProps' => [__CLASS__, 'getDropdownProps'],
128: ],
129: 'date' => [
130: 'component' => self::DATE,
131: 'componentProps' => [__CLASS__, 'getDatePickerProps'],
132: ],
133: 'lookup' => [
134: 'component' => self::LOOKUP,
135: 'componentProps' => [__CLASS__, 'getLookupProps'],
136: ],
137: ];
138:
139: /** @var bool Add row when tabbing out of last column in last row. */
140: public $addOnTab = false;
141:
142: /** @var array The definition of each field used in every multiline row. */
143: private $fieldDefs;
144:
145: /** @var JsCallback */
146: private $renderCallback;
147:
148: /** @var \Closure(mixed, Form): (JsExpressionable|View|string|void)|null Function to execute when field change or row is delete. */
149: protected $onChangeFunction;
150:
151: /** @var array Set fields that will trigger onChange function. */
152: protected $eventFields;
153:
154: /** @var array Collection of field errors. */
155: private $rowErrors;
156:
157: /** @var array The fields names used in each row. */
158: public $rowFields;
159:
160: /** @var array The data sent for each row. */
161: public $rowData;
162:
163: /** @var int The max number of records (rows) that can be added to Multiline. 0 means no limit. */
164: public $rowLimit = 0;
165:
166: /** @var int The maximum number of items for select type field. */
167: public $itemLimit = 25;
168:
169: /**
170: * Container for component that need Props set based on their field value as Lookup component.
171: * Set during fieldDefinition and apply during renderView() after getValue().
172: * Must contains callable function and function will receive $model field and value as parameter.
173: *
174: * @var array<string, \Closure(Field, string): void>
175: */
176: private $valuePropsBinding = [];
177:
178: /**
179: * A JsFunction to execute when Multiline add(+) button is clicked.
180: * The function is execute after multiline component finish adding a row of fields.
181: * The function also receive the row value as an array.
182: * ex: $jsAfterAdd = new JsFunction(['value'], [new JsExpression('console.log(value)')]);.
183: *
184: * @var JsFunction
185: */
186: public $jsAfterAdd;
187:
188: /**
189: * A JsFunction to execute when Multiline delete button is clicked.
190: * The function is execute after multiline component finish deleting rows.
191: * The function also receive the row value as an array.
192: * ex: $jsAfterDelete = new JsFunction(['value'], [new JsExpression('console.log(value)')]);.
193: *
194: * @var JsFunction
195: */
196: public $jsAfterDelete;
197:
198: #[\Override]
199: protected function init(): void
200: {
201: parent::init();
202:
203: if (!$this->multiLineTemplate) {
204: $this->multiLineTemplate = new HtmlTemplate('<div {$attributes}><atk-multiline v-bind="initData"></atk-multiline></div>');
205: }
206:
207: $this->multiLine = View::addTo($this, ['template' => $this->multiLineTemplate]);
208:
209: $this->renderCallback = JsCallback::addTo($this);
210:
211: // load the data associated with this input and validate it
212: $this->form->onHook(Form::HOOK_LOAD_POST, function (Form $form, array &$postRawData) {
213: $this->rowData = $this->typeCastLoadValues($this->getApp()->decodeJson($this->getApp()->getRequestPostParam($this->shortName)));
214: if ($this->rowData) {
215: $this->rowErrors = $this->validate($this->rowData);
216: if ($this->rowErrors) {
217: throw new ValidationException([$this->shortName => 'multiline error']);
218: }
219: }
220:
221: // remove __atml ID from array field
222: if ($this->form->model->getField($this->shortName)->type === 'json') {
223: $rows = [];
224: foreach ($this->rowData as $key => $cols) {
225: unset($cols['__atkml']);
226: $rows[] = $cols;
227: }
228: $postRawData[$this->shortName] = $this->getApp()->encodeJson($rows);
229: }
230: });
231:
232: // change form error handling
233: $this->form->onHook(Form::HOOK_DISPLAY_ERROR, function (Form $form, $fieldName, $str) {
234: // when errors are coming from this Multiline field, then notify Multiline component about them
235: // otherwise use normal field error
236: if ($fieldName === $this->shortName) {
237: // multiline.js component listen to 'multiline-rows-error' event
238: $jsError = $this->jsEmitEvent($this->multiLine->name . '-multiline-rows-error', ['errors' => $this->rowErrors]);
239: } else {
240: $jsError = $form->js()->form('add prompt', $fieldName, $str);
241: }
242:
243: return $jsError;
244: });
245: }
246:
247: protected function typeCastLoadValues(array $values): array
248: {
249: $dataRows = [];
250: foreach ($values as $k => $row) {
251: foreach ($row as $fieldName => $value) {
252: if ($fieldName === '__atkml') {
253: $dataRows[$k][$fieldName] = $value;
254: } else {
255: $dataRows[$k][$fieldName] = $this->getApp()->uiPersistence->typecastLoadField($this->model->getField($fieldName), $value);
256: }
257: }
258: }
259:
260: return $dataRows;
261: }
262:
263: /**
264: * Add a callback when fields are changed. You must supply array of fields
265: * that will trigger the callback when changed.
266: *
267: * @param \Closure(mixed, Form): (JsExpressionable|View|string|void) $fx
268: */
269: public function onLineChange(\Closure $fx, array $fields): void
270: {
271: $this->eventFields = $fields;
272:
273: $this->onChangeFunction = $fx;
274: }
275:
276: /**
277: * Get Multiline initial field value. Value is based on model set and will
278: * output data rows as JSON string value.
279: */
280: public function getValue(): string
281: {
282: if ($this->entityField->getField()->type === 'json') {
283: $jsonValues = $this->getApp()->uiPersistence->typecastSaveField($this->entityField->getField(), $this->entityField->get() ?? []);
284: } else {
285: // set data according to HasMany relation or using model
286: $rows = [];
287: foreach ($this->model as $row) {
288: $cols = [];
289: foreach ($this->rowFields as $fieldName) {
290: $field = $this->model->getField($fieldName);
291: $value = $this->getApp()->uiPersistence->typecastSaveField($field, $row->get($field->shortName));
292: $cols[$fieldName] = $value;
293: }
294: $rows[] = $cols;
295: }
296: $jsonValues = $this->getApp()->encodeJson($rows);
297: }
298:
299: return $jsonValues;
300: }
301:
302: /**
303: * Validate each row and return errors if found.
304: */
305: public function validate(array $rows): array
306: {
307: $rowErrors = [];
308: $entity = $this->model->createEntity();
309:
310: foreach ($rows as $cols) {
311: $rowId = $this->getMlRowId($cols);
312: foreach ($cols as $fieldName => $value) {
313: if ($fieldName === '__atkml' || $fieldName === $entity->idField) {
314: continue;
315: }
316:
317: try {
318: $field = $entity->getField($fieldName);
319: // save field value only if the field was editable
320: if (!$field->readOnly) {
321: $entity->set($fieldName, $value);
322: }
323: } catch (CoreException $e) {
324: $rowErrors[$rowId][] = ['name' => $fieldName, 'msg' => $e->getMessage()];
325: }
326: }
327: $rowErrors = $this->addModelValidateErrors($rowErrors, $rowId, $entity);
328: }
329:
330: return $rowErrors;
331: }
332:
333: /**
334: * @return $this
335: */
336: public function saveRows(): self
337: {
338: $model = $this->model;
339:
340: // collects existing IDs
341: $currentIds = array_column($model->export(), $model->idField);
342:
343: foreach ($this->rowData as $row) {
344: $entity = $row[$model->idField] !== null
345: ? $model->load($row[$model->idField])
346: : $model->createEntity();
347: foreach ($row as $fieldName => $value) {
348: if ($fieldName === '__atkml') {
349: continue;
350: }
351:
352: if ($model->getField($fieldName)->isEditable()) {
353: $entity->set($fieldName, $value);
354: }
355: }
356:
357: if (!$entity->isLoaded() || $entity->getDirtyRef() !== []) {
358: $entity->save();
359: }
360:
361: $k = array_search($entity->getId(), $currentIds, true);
362: if ($k !== false) {
363: unset($currentIds[$k]);
364: }
365: }
366:
367: // delete removed IDs
368: foreach ($currentIds as $id) {
369: $model->delete($id);
370: }
371:
372: return $this;
373: }
374:
375: /**
376: * Check for model validate error.
377: */
378: protected function addModelValidateErrors(array $errors, string $rowId, Model $entity): array
379: {
380: $entityErrors = $entity->validate();
381: if ($entityErrors) {
382: foreach ($entityErrors as $fieldName => $msg) {
383: $errors[$rowId][] = ['name' => $fieldName, 'msg' => $msg];
384: }
385: }
386:
387: return $errors;
388: }
389:
390: /**
391: * Finds and returns Multiline row ID.
392: */
393: private function getMlRowId(array $row): ?string
394: {
395: $rowId = null;
396: foreach ($row as $col => $value) {
397: if ($col === '__atkml') {
398: $rowId = $value;
399:
400: break;
401: }
402: }
403:
404: return $rowId;
405: }
406:
407: /**
408: * @param array<int, string>|null $fields
409: */
410: #[\Override]
411: public function setModel(Model $model, array $fields = null): void
412: {
413: parent::setModel($model);
414:
415: if ($fields === null) {
416: $fields = array_keys($model->getFields('not system'));
417: }
418: $this->rowFields = array_merge([$model->idField], $fields);
419:
420: foreach ($this->rowFields as $fieldName) {
421: $this->fieldDefs[] = $this->getFieldDef($model->getField($fieldName));
422: }
423: }
424:
425: /**
426: * Set hasMany reference model to use with multiline.
427: *
428: * Note: When using setReferenceModel you might need to set this corresponding field to neverPersist to true.
429: * Otherwise, form will try to save 'multiline' field value as an array when form is save.
430: * $multiline = $form->addControl('multiline', [Multiline::class], ['neverPersist' => true])
431: */
432: public function setReferenceModel(string $refModelName, Model $entity = null, array $fieldNames = []): void
433: {
434: if ($entity === null) {
435: if (!$this->form->model->isEntity()) {
436: throw new Exception('Model entity is not set');
437: }
438:
439: $entity = $this->form->model;
440: }
441:
442: $this->setModel($entity->ref($refModelName), $fieldNames);
443: }
444:
445: /**
446: * Return field definition in order to properly render them in Multiline.
447: *
448: * Multiline uses Vue components in order to manage input type based on field type.
449: * Component name and props are determine via the getComponentDefinition function.
450: */
451: public function getFieldDef(Field $field): array
452: {
453: return [
454: 'name' => $field->shortName,
455: 'type' => $field->type,
456: 'definition' => $this->getComponentDefinition($field),
457: 'cellProps' => $this->getSuiTableCellProps($field),
458: 'caption' => $field->getCaption(),
459: 'default' => $this->getApp()->uiPersistence->typecastSaveField($field, $field->default),
460: 'isExpr' => @isset($field->expr), // @phpstan-ignore-line
461: 'isEditable' => $field->isEditable(),
462: 'isHidden' => $field->isHidden(),
463: 'isVisible' => $field->isVisible(),
464: ];
465: }
466:
467: /**
468: * Each field input, represent by a Vue component, is place within a table cell.
469: * Cell properties can be customized via $field->ui['multiline'][Form\Control\Multiline::TABLE_CELL].
470: */
471: protected function getSuiTableCellProps(Field $field): array
472: {
473: $props = [];
474:
475: if ($field->type === 'integer' || $field->type === 'atk4_money') {
476: $props['text-align'] = 'right';
477: }
478:
479: return array_merge($props, $this->componentProps[self::TABLE_CELL] ?? [], $field->ui['multiline'][self::TABLE_CELL] ?? []);
480: }
481:
482: /**
483: * Return props for input component.
484: */
485: protected function getSuiInputProps(Field $field): array
486: {
487: $props = $this->componentProps[self::INPUT] ?? [];
488:
489: $props['type'] = ($field->type === 'integer' || $field->type === 'float' || $field->type === 'atk4_money') ? 'number' : 'text';
490:
491: return array_merge($props, $field->ui['multiline'][self::INPUT] ?? []);
492: }
493:
494: /**
495: * Return props for AtkDatePicker component.
496: */
497: protected function getDatePickerProps(Field $field): array
498: {
499: $props = [];
500: $props['config'] = $this->componentProps[self::DATE] ?? [];
501: $props['config']['allowInput'] ??= true;
502:
503: $calendar = new Calendar();
504: $phpFormat = $this->getApp()->uiPersistence->{$field->type . 'Format'};
505: $props['config']['dateFormat'] = $calendar->convertPhpDtFormatToFlatpickr($phpFormat, true);
506: if ($field->type === 'datetime' || $field->type === 'time') {
507: $props['config']['noCalendar'] = $field->type === 'time';
508: $props['config']['enableTime'] = true;
509: $props['config']['time_24hr'] = $calendar->isDtFormatWith24hrTime($phpFormat);
510: $props['config']['enableSeconds'] ??= $calendar->isDtFormatWithSeconds($phpFormat);
511: $props['config']['formatSecondsPrecision'] ??= $calendar->isDtFormatWithMicroseconds($phpFormat) ? 6 : -1;
512: $props['config']['disableMobile'] = true;
513: }
514:
515: return $props;
516: }
517:
518: /**
519: * Return props for Dropdown components.
520: */
521: protected function getDropdownProps(Field $field): array
522: {
523: $props = array_merge(
524: ['floating' => false, 'closeOnBlur' => true, 'selection' => true],
525: $this->componentProps[self::SELECT] ?? []
526: );
527:
528: $items = $this->getFieldItems($field, $this->itemLimit);
529: foreach ($items as $value => $text) {
530: $props['options'][] = ['key' => $value, 'text' => $text, 'value' => $value];
531: }
532:
533: return $props;
534: }
535:
536: /**
537: * Set property for AtkLookup component.
538: */
539: protected function getLookupProps(Field $field): array
540: {
541: // set any of SuiDropdown props via this property
542: // will be applied globally
543: $props = [];
544: $props['config'] = $this->componentProps[self::LOOKUP] ?? [];
545: $items = $this->getFieldItems($field, 10);
546: foreach ($items as $value => $text) {
547: $props['config']['options'][] = ['key' => $value, 'text' => $text, 'value' => $value];
548: }
549:
550: if ($field->hasReference()) {
551: $props['config']['reference'] = $field->shortName;
552: $props['config']['search'] = true;
553: }
554:
555: $props['config']['placeholder'] ??= 'Select ' . $field->getCaption();
556:
557: $this->valuePropsBinding[$field->shortName] = fn ($field, $value) => $this->setLookupOptionValue($field, $value);
558:
559: return $props;
560: }
561:
562: public function setLookupOptionValue(Field $field, string $value): void
563: {
564: $model = $field->getReference()->refModel($this->model);
565: $entity = $model->tryLoadBy($field->getReference()->getTheirFieldName($model), $value);
566: if ($entity !== null) {
567: $option = ['key' => $value, 'text' => $entity->get($model->titleField), 'value' => $value];
568: foreach ($this->fieldDefs as $key => $component) {
569: if ($component['name'] === $field->shortName) {
570: $this->fieldDefs[$key]['definition']['componentProps']['optionalValue'] =
571: isset($this->fieldDefs[$key]['definition']['componentProps']['optionalValue'])
572: ? array_merge($this->fieldDefs[$key]['definition']['componentProps']['optionalValue'], [$option])
573: : [$option];
574: }
575: }
576: }
577: }
578:
579: /**
580: * Component definition require at least a name and a props array.
581: */
582: protected function getComponentDefinition(Field $field): array
583: {
584: $name = $field->ui['multiline']['component'] ?? null;
585: if ($name) {
586: $component = $this->fieldMapToComponent[$name];
587: } elseif (!$field->isEditable()) {
588: $component = $this->fieldMapToComponent['readonly'];
589: } elseif ($field->enum || $field->values) {
590: $component = $this->fieldMapToComponent['select'];
591: } elseif ($field->type === 'date' || $field->type === 'time' || $field->type === 'datetime') {
592: $component = $this->fieldMapToComponent['date'];
593: } elseif ($field->type === 'text') {
594: $component = $this->fieldMapToComponent['textarea'];
595: } elseif ($field->hasReference()) {
596: $component = $this->fieldMapToComponent['lookup'];
597: } else {
598: $component = $this->fieldMapToComponent['default'];
599: }
600:
601: // map all callables defaults
602: foreach ($component as $k => $v) {
603: if (is_array($v) && is_callable($v)) {
604: $component[$k] = call_user_func($v, $field);
605: }
606: }
607:
608: return $component;
609: }
610:
611: protected function getFieldItems(Field $field, ?int $limit = 10): array
612: {
613: $items = [];
614: if ($field->enum !== null) {
615: $items = array_slice($field->enum, 0, $limit);
616: $items = array_combine($items, $items);
617: }
618: if ($field->values !== null) {
619: $items = array_slice($field->values, 0, $limit, true);
620: } elseif ($field->hasReference()) {
621: $model = $field->getReference()->refModel($this->model);
622: $model->setLimit($limit);
623:
624: foreach ($model as $item) {
625: $items[$item->get($field->getReference()->getTheirFieldName($model))] = $item->get($model->titleField);
626: }
627: }
628:
629: return $items;
630: }
631:
632: /**
633: * Apply Props to component that require props based on field value.
634: */
635: protected function valuePropsBinding(string $values): void
636: {
637: $fieldValues = $this->getApp()->decodeJson($values);
638:
639: foreach ($fieldValues as $rows) {
640: foreach ($rows as $fieldName => $value) {
641: if (isset($this->valuePropsBinding[$fieldName])) {
642: ($this->valuePropsBinding[$fieldName])($this->model->getField($fieldName), $value);
643: }
644: }
645: }
646: }
647:
648: #[\Override]
649: protected function renderView(): void
650: {
651: $this->model->assertIsModel();
652:
653: $this->renderCallback->set(function () {
654: $this->outputJson();
655: });
656:
657: parent::renderView();
658:
659: $inputValue = $this->getValue();
660: $this->valuePropsBinding($inputValue);
661:
662: $this->multiLine->vue('atk-multiline', [
663: 'data' => [
664: 'formName' => $this->form->formElement->name,
665: 'inputValue' => $inputValue,
666: 'inputName' => $this->shortName,
667: 'fields' => $this->fieldDefs,
668: 'url' => $this->renderCallback->getJsUrl(),
669: 'eventFields' => $this->eventFields,
670: 'hasChangeCb' => $this->onChangeFunction !== null,
671: 'tableProps' => $this->tableProps,
672: 'rowLimit' => $this->rowLimit,
673: 'caption' => $this->caption,
674: 'afterAdd' => $this->jsAfterAdd,
675: 'afterDelete' => $this->jsAfterDelete,
676: 'addOnTab' => $this->addOnTab,
677: ],
678: ]);
679: }
680:
681: /**
682: * Render callback according to multi line action.
683: * 'update-row' need special formatting.
684: */
685: private function outputJson(): void
686: {
687: switch ($this->getApp()->getRequestPostParam('__atkml_action')) {
688: case 'update-row':
689: $entity = $this->createDummyEntityFromPost($this->model);
690: $expressionValues = array_merge($this->getExpressionValues($entity), $this->getCallbackValues($entity));
691: $this->getApp()->terminateJson(['success' => true, 'expressions' => $expressionValues]);
692: // no break - expression above always terminate
693: case 'on-change':
694: $rowsRaw = $this->getApp()->decodeJson($this->getApp()->getRequestPostParam('rows'));
695: $response = ($this->onChangeFunction)($this->typeCastLoadValues($rowsRaw), $this->form);
696: $this->renderCallback->terminateAjax($this->renderCallback->getAjaxec($response));
697: // TODO JsCallback::terminateAjax() should return never
698: }
699: }
700:
701: /**
702: * Return values associated with callback field.
703: */
704: private function getCallbackValues(Model $entity): array
705: {
706: $values = [];
707: foreach ($this->fieldDefs as $def) {
708: $fieldName = $def['name'];
709: if ($fieldName === $entity->idField) {
710: continue;
711: }
712: $field = $entity->getField($fieldName);
713: if ($field instanceof CallbackField) {
714: $value = ($field->expr)($entity);
715: $values[$fieldName] = $this->getApp()->uiPersistence->typecastSaveField($field, $value);
716: }
717: }
718:
719: return $values;
720: }
721:
722: /**
723: * Looks inside the POST of the request and loads data into model.
724: * Allow to Run expression base on post row value.
725: */
726: private function createDummyEntityFromPost(Model $model): Model
727: {
728: $entity = (clone $model)->createEntity(); // clone for clearing "required"
729:
730: foreach ($this->fieldDefs as $def) {
731: $fieldName = $def['name'];
732: if ($fieldName === $entity->idField) {
733: continue;
734: }
735:
736: $field = $entity->getField($fieldName);
737:
738: $value = $this->getApp()->uiPersistence->typecastLoadField($field, $this->getApp()->getRequestPostParam($fieldName));
739: if ($field->isEditable()) {
740: try {
741: $field->required = false;
742: $entity->set($fieldName, $value);
743: } catch (ValidationException $e) {
744: // bypass validation at this point
745: }
746: }
747: }
748:
749: return $entity;
750: }
751:
752: /**
753: * Get all field expression in model, but only evaluate expression used in rowFields.
754: *
755: * @return array<string, SqlExpressionField>
756: */
757: private function getExpressionFields(Model $model): array
758: {
759: $fields = [];
760: foreach ($model->getFields() as $field) {
761: if (!in_array($field->shortName, $this->rowFields, true) || !$field instanceof SqlExpressionField) {
762: continue;
763: }
764:
765: $fields[$field->shortName] = $field;
766: }
767:
768: return $fields;
769: }
770:
771: /**
772: * Return values associated to field expression.
773: */
774: private function getExpressionValues(Model $entity): array
775: {
776: $dummyFields = $this->getExpressionFields($entity);
777: foreach ($dummyFields as $k => $field) {
778: $dummyFields[$k] = clone $field;
779: $dummyFields[$k]->expr = $this->getDummyExpression($field, $entity);
780: }
781:
782: if ($dummyFields === []) {
783: return [];
784: }
785:
786: $dummyModel = new Model($entity->getModel()->getPersistence(), ['table' => $entity->table]);
787: $dummyModel->removeField('id');
788: $dummyModel->idField = $entity->idField;
789:
790: $createExprFromValueFx = static function ($v) use ($dummyModel): Persistence\Sql\Expression {
791: if (is_int($v)) {
792: // TODO hack for multiline.php test for PostgreSQL
793: // related with https://github.com/atk4/data/pull/989
794: return $dummyModel->expr((string) $v);
795: }
796:
797: return $dummyModel->expr('[]', [$v]);
798: };
799:
800: foreach ($entity->getFields() as $field) {
801: $dummyModel->addExpression($field->shortName, [
802: 'expr' => isset($dummyFields[$field->shortName])
803: ? $dummyFields[$field->shortName]->expr
804: : ($field->shortName === $dummyModel->idField
805: ? '-1'
806: : $createExprFromValueFx($entity->getModel()->getPersistence()->typecastSaveField($field, $field->get($entity)))),
807: 'type' => $field->type,
808: 'actual' => $field->actual,
809: ]);
810: }
811: $dummyModel->setLimit(1); // TODO must work with empty table, no table should be used
812: $values = $dummyModel->loadOne()->get();
813: unset($values[$entity->idField]);
814:
815: $formatValues = [];
816: foreach ($values as $f => $value) {
817: if (isset($dummyFields[$f])) {
818: $field = $entity->getField($f);
819: $formatValues[$f] = $this->getApp()->uiPersistence->typecastSaveField($field, $value);
820: }
821: }
822:
823: return $formatValues;
824: }
825:
826: /**
827: * Return expression where fields are replace with their current or default value.
828: * Ex: total field expression = [qty] * [price] will return 4 * 100
829: * where qty and price current value are 4 and 100 respectively.
830: *
831: * @return string
832: */
833: private function getDummyExpression(SqlExpressionField $exprField, Model $entity)
834: {
835: $expr = $exprField->expr;
836: if ($expr instanceof \Closure) {
837: $expr = $exprField->getDsqlExpression($entity->getModel()->expr(''));
838: }
839: if ($expr instanceof Persistence\Sql\Expression) {
840: $expr = \Closure::bind(static fn () => $expr->template, null, Persistence\Sql\Expression::class)();
841: }
842:
843: $matches = [];
844: preg_match_all('~\[[a-z0-9_]*\]|{[a-z0-9_]*}~i', $expr, $matches);
845:
846: foreach ($matches[0] as $match) {
847: $fieldName = substr($match, 1, -1);
848: $field = $entity->getField($fieldName);
849: if ($field instanceof SqlExpressionField) {
850: $expr = str_replace($match, $this->getDummyExpression($field, $entity), $expr);
851: } else {
852: $expr = str_replace($match, $this->getValueForExpression($exprField, $fieldName, $entity), $expr);
853: }
854: }
855:
856: return $expr;
857: }
858:
859: /**
860: * Return a value according to field used in expression and the expression type.
861: * If field used in expression is null, the default value is returned.
862: *
863: * @return string
864: */
865: private function getValueForExpression(Field $exprField, string $fieldName, Model $entity)
866: {
867: switch ($exprField->type) {
868: case 'integer':
869: case 'float':
870: case 'atk4_money':
871: $value = (string) ($entity->get($fieldName) ?? 0);
872:
873: break;
874: default:
875: $value = '"' . $entity->get($fieldName) . '"';
876: }
877:
878: return $value;
879: }
880: }
881: