diff --git a/demos/_demo-data/create-db.php b/demos/_demo-data/create-db.php index 02904219f2..27e99f5181 100644 --- a/demos/_demo-data/create-db.php +++ b/demos/_demo-data/create-db.php @@ -62,7 +62,12 @@ public function import(array $rowsMulti) return parent::import(array_map(function (array $rows): array { $rowsPrefixed = []; foreach ($rows as $k => $v) { - $rowsPrefixed[$this->prefixFieldName($k)] = $v; + $field = $this->getField($this->prefixFieldName($k)); + if (in_array($field->type, ['date', 'time', 'datetime'], true)) { + $v = new \DateTime($v . ' GMT'); + } + + $rowsPrefixed[$field->shortName] = $v; } return $rowsPrefixed; @@ -1100,11 +1105,6 @@ public function import(array $rowsMulti) ['id' => 3, 'project_name' => 'Agile Data', 'project_code' => 'at03', 'description' => 'Agile Data implements an entirely new pattern for data abstraction, that is specifically designed for remote databases such as RDS, Cloud SQL, BigQuery and other distributed data storage architectures. It focuses on reducing number of requests your App have to send to the Database by using more sophisticated queries while also offering full Domain Model mapping and Database vendor abstraction.', 'client_name' => 'Agile Toolkit', 'client_address' => 'Some Street,' . "\n" . 'Garden City' . "\n" . 'UK', 'client_country_iso' => 'GB', 'is_commercial' => 0, 'currency' => 'GBP', 'is_completed' => 1, 'project_budget' => 12000, 'project_invoiced' => 0, 'project_paid' => 0, 'project_hour_cost' => 0, 'project_hours_est' => 300, 'project_hours_reported' => 394, 'project_expenses_est' => 600, 'project_expenses' => 430, 'project_mgmt_cost_pct' => 0.2, 'project_qa_cost_pct' => 0.3, 'start_date' => '2016-04-17', 'finish_date' => '2016-06-20', 'finish_time' => '03:04:00', 'created' => '2017-04-06 10:30:15', 'updated' => '2017-04-06 10:35:04'], ['id' => 4, 'project_name' => 'Agile UI', 'project_code' => 'at04', 'description' => 'Web UI Component library.', 'client_name' => 'Agile Toolkit', 'client_address' => 'Some Street,' . "\n" . 'Garden City' . "\n" . 'UK', 'client_country_iso' => 'GB', 'is_commercial' => 0, 'currency' => 'GBP', 'is_completed' => 0, 'project_budget' => 20000, 'project_invoiced' => 0, 'project_paid' => 0, 'project_hour_cost' => 0, 'project_hours_est' => 600, 'project_hours_reported' => 368, 'project_expenses_est' => 1200, 'project_expenses' => 0, 'project_mgmt_cost_pct' => 0.3, 'project_qa_cost_pct' => 0.4, 'start_date' => '2016-09-17', 'finish_date' => '', 'finish_time' => '', 'created' => '2017-04-06 10:30:15', 'updated' => '2017-04-06 10:35:04'], ]; -foreach ($data as $rowIndex => $row) { - foreach (['start_date', 'finish_date', 'finish_time', 'created', 'updated'] as $k) { - $data[$rowIndex][$k] = new \DateTime($row[$k] . ' GMT'); - } -} $model->import($data); $model = new ImportModelWithPrefixedFields($db, ['table' => 'product_category']); @@ -1148,4 +1148,17 @@ public function import(array $rowsMulti) ['id' => 7, 'name' => 'Ice Cream', 'brand' => 'Milk Corp.', 'product_category_id' => 3, 'product_sub_category_id' => 8], ]); +$model = new ImportModelWithPrefixedFields($db, ['table' => 'multiline_item']); +$model->addField('item', ['type' => 'string']); +$model->addField('inv_date', ['type' => 'date']); +$model->addField('inv_time', ['type' => 'time']); +$model->addField('country_id', ['type' => 'bigint']); +$model->addField('qty', ['type' => 'integer']); +$model->addField('box', ['type' => 'integer']); +(new Migrator($model))->create(); +$model->import([ + ['id' => 1, 'item' => 'Chocolate', 'inv_date' => '2020-02-20', 'inv_time' => '7:20', 'country_id' => 80, 'qty' => 7, 'box' => 5], + ['id' => 2, 'item' => 'DAP delivery', 'inv_date' => '2020-02-01', 'inv_time' => '8:33', 'country_id' => 223, 'qty' => 2, 'box' => 100], +]); + echo 'import complete!' . "\n\n"; diff --git a/demos/form-control/multiline.php b/demos/form-control/multiline.php index 399cb50ab0..b39c54f960 100644 --- a/demos/form-control/multiline.php +++ b/demos/form-control/multiline.php @@ -4,8 +4,6 @@ namespace Atk4\Ui\Demos; -use Atk4\Data\Model; -use Atk4\Data\Persistence; use Atk4\Ui\Form; use Atk4\Ui\Header; use Atk4\Ui\JsExpression; @@ -17,73 +15,15 @@ Header::addTo($app, ['Multiline form control', 'icon' => 'database', 'subHeader' => 'Collect/Edit multiple rows of table record.']); -/** @var Model $inventoryItemClass */ -$inventoryItemClass = AnonymousClassNameCache::get_class(fn () => new class() extends Model { - public Persistence $countryPersistence; - - protected function init(): void - { - parent::init(); - - $this->addField('item', [ - 'required' => true, - 'default' => 'item', - 'ui' => ['multiline' => [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]], - ]); - $this->addField('inv_date', [ - 'default' => new \DateTime(), - 'type' => 'date', - 'ui' => ['multiline' => [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]], - ]); - $this->addField('inv_time', [ - 'default' => new \DateTime(), - 'type' => 'time', - 'ui' => ['multiline' => [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]], - ]); - $this->hasOne('country', [ - 'model' => new Country($this->countryPersistence), - 'ui' => ['multiline' => [Form\Control\Multiline::TABLE_CELL => ['width' => 3]]], - ]); - $this->addField('qty', [ - 'type' => 'integer', - 'caption' => 'Qty / Box', - 'default' => 1, - 'required' => true, - 'ui' => ['multiline' => [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]], - ]); - $this->addField('box', [ - 'type' => 'integer', - 'caption' => '# of Boxes', - 'default' => 1, - 'required' => true, - 'ui' => ['multiline' => [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]], - ]); - $this->addExpression('total', [ - 'expr' => function (Model $row) { - return $row->get('qty') * $row->get('box'); - }, - 'type' => 'integer', - 'ui' => ['multiline' => [Form\Control\Multiline::TABLE_CELL => ['width' => 1, 'class' => 'blue']]], - ]); - } -}); - -$inventory = new $inventoryItemClass(new Persistence\Array_(), ['countryPersistence' => $app->db]); - -// Populate some data. -$total = 0; -for ($i = 1; $i < 3; ++$i) { - $entity = $inventory->createEntity(); - $entity->set('id', $i); - $entity->set('inv_date', new \DateTime()); - $entity->set('inv_time', new \DateTime()); - $entity->set('item', 'item_' . $i); - $entity->set('country', random_int(1, 100)); - $entity->set('qty', random_int(10, 100)); - $entity->set('box', random_int(1, 10)); - $total += $entity->get('qty') * $entity->get('box'); - $entity->saveAndUnload(); -} +$inventory = new MultilineItem($app->db); +$inventory->getField($inventory->fieldName()->item)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; +$inventory->getField($inventory->fieldName()->inv_date)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; +$inventory->getField($inventory->fieldName()->inv_date)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; +$inventory->getField($inventory->fieldName()->country_id)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 3]]; +$inventory->getField($inventory->fieldName()->qty)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; +$inventory->getField($inventory->fieldName()->box)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 2]]; +$inventory->getField($inventory->fieldName()->total_sql)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 1, 'class' => 'blue']]; +$inventory->getField($inventory->fieldName()->total_php)->ui['multiline'] = [Form\Control\Multiline::TABLE_CELL => ['width' => 1, 'class' => 'blue']]; $form = Form::addTo($app); @@ -93,6 +33,10 @@ protected function init(): void $multiline->setModel($inventory); // Add total field. +$total = 0; +foreach ($inventory as $item) { + $total += $item->qty * $item->box; +} $sublayout = $form->layout->addSubLayout([Form\Layout\Section\Columns::class]); $sublayout->addColumn(12); $column = $sublayout->addColumn(4); @@ -102,19 +46,19 @@ protected function init(): void $multiline->onLineChange(function (array $rows, Form $form) use ($controlTotal) { $total = 0; foreach ($rows as $row => $cols) { - $qty = $cols['qty'] ?? 0; - $box = $cols['box'] ?? 0; - $total += $qty * $box; + $total += $cols[MultilineItem::hinting()->fieldName()->qty] * $cols[MultilineItem::hinting()->fieldName()->box]; } return $controlTotal->jsInput()->val($total); -}, ['qty', 'box']); +}, [$inventory->fieldName()->qty, $inventory->fieldName()->box]); $multiline->jsAfterAdd = new JsFunction(['value'], [new JsExpression('console.log(value)')]); $multiline->jsAfterDelete = new JsFunction(['value'], [new JsExpression('console.log(value)')]); $form->onSubmit(function (Form $form) use ($multiline) { - $rows = $multiline->saveRows()->model->export(); + $rows = $multiline->model->atomic(function () use ($multiline) { + return $multiline->saveRows()->model->export(); + }); return new JsToast($form->getApp()->encodeJson(array_values($rows))); }); diff --git a/demos/init-db.php b/demos/init-db.php index fb66801e76..9cb81c5b2b 100644 --- a/demos/init-db.php +++ b/demos/init-db.php @@ -489,3 +489,44 @@ protected function init(): void ])->addTitle(); } } + +/** + * @property string $item @Atk4\Field() + * @property \DateTime $inv_date @Atk4\Field() + * @property \DateTime $inv_time @Atk4\Field() + * @property Country $country_id @Atk4\RefOne() + * @property int $qty @Atk4\Field() + * @property int $box @Atk4\Field() + * @property int $total_sql @Atk4\Field() + * @property int $total_php @Atk4\Field() + */ +class MultilineItem extends ModelWithPrefixedFields +{ + public $table = 'multiline_item'; + + protected function init(): void + { + parent::init(); + + $this->addField($this->fieldName()->item, ['required' => true]); + $this->addField($this->fieldName()->inv_date, ['type' => 'date']); + $this->addField($this->fieldName()->inv_time, ['type' => 'time']); + $this->hasOne($this->fieldName()->country_id, [ + 'model' => [Country::class], + ]); + $this->addField($this->fieldName()->qty, ['type' => 'integer', 'required' => true]); + $this->addField($this->fieldName()->box, ['type' => 'integer', 'required' => true]); + $this->addExpression($this->fieldName()->total_sql, [ + 'expr' => function (Model /* TODO self is not working bacause of clone in Multiline */ $row) { + return $row->expr('{' . $this->fieldName()->qty . '} * {' . $this->fieldName()->box . '}'); // @phpstan-ignore-line + }, + 'type' => 'integer', + ]); + $this->addCalculatedField($this->fieldName()->total_php, [ + 'expr' => function (self $row) { + return $row->qty * $row->box; + }, + 'type' => 'integer', + ]); + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 37819c0ce0..cc81533d34 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -153,6 +153,9 @@ parameters: - path: 'src/Form/Control/Checkbox.php' message: '~^Access to an undefined property Atk4\\Ui\\Jquery::\$checked\.$~' + - + path: 'src/Form/Control/Multiline.php' + message: '~^Call to an undefined method Atk4\\Data\\Model::expr\(\)\.$~' - path: 'src/JsVueService.php' message: '~^Call to an undefined method Atk4\\Ui\\JsChain::createAtkVue\(\)\.$~' diff --git a/src/Form/Control/Multiline.php b/src/Form/Control/Multiline.php index 30eb1f5485..427fa8e95b 100644 --- a/src/Form/Control/Multiline.php +++ b/src/Form/Control/Multiline.php @@ -727,6 +727,8 @@ private function getCallbackValues(Model $model): array */ private function setDummyModelValue(Model $model): Model { + $model = clone $model; // for clearing "required" + foreach ($this->fieldDefs as $def) { $fieldName = $def['name']; if ($fieldName === $model->idField) { @@ -738,9 +740,10 @@ private function setDummyModelValue(Model $model): Model $value = $this->getApp()->uiPersistence->typecastLoadField($field, $_POST[$fieldName] ?? null); if ($field->isEditable()) { try { + $field->required = false; $model->set($fieldName, $value); } catch (ValidationException $e) { - // Bypass validation at this point. + // bypass validation at this point } } } @@ -753,26 +756,36 @@ private function setDummyModelValue(Model $model): Model */ private function getExpressionValues(Model $model): array { - $dummyFields = []; + $dummyFields = $this->getExpressionFields($model); $formatValues = []; - foreach ($this->getExpressionFields($model) as $k => $field) { + foreach ($dummyFields as $k => $field) { if (!is_callable($field->expr)) { - $dummyFields[$k]['name'] = $field->shortName; - $dummyFields[$k]['expr'] = $this->getDummyExpression($field, $model); + $dummyFields[$k]->expr = $this->getDummyExpression($field, $model); } } if ($dummyFields !== []) { $dummyModel = new Model($model->getPersistence(), ['table' => $model->table]); - foreach ($dummyFields as $field) { - $dummyModel->addExpression($field['name'], ['expr' => $field['expr'], 'type' => $model->getField($field['name'])->type]); + $dummyModel->removeField('id'); + $dummyModel->idField = $model->idField; + foreach ($model->getFields() as $field) { + $dummyModel->addExpression($field->shortName, [ + 'expr' => isset($dummyFields[$field->shortName]) + ? $dummyFields[$field->shortName]->expr + : ($field->shortName === $dummyModel->idField + ? '-1' + : $dummyModel->expr('[]', [$model->getPersistence()->typecastSaveField($field, $field->get($model))])), + 'type' => $field->type, + 'actual' => $field->actual, + ]); } - $values = $dummyModel->loadAny()->get(); + $dummyModel->setLimit(1); // TODO must work with empty table, no table should be used + $values = $dummyModel->loadOne()->get(); unset($values[$model->idField]); foreach ($values as $f => $value) { - if ($value) { + if (isset($dummyFields[$f])) { $field = $model->getField($f); $formatValues[$f] = $this->getApp()->uiPersistence->typecastSaveField($field, $value); } @@ -785,6 +798,8 @@ private function getExpressionValues(Model $model): array /** * Get all field expression in model, but only evaluate expression used in * rowFields. + * + * @return array */ private function getExpressionFields(Model $model): array { @@ -794,7 +809,7 @@ private function getExpressionFields(Model $model): array continue; } - $fields[] = $field; + $fields[$field->shortName] = $field; } return $fields; diff --git a/tests-behat/multiline.feature b/tests-behat/multiline.feature new file mode 100644 index 0000000000..03a6fb883c --- /dev/null +++ b/tests-behat/multiline.feature @@ -0,0 +1,8 @@ +Feature: Multiline + + Scenario: + Given I am on "form-control/multiline.php" + When I fill in "-atk_fp_multiline_item__qty" with "2" + When I fill in "-atk_fp_multiline_item__box" with "67" + Then I press button "Save" + Then Toast display should contain text '"atk_fp_multiline_item__qty": 2, "atk_fp_multiline_item__box": 67, "atk_fp_multiline_item__total_sql": 134 }'