diff --git a/demos/form-control/multiline.php b/demos/form-control/multiline.php index e0d4b5cb7f..8de33913a5 100644 --- a/demos/form-control/multiline.php +++ b/demos/form-control/multiline.php @@ -44,9 +44,9 @@ $controlTotal = $column->addControl('total', ['readOnly' => true])->set($total); // update total when qty and box value in any row has changed -$multiline->onLineChange(static function (array $rows, Form $form) use ($controlTotal) { +$multiline->onLineChange(static function (array $rows, array $mlids, Form $form) use ($controlTotal) { $total = 0; - foreach ($rows as $row => $cols) { + foreach ($rows as $cols) { $total += $cols[MultilineItem::hinting()->fieldName()->qty] * $cols[MultilineItem::hinting()->fieldName()->box]; } diff --git a/docs/multiline.md b/docs/multiline.md index c6b992885e..123a6e0f34 100644 --- a/docs/multiline.md +++ b/docs/multiline.md @@ -177,9 +177,9 @@ You can return a single JsExpressionable or an array of JsExpressionables which In this case we display a message when any of the control value for 'qty' and 'box' are changed: ``` -$multiline->onLineChange(function (array $rows, Form $form) { +$multiline->onLineChange(function (array $rows, array $mlids, Form $form) { $total = 0; - foreach ($rows as $row => $cols) { + foreach ($rows as $cols) { $qty = $cols['qty'] ?? 0; $box = $cols['box'] ?? 0; $total += $qty * $box; diff --git a/src/Form/Control/Multiline.php b/src/Form/Control/Multiline.php index 62832e97c2..a64d284235 100644 --- a/src/Form/Control/Multiline.php +++ b/src/Form/Control/Multiline.php @@ -10,7 +10,6 @@ use Atk4\Data\Field\SqlExpressionField; use Atk4\Data\Model; use Atk4\Data\Persistence; -use Atk4\Data\Reference\ContainsOne; use Atk4\Data\ValidationException; use Atk4\Ui\Form; use Atk4\Ui\HtmlTemplate; @@ -150,7 +149,7 @@ class Multiline extends Form\Control /** @var JsCallback */ private $renderCallback; - /** @var \Closure(mixed, Form): (JsExpressionable|View|string|void)|null Function to execute when field change or row is delete. */ + /** @var \Closure(list>, list, Form): (JsExpressionable|View|string|void)|null Function to execute when field change or row is delete. */ protected $onChangeFunction; /** @var list Set fields that will trigger onChange function. */ @@ -163,7 +162,7 @@ class Multiline extends Form\Control public $rowFields; /** @var list> The data sent for each row. */ - public $rowData; + protected $rowData; /** @var int The max number of records (rows) that can be added to Multiline. 0 means no limit. */ public $rowLimit = 0; @@ -227,7 +226,7 @@ protected function init(): void return $jsError; }); - if ($this->isContainsOne()) { + if ($this->isOneToOne()) { $this->rowLimit = 1; } } @@ -252,32 +251,26 @@ private function typecastUiSaveValues(array $values): array } /** - * @param array> $values + * @param array $row * - * @return array> + * @return array */ - private function typecastUiLoadValues(array $values): array + private function typecastUiLoadRow(array $row): array { $res = []; - foreach ($values as $k => $row) { - foreach ($row as $fieldName => $value) { - if ($fieldName === '__atkml') { - $res[$k][$fieldName] = $value; - } else { - $res[$k][$fieldName] = $fieldName === $this->model->idField - ? $this->getApp()->uiPersistence->typecastAttributeLoadField($this->model->getField($fieldName), $value) - : $this->getApp()->uiPersistence->typecastLoadField($this->model->getField($fieldName), $value); - } - } + foreach ($row as $fieldName => $value) { + $res[$fieldName] = $fieldName === $this->model->idField + ? $this->getApp()->uiPersistence->typecastAttributeLoadField($this->model->getField($fieldName), $value) + : $this->getApp()->uiPersistence->typecastLoadField($this->model->getField($fieldName), $value); } return $res; } - private function isContainsOne(): bool + private function isOneToOne(): bool { return $this->entityField->getField()->hasReference() - && $this->entityField->getField()->getReference() instanceof ContainsOne; + && $this->entityField->getField()->getReference()->isOneToOne(); } #[\Override] @@ -349,23 +342,37 @@ private function invokeWithContainsXxxNormalizeHookIgnored(\Closure $fx): void }, null, Model::class)(); } + /** + * @return array{list>, list} + */ + private function decodeInput(string $json): array + { + $rowDataWithMlid = $this->getApp()->decodeJson($json); + $rowData = []; + $mlids = []; + foreach ($rowDataWithMlid as $row) { + $mlids[] = $row['__atkml']; + unset($row['__atkml']); + $rowData[] = $this->typecastUiLoadRow($row); + } + + return [$rowData, $mlids]; + } + #[\Override] public function setInputValue(string $value): void { - $this->rowData = $this->typecastUiLoadValues($this->getApp()->decodeJson($value)); + [$rowData, $mlids] = $this->decodeInput($value); + + $this->rowData = $rowData; if ($this->rowData !== []) { - $this->rowErrors = $this->validate($this->rowData); + $this->rowErrors = $this->validate($this->rowData, $mlids); if ($this->rowErrors !== []) { throw new ValidationException([$this->shortName => 'Multiline error']); } } - $rowsRaw = []; - foreach ($this->rowData as $k => $v) { - unset($v['__atkml']); - - $rowsRaw[$k] = $this->typecastContainedSaveRow($v); - } + $rowsRaw = array_map(fn ($v) => $this->typecastContainedSaveRow($v), $this->rowData); // mimic ContainsOne save format // https://github.com/atk4/data/blob/6.0.0/src/Reference/ContainsOne.php#L37 @@ -373,7 +380,7 @@ public function setInputValue(string $value): void if ($rowsRaw === []) { $value = ''; } else { - foreach ($rowsRaw as $k => $rowRaw) { // @phpstan-ignore foreach.keyOverwrite (https://github.com/phpstan/phpstan-strict-rules/issues/194) + foreach ($rowsRaw as $k => $rowRaw) { $idFieldRawName = $this->model->getIdField()->getPersistenceName(); if ($rowRaw[$idFieldRawName] === null) { $refModel = $this->entityField->getField()->hasReference() @@ -396,7 +403,7 @@ public function setInputValue(string $value): void } } - if ($this->isContainsOne()) { + if ($this->isOneToOne()) { assert(count($rowsRaw) === 1); $rowsRaw = array_first($rowsRaw); } @@ -413,8 +420,8 @@ public function setInputValue(string $value): void * Add a callback when fields are changed. You must supply array of fields * that will trigger the callback when changed. * - * @param \Closure(mixed, Form): (JsExpressionable|View|string|void) $fx - * @param list $fields + * @param \Closure(list>, list, Form): (JsExpressionable|View|string|void) $fx + * @param list $fields */ public function onLineChange(\Closure $fx, array $fields): void { @@ -427,18 +434,19 @@ public function onLineChange(\Closure $fx, array $fields): void * Validate each row and return errors if found. * * @param list> $rows + * @param list $mlids * * @return array> */ - public function validate(array $rows): array + public function validate(array $rows, array $mlids): array { $rowErrors = []; $entity = $this->model->createEntity(); - foreach ($rows as $cols) { - $rowId = $this->getMlRowId($cols); + foreach ($rows as $i => $cols) { + $rowId = $mlids[$i]; foreach ($cols as $fieldName => $value) { - if ($fieldName === '__atkml' || $fieldName === $entity->idField) { + if ($fieldName === $entity->idField) { continue; } @@ -477,10 +485,6 @@ public function saveRows(): self ? $model->load($row[$model->idField]) : $model->createEntity(); foreach ($row as $fieldName => $value) { - if ($fieldName === '__atkml') { - continue; - } - if ($model->getField($fieldName)->isEditable()) { $entity->set($fieldName, $value); } @@ -513,25 +517,6 @@ protected function addModelValidateErrors(array $errors, string $rowId, Model $e return $errors; } - /** - * Finds and returns Multiline row ID. - * - * @param array $row - */ - private function getMlRowId(array $row): ?string - { - $rowId = null; - foreach ($row as $col => $value) { - if ($col === '__atkml') { - $rowId = $value; - - break; - } - } - - return $rowId; - } - /** * @param list|null $fields */ @@ -831,10 +816,8 @@ private function outputJson(): void $this->getApp()->terminateJson(['success' => true, 'expressions' => $expressionValues]); // no break - expression above always terminate case 'on-change': - $rowsRaw = $this->getApp()->decodeJson($this->getApp()->getRequestPostParam('rows')); - $this->renderCallback->set(function () use ($rowsRaw) { - return ($this->onChangeFunction)($this->typecastUiLoadValues($rowsRaw), $this->form); - }); + [$rows, $mlids] = $this->decodeInput($this->getApp()->getRequestPostParam('rows')); + $this->renderCallback->set(fn () => ($this->onChangeFunction)($rows, $mlids, $this->form)); } }