diff --git a/demos/collection/table.php b/demos/collection/table.php index e4d4346068..556d23df9a 100644 --- a/demos/collection/table.php +++ b/demos/collection/table.php @@ -1,84 +1,73 @@ add(['View', 'ui' => 'buttons']); +$table = $app->add(['Table', 'celled' => true]); -use Atk4\Data\Model; -use Atk4\Ui\Button; -use Atk4\Ui\Js\JsReload; -use Atk4\Ui\Js\JsToast; -use Atk4\Ui\Table; -use Atk4\Ui\View; +$bb->add(['Button', 'Refresh Table', 'icon' => 'refresh']) + ->on('click', new \atk4\ui\jsReload($table)); -/** @var \Atk4\Ui\App $app */ -require_once __DIR__ . '/../init-app.php'; +$bb->on('click', $table->js()->reload()); -if ($_GET['id'] ?? null) { - $app->layout->js(true, new JsToast('Details link is in simulation mode.')); -} +$table->setModel(new SomeData(), false); -$bb = View::addTo($app, ['ui' => 'buttons']); - -$table = Table::addTo($app, ['class.celled' => true]); -Button::addTo($bb, ['Refresh Table', 'icon' => 'refresh']) - ->on('click', new JsReload($table)); - -$table->setModel(new SomeData(), []); - -$table->addColumn('name', new Table\Column\Link(['table', 'foo' => 'bar'], ['person_id' => 'id'], ['target' => '_blank'])); -$table->addColumn('surname', new Table\Column\Template('{$surname}'))->addClass('warning'); -$table->addColumn('title', new Table\Column\Status([ +$table->addColumn('name', new \atk4\ui\TableColumn\Link(['details'], ['id' => 'id'])); +$table->addColumn('surname', new \atk4\ui\TableColumn\Template('{$surname}'))->addClass('warning'); +$table->addColumn('title', new \atk4\ui\TableColumn\Status([ 'positive' => ['Prof.'], 'negative' => ['Dr.'], ])); - $table->addColumn('date'); -$table->addColumn('salary', new Table\Column\Money()); -$table->addColumn('logo_url', [Table\Column\Image::class, 'caption' => 'Our Logo']); - -$table->onHook(Table\Column::HOOK_GET_HTML_TAGS, function (Table $table, Model $row) { - switch ($row->getId()) { - case 1: - $color = 'yellow'; - - break; - case 2: - $color = 'grey'; +$table->addColumn('salary', new \atk4\ui\TableColumn\Money()); //->addClass('right aligned single line', 'all')); - break; - case 3: - $color = 'brown'; - - break; - default: - $color = ''; - } - if ($color) { +$table->addHook('getHTMLTags', function ($table, $row) { + if ($row->id == 1) { return [ - 'name' => $table->getApp()->getTag('div', ['class' => 'ui ribbon ' . $color . ' label'], $row->get('name')), + 'name' => $table->app->getTag('div', ['class' => 'ui ribbon label'], $row['name']), ]; } }); $table->addTotals([ - 'name' => 'Totals:', - 'salary' => ['sum'], + 'name' => 'Total {$_row_count} rows:', + 'surname'=> [ + // longest surname + 'compare'=> function ($total, $value, $model) { + return strlen($value) > strlen($total) ? $value : $total; + }, + 'title'=> function ($total, $model) { + return 'Shortes is: '.$total; + }, + ], + 'salary' => [ + 'init' => '123', + 'update'=> null, + ], ]); -$myArray = [ - ['name' => 'Vinny', 'surname' => 'Sihra', 'birthdate' => '1973-02-03', 'cv' => 'I am BIG Vinny'], - ['name' => 'Zoe', 'surname' => 'Shatwell', 'birthdate' => '1958-08-21', 'cv' => null], - ['name' => 'Darcy', 'surname' => 'Wild', 'birthdate' => '1968-11-01', 'cv' => 'I like addExpression('total', '[price] * [amount]')->type = 'atk4_money'; - -$table->setModel($order, ['name', 'price', 'amount', 'total', 'status']); -``` - -The type of the Model Field determines the way how value is presented in the table. I've specified -value to be 'atk4_money' which makes column align values to the right, format it with 2 decimal signs -and possibly add a currency sign. - -To learn about value formatting, read documentation on {ref}`uiPersistence`. - -Table object does not contain any information about your fields (such as captions) but instead it will -consult your Model for the necessary field information. If you are willing to define the type but also -specify the caption, you can use code like this: - -``` -$table = Table::addTo($app); -$order = new Order($db); - -$order->addExpression('total', [ - '[price]*[amount]', - 'type' => 'atk4_money', - 'caption' => 'Total Price', -]); - -$table->setModel($order, ['name', 'price', 'amount', 'total', 'status']); -``` - -### Column Objects - -To read more about column objects, see {ref}`tablecolumn` - -### Advanced Column Denifitions +### Column Denifition Table defines a method `columnFactory`, which returns Column object which is to be used to display values of specific model Field. @@ -235,6 +189,68 @@ $table->addColumn($colGap); This will result in 3 gap columns rendered to the left, middle and right of your Table. +## Calculated Fields + +Apart from adding columns that reflect current values of your database, there are several ways +how you can calculate additional values. You must know the capabilities of your database server +if you want to execute some calculation there. (See https://atk4-data.readthedocs.io/en/develop/expressions.html) + +### In the database - best performance + +It's always a good idea to calculate column inside database. Lets create "total" column which will +multiply "price" and "amount" values. Use `addExpression` to provide in-line definition for this +field if it's not alrady defined in `Order::init()`: + +``` +$table = $app->add('Table'); +$order = new Order($db); + +$order->addExpression('total', [ + '[price] * [amount]', + 'type' => 'money', +]); + +$table->setModel($order, ['name', 'price', 'amount', 'total', 'status']); +``` + +### In the model - best compatibility + +If your database does not have the capacity to perform calculations, e.g. you are using NoSQL with +no support for expressions, the solution is to calculate value in the PHP: + +``` +$table = $app->add('Table'); +$order = new Order($db); + +$model->addField('total', [ + 'Callback', + function ($m) { + return $m['price'] * $m['amount']; + }, + 'type' => 'money', +]); + +$table->setModel($order, ['name', 'price', 'amount', 'total', 'status']); +``` + +### Alternative approaches + +:::{warning} Those alternatives are for special cases, when you are unable to perform calculation +in the database or in the model. Please use with caution. +:: + +You can add add a custom code that performs formatting within the table through a hook: + +``` +$table->addField('no'); + +$table->addHook('beforeRow', function($table)) { + $table->model['no'] = @++$t->npk; +} +``` + +To read more about column objects, see {ref}`tablecolumn` + ## Table sorting :::{php:attr} sortable @@ -453,6 +469,202 @@ getDataCellHtml can be left as-is and will be handled correctly. If you have ove getDataCellHtml only, then your column will still work OK provided that it's used as a last decorator. +## Table Totals + +:::{php:attr} totals_plan +::: +:::{php:attr} totals +::: +:::{php:method} addTotals($plan, $plan_id = null) +::: + + +Table implements a built-in handling for the "Totals" row. Simple usage would be: + +``` +$table->addTotals(); +``` + +but here is what actually happens: + +1. when calling addTotals() - you define a total calculation plan. +2. while iterating through table, totals are accumulated / modified according to plan. +3. when table finishes with the rows, it adds yet another row with the accumulated values. + +:::{important} addTotals() will only calculate based on rendered rows. If your table has limit +or uses {php:class}`Paginator` you should calculate totals differently. See {php:meth}`Grid::addGrandTotals` +:: + +Method addTotals() can be executed multiple times and every time it defines a new plan and +will be reflected as an extra row for totals. + +To illustrate the need for multiple totals plans, here are some the scenarios which Table allows +you to cover: + +- Add "subtotal" then "tax" and then "total" row to your table. +- Create "group totals" which will appear in the middle of the table. +- Display per-page total and grand totals (for entire table). + +### Definition of a "totals plan" + +Each column may have a different plan, which consists of stages: + +- init: defines the initial value +- update: defines how value is updated +- format: defines how value is formatted before outputting + +Here is the plan to calculate number of records: + +``` +$table->addTotals(['client' => [ + 'init' => 0, + 'update' => 'increment', + 'format' => 'Totals for {$client} client(s)', +]); +``` + +To make things easier to define, each stage has a reasonable default: + +- init: set to 0 +- update: 'sum' for numeric/money type and 'increment' otherwise +- format: will output '-' by default + +Also when calling addTotals() the column plan value does not have to be array, in which case the 'format' +is set. The above example can therefore be shortened: + +``` +$table->addTotals(['client' => 'Totals for {$clients} client(s)']); +``` + +In addition to the plans you define, there is also going to be `{$_row_count}` which automatically counts +number of rows in your table. + +### Possible values for total plan stages + +`init` value is typically a number, but can be any value, especially if you are going to control it's +increment / formatting. + +`update` can be string. Supported built-ins are: + +- min +- inc +- max +- sum +- avg + +You can use a callable which gives you an option to perform update yourself. Also, make note that `inc` +will ONLY increment when value of the column it's associated with is not empty. If you need to get +total number of rows, use a special value `{$_row_count}` + +Update can also be set to `false` if you don't want update to take place. If not set, or set to `null` +then default action (based on column type) will be invoked. + +`format` uses a template in a format suitable for {php:class}`Template`. Within the template you can +reference other fields too. A benefit for using template is that type formatting is done automatically +for you. + +If `format` set to `null` or omitted then default action will be used which is to display totals only +`money` / `numeric` and title column ("Totals for 123 record(s)") + +### Using Callbacks + +Value of any stage may also be a callback. Callbacks will be executed during the appropriate stage execution +and the value will be used: + +`init` defines callback like this: + +``` +function($model) { + + // $model is not loaded yet!! + + return 0; // initial value +} +``` + +`update` defines callback like this: + +``` +function($total, $value, $model) { + + // $total is previous value + // $value is value of the column + // $model will be loaded to current column +} +``` + +Here is example how you can implement "longest value" function: + +``` +function ($total, $value) { + return strlen($value) > strlen($total) ? $value : $total; +} +``` + +`format` defines callback like this: + +``` +function ($total, $model) { + return 'Total is: '.$total; +} +``` + +:::{important} when defining format as a string, template engine performs value +formatting. If you define it through the callback, it's up to you. +:: + +### Some examples + +Calculate total salary yourself: + + +``` +$salary_value = my_calc_salary(); + +$table->addTotals(['salary'=> money_format($salary_value])); +``` + +:::{important} The value for the format above is passed through Template, so if user has control over +the value, he may reference model field you don't want him to see. Keep your security tight!! +:: + +Safer version of the above code would be: + +``` +$salary_value = my_calc_salary(); + +$table->addTotals(['salary'=> function() use($salary_value) { money_format($salary_value]); }); +``` + +Here is another alternative: + +``` +$salary_value = my_calc_salary(); + +$table->addTotals(['salary'=> [ + 'init'=> $salary_value, + 'update'=>false, +]); +``` + +Combine output into single column: + +``` +$table->addTotals([ + 'name'=>['update'=>'increment', 'format'=>'Total salary for {$name} employees is {$salary}'], + 'salary'=>['update'=>'sum', 'format'=>false] +]); +``` + +With introduciton of callbacks you can show average value too: + +``` +$table->addTotals([ + 'name'=>['update'=>'increment', 'format'=>'Total salary for {$name} employees is {$salary}'], + 'salary'=>['update'=>'sum', 'format'=>false] +]); +``` + ## Advanced Usage Table is a very flexible object and can be extended through various means. This chapter will focus diff --git a/src/Grid.php b/src/Grid.php index 17d6e21c96..cbba4ad9b0 100644 --- a/src/Grid.php +++ b/src/Grid.php @@ -1,71 +1,62 @@ 'Actions...']; - - protected function init(): void + public function init() { parent::init(); - $this->container = View::addTo($this, ['template' => $this->template->cloneRegion('Container')]); - $this->template->del('Container'); + $this->container = $this->add(['View', 'ui'=>'', 'template' => new Template('
{$Table}{$Content}
{$Paginator}
')]); - if (!$this->sortTrigger) { - $this->sortTrigger = $this->name . '_sort'; - } - - if ($this->menu !== false && !is_object($this->menu)) { - $this->menu = $this->add(Factory::factory([Menu::class, 'activateOnClick' => false], $this->menu), 'Menu'); + if ($this->menu !== false) { + $this->menu = $this->add($this->factory(['Menu', 'activate_on_click' => false], $this->menu), 'Menu'); } - $this->table = $this->initTable(); + $this->table = $this->container->add($this->factory(['Table', 'very compact striped single line', 'reload' => $this], $this->table), 'Table'); if ($this->paginator !== false) { - $seg = View::addTo($this->container, [], ['Paginator'])->setStyle('text-align', 'center'); - $this->paginator = $seg->add(Factory::factory([Paginator::class, 'reload' => $this->container], $this->paginator)); - $this->stickyGet($this->paginator->name); - } - - // TODO dirty way to set stickyGet - add addQuickSearch to find the expected search input component ID and then remove it - if ($this->menu !== false) { - $appUniqueHashesBackup = $this->getApp()->uniqueNameHashes; - $menuElementNameCountsBackup = \Closure::bind(fn () => $this->_elementNameCounts, $this->menu, AbstractView::class)(); - try { - $menuRight = $this->menu->addMenuRight(); // @phpstan-ignore-line - $menuItemView = View::addTo($menuRight->addItem()->setElement('div')); - $quickSearch = JsSearch::addTo($menuItemView); - $this->stickyGet($quickSearch->name . '_q'); - $this->menu->removeElement($menuRight->shortName); - } finally { - $this->getApp()->uniqueNameHashes = $appUniqueHashesBackup; - \Closure::bind(fn () => $this->_elementNameCounts = $menuElementNameCountsBackup, $this->menu, AbstractView::class)(); - } + $seg = $this->container->add(['View'], 'Paginator')->addStyle('text-align', 'center'); + $this->paginator = $seg->add($this->factory(['Paginator', 'reload' => $this], $this->paginator)); } } - protected function initTable(): Table - { - /** @var Table */ - $table = $this->container->add(Factory::factory([Table::class, 'class.very compact very basic striped single line' => true, 'reload' => $this->container], $this->table), 'Table'); - - return $table; - } - /** * Add new column to grid. If column with this name already exists, * an. Simply calls Table::addColumn(), so check that method out. * - * @param string|null $name Data model field name - * @param array|Table\Column $columnDecorator - * @param ($name is null ? array{} : array|Field) $field + * @param string $name Data model field name + * @param array|string|object|null $columnDecorator + * @param array|string|object|null $field * - * @return Table\Column + * @return TableColumn\Generic */ - public function addColumn(?string $name, $columnDecorator = [], $field = []) + public function addColumn($name, $columnDecorator = null, $field = null) { return $this->table->addColumn($name, $columnDecorator, $field); } @@ -158,341 +118,119 @@ public function addColumn(?string $name, $columnDecorator = [], $field = []) /** * Add additional decorator for existing column. * - * @param array|Table\Column $seed - * - * @return Table\Column - */ - public function addDecorator(string $name, $seed) - { - return $this->table->addDecorator($name, $seed); - } - - /** - * Add a new button to the Grid Menu with a given text. - * - * @param string $label - */ - public function addButton($label): Button - { - if (!$this->menu) { - throw new Exception('Unable to add Button without Menu'); - } - - return Button::addTo($this->menu->addItem(), [$label]); - } - - /** - * Set item per page value. - * - * If an array is passed, it will also add an ItemPerPageSelector to paginator. - * - * @param int|list $ipp - * @param string $label + * @param string $name Column name + * @param TableColumn\Generic|array $decorator Seed or object of the decorator */ - public function setIpp($ipp, $label = 'Items per page:'): void + public function addDecorator($name, $decorator) { - if (is_array($ipp)) { - $this->addItemsPerPageSelector($ipp, $label); - } else { - $this->ipp = $ipp; - } + return $this->table->addDecorator($name, $decorator); } /** - * Add ItemsPerPageSelector View in grid menu or paginator in order to dynamically setup number of item per page. + * Add a new buton to the Grid Menu with a given text. * - * @param list $items an array of item's per page value - * @param string $label the memu item label + * WARNING: needs to be reviewed! * - * @return $this + * @param mixed $text */ - public function addItemsPerPageSelector(array $items = [10, 100, 1000], $label = 'Items per page:') + public function addButton($text) { - $ipp = (int) $this->container->stickyGet('ipp'); - if ($ipp) { - $this->ipp = $ipp; - } else { - $this->ipp = $items[0]; - } - - $pageLength = ItemsPerPageSelector::addTo($this->paginator, ['pageLengthItems' => $items, 'label' => $label, 'currentIpp' => $this->ipp], ['afterPaginator']); - $this->paginator->template->trySet('PaginatorType', 'ui grid'); - - $sortBy = $this->getSortBy(); - if ($sortBy) { - $pageLength->stickyGet($this->sortTrigger, $sortBy); - } - - $pageLength->onPageLengthSelect(function (int $ipp) { - $this->ipp = $ipp; - $this->setModelLimitFromPaginator(); - // add ipp to quicksearch - if ($this->quickSearch instanceof JsSearch) { - $this->container->js(true, $this->quickSearch->js()->atkJsSearch('setUrlArgs', ['ipp', $this->ipp])); - } - $this->applySort(); - - // return the view to reload. - return $this->container; - }); - - return $this; - } - - /** - * Add dynamic scrolling paginator. - * - * @param int $ipp number of item per page to start with - * @param array $options an array with JS Scroll plugin options - * @param View $container the container holding the lister for scrolling purpose - * @param string $scrollRegion A specific template region to render. Render output is append to container HTML element. - * - * @return $this - */ - public function addJsPaginator($ipp, $options = [], $container = null, $scrollRegion = 'Body') - { - if ($this->paginator) { - $this->paginator->destroy(); - // prevent action(count) to be output twice. - $this->paginator = null; - } - - $sortBy = $this->getSortBy(); - if ($sortBy) { - $this->stickyGet($this->sortTrigger, $sortBy); - } - $this->applySort(); - - $this->table->addJsPaginator($ipp, $options, $container, $scrollRegion); - - return $this; + return $this->menu->addItem()->add(new Button($text)); } /** - * Add dynamic scrolling paginator in container. - * Use this to make table headers fixed. - * - * @param int $ipp number of item per page to start with - * @param int $containerHeight number of pixel the table container should be - * @param array $options an array with JS Scroll plugin options - * @param View $container the container holding the lister for scrolling purpose - * @param string $scrollRegion A specific template region to render. Render output is append to container HTML element. + * Add Search input field using js action. * - * @return $this - */ - public function addJsPaginatorInContainer($ipp, $containerHeight, $options = [], $container = null, $scrollRegion = 'Body') - { - $this->table->hasCollapsingCssActionColumn = false; - $options = array_merge($options, [ - 'hasFixTableHeader' => true, - 'tableContainerHeight' => $containerHeight, - ]); - // adding a state context to JS scroll plugin. - $options = array_merge(['stateContext' => $this->container], $options); - - return $this->addJsPaginator($ipp, $options, $container, $scrollRegion); - } - - /** - * Add Search input field using JS action. - * By default, will query server when using Enter key on input search field. - * You can change it to query server on each keystroke by passing $autoQuery true,. + * @param array $fields * - * @param array $fields the list of fields to search for - * @param bool $hasAutoQuery will query server on each key pressed + * @throws Exception + * @throws \atk4\data\Exception */ - public function addQuickSearch($fields = [], $hasAutoQuery = false): void + public function addQuickSearch($fields = []) { - if (!$this->model) { - throw new Exception('Call setModel() before addQuickSearch()'); - } - if (!$fields) { - $fields = [$this->model->titleField]; + $fields = [$this->model->title_field]; } if (!$this->menu) { - throw new Exception('Unable to add QuickSearch without Menu'); - } - - $view = View::addTo($this->menu->addMenuRight()->addItem()->setElement('div')); - - $this->quickSearch = JsSearch::addTo($view, ['reload' => $this->container, 'autoQuery' => $hasAutoQuery]); - $q = $this->stickyGet($this->quickSearch->name . '_q') ?? ''; - $qWords = preg_split('~\s+~', $q, -1, \PREG_SPLIT_NO_EMPTY); - if (count($qWords) > 0) { - $andScope = Model\Scope::createAnd(); - foreach ($qWords as $v) { - $orScope = Model\Scope::createOr(); - foreach ($fields as $field) { - $orScope->addCondition($field, 'like', '%' . $v . '%'); - } - $andScope->addCondition($orScope); - } - $this->model->addCondition($andScope); - } - $this->quickSearch->initValue = $q; - } - - public function jsReload($args = [], $afterSuccess = null, $apiConfig = []): JsExpressionable - { - return new JsReload($this->container, $args, $afterSuccess, $apiConfig); - } - - /** - * Adds a new button into the action column on the right. For Crud this - * column will already contain "delete" and "edit" buttons. - * - * @param string|array|View $button Label text, object or seed for the Button - * @param JsExpressionable|JsCallbackSetClosure $action - * @param bool $isDisabled - * - * @return View - */ - public function addActionButton($button, $action = null, string $confirmMsg = '', $isDisabled = false) - { - return $this->getActionButtons()->addButton($button, $action, $confirmMsg, $isDisabled); - } - - /** - * Add a button for executing a model action via an action executor. - * - * @return View - */ - public function addExecutorButton(UserAction\ExecutorInterface $executor, Button $button = null) - { - if ($button !== null) { - $this->add($button); - } else { - $button = $this->getExecutorFactory()->createTrigger($executor->getAction(), ExecutorFactory::TABLE_BUTTON); - } - - $confirmation = $executor->getAction()->getConfirmation(); - if (!$confirmation) { - $confirmation = ''; - } - $disabled = is_bool($executor->getAction()->enabled) ? !$executor->getAction()->enabled : $executor->getAction()->enabled; - - return $this->getActionButtons()->addButton($button, $executor, $confirmation, $disabled); - } - - private function getActionButtons(): Table\Column\ActionButtons - { - if ($this->actionButtons === null) { - $this->actionButtons = $this->table->addColumn(null, $this->actionButtonsDecorator); - } - - return $this->actionButtons; // @phpstan-ignore-line - } - - /** - * Similar to addAction. Will add Button that when click will display - * a Dropdown menu. - * - * @param View|string $view - * @param JsExpressionable|JsCallbackSetClosure $action - * - * @return View - */ - public function addActionMenuItem($view, $action = null, string $confirmMsg = '', bool $isDisabled = false) - { - return $this->getActionMenu()->addActionMenuItem($view, $action, $confirmMsg, $isDisabled); - } - - /** - * @return View - */ - public function addExecutorMenuItem(ExecutorInterface $executor) - { - $item = $this->getExecutorFactory()->createTrigger($executor->getAction(), ExecutorFactory::TABLE_MENU_ITEM); - // ConfirmationExecutor take care of showing the user confirmation, thus make it empty. - $confirmation = !$executor instanceof ConfirmationExecutor ? $executor->getAction()->getConfirmation() : ''; - if (!$confirmation) { - $confirmation = ''; + throw new Exception(['Unable to add QuickSearch without Menu']); } - $disabled = is_bool($executor->getAction()->enabled) ? !$executor->getAction()->enabled : $executor->getAction()->enabled; - return $this->getActionMenu()->addActionMenuItem($item, $executor, $confirmation, $disabled); - } + $view = $this->menu + ->addMenuRight()->addItem()->setElement('div') + ->add('View'); - /** - * @return Table\Column\ActionMenu - */ - private function getActionMenu() - { - if (!$this->actionMenu) { - $this->actionMenu = $this->table->addColumn(null, $this->actionMenuDecorator); - } - - return $this->actionMenu; // @phpstan-ignore-line - } + $this->quickSearch = $view->add(['jsSearch', 'reload' => $this->container]); - /** - * Add action menu items using Model. - * You may specify the scope of actions to be added. - * - * @param string|null $appliesTo the scope of model action - */ - public function addActionMenuFromModel(string $appliesTo = null): void - { - foreach ($this->model->getUserActions($appliesTo) as $action) { - $this->addActionMenuItem($action); + if ($q = $this->stickyGet('_q')) { + $cond = []; + foreach ($fields as $field) { + $cond[] = [$field, 'like', '%'.$q.'%']; + } + $this->model->addCondition($cond); } } /** - * An array of column name where filter is needed. - * Leave empty to include all column in grid. - * - * @param array|null $names an array with the name of column + * Adds a new button into the action column on the right. For CRUD this + * column will already contain "delete" and "edit" buttons. * - * @return $this + * @param string|array|View $button Label text, object or seed for the Button + * @param jsExpressionable|callable $action JavaScript action or callback + * @param bool|string $confirm Should we display confirmation "Are you sure?" */ - public function addFilterColumn($names = null) + public function addAction($button, $action, $confirm = false) { - if (!$this->menu) { - throw new Exception('Unable to add Filter Column without Menu'); + if (!$this->actions) { + $this->actions = $this->table->addColumn(null, 'Actions'); } - $this->menu->addItem(['Clear Filters'], new JsReload($this->table->reload, ['atk_clear_filter' => 1])); - $this->table->setFilterColumn($names); - return $this; + return $this->actions->addAction($button, $action, $confirm); } /** * Add a dropdown menu to header column. * - * @param string $columnName the name of column where to add dropdown - * @param array $items the menu items to add - * @param \Closure(string): (JsExpressionable|View|string|void) $fx the callback function to execute when an item is selected - * @param string $icon the icon - * @param string $menuId the menu ID return by callback + * @param string $columnName The name of column where to add dropdown. + * @param array $items The menu items to add. + * @param callable $fx The callback function to execute when an item is selected. + * @param string $icon The icon. + * @param string $menuId The menu id return by callback. + * + * @throws Exception */ - public function addDropdown(string $columnName, $items, \Closure $fx, $icon = 'caret square down', $menuId = null): void + public function addDropdown($columnName, $items, $fx, $icon = 'caret square down', $menuId = null) { $column = $this->table->columns[$columnName]; - + if (!isset($column)) { + throw new Exception('The column where you want to add dropdown does not exist: '.$columnName); + } if (!$menuId) { $menuId = $columnName; } - $column->addDropdown($items, function (string $item) use ($fx) { - return $fx($item); + $column->addDropdown($items, function ($item) use ($fx) { + return call_user_func($fx, [$item]); }, $icon, $menuId); } /** * Add a popup to header column. * - * @param string $columnName the name of column where to add popup - * @param Popup $popup popup view - * @param string $icon the icon + * @param string $columnName The name of column where to add popup. + * @param Popup $popup Popup view. + * @param string $icon The icon. + * + * @throws Exception * * @return mixed */ public function addPopup($columnName, $popup = null, $icon = 'caret square down') { $column = $this->table->columns[$columnName]; + if (!isset($column)) { + throw new Exception('The column where you want to add popup does not exist: '.$columnName); + } return $column->addPopup($popup, $icon); } @@ -501,156 +239,132 @@ public function addPopup($columnName, $popup = null, $icon = 'caret square down' * Similar to addAction but when button is clicked, modal is displayed * with the $title and $callback is executed through VirtualPage. * - * @param string|array|View $button - * @param string $title - * @param \Closure(View, string|null): void $callback - * @param array $args extra URL argument for callback - * - * @return View + * @param string|array|View $button + * @param string $title + * @param callable $callback function($page){ . .} */ - public function addModalAction($button, $title, \Closure $callback, $args = []) + public function addModalAction($button, $title, $callback) { - return $this->getActionButtons()->addModal($button, $title, $callback, $this, $args); - } + if (!$this->actions) { + $this->actions = $this->table->addColumn(null, 'Actions'); + } - /** - * Get sortBy value from URL parameter. - */ - public function getSortBy(): ?string - { - return $_GET[$this->sortTrigger] ?? null; + return $this->actions->addModal($button, $title, $callback, $this); } /** * Apply ordering to the current model as per the sort parameters. */ - public function applySort(): void + public function applySort() { - if ($this->sortable === false) { - return; - } - - $sortBy = $this->getSortBy(); - - if ($sortBy && $this->paginator) { - $this->paginator->addReloadArgs([$this->sortTrigger => $sortBy]); - } - - $isDesc = false; - if ($sortBy && substr($sortBy, 0, 1) === '-') { - $isDesc = true; - $sortBy = substr($sortBy, 1); + //$sortby = $this->app->stickyGET($this->name.'_sort', null); + $sortby = $this->stickyGet($this->name.'_sort'); + $desc = false; + if ($sortby && $sortby[0] == '-') { + $desc = true; + $sortby = substr($sortby, 1); } $this->table->sortable = true; - if ($sortBy && isset($this->table->columns[$sortBy]) && $this->model->hasField($sortBy)) { - $this->model->setOrder($sortBy, $isDesc ? 'desc' : 'asc'); - $this->table->sortBy = $sortBy; - $this->table->sortDirection = $isDesc ? 'desc' : 'asc'; + if ( + $sortby + && isset($this->table->columns[$sortby]) + && $this->model->hasElement($sortby) instanceof \atk4\data\Field + ) { + $this->model->setOrder($sortby, $desc); + $this->table->sort_by = $sortby; + $this->table->sort_order = $desc ? 'descending' : 'ascending'; } $this->table->on( 'click', - 'thead>tr>th.sortable', - new JsReload($this->container, [$this->sortTrigger => (new Jquery())->data('sort')]) + 'thead>tr>th', + new jsReload($this, [$this->name.'_sort' => (new jQuery())->data('column')]) ); } - /** - * Sets data Model of Grid. - * - * If $columns is not defined, then automatically will add columns for all - * visible model fields. If $columns is set to false, then will not add - * columns at all. - * - * @param array|null $columns - */ - public function setModel(Model $model, array $columns = null): void + public function setModel(\atk4\data\Model $model, $columns = null) { - $this->table->setModel($model, $columns); + $this->model = $this->table->setModel($model, $columns); + + if ($this->sortable === null) { + $this->sortable = true; + } - parent::setModel($model); + if ($this->sortable) { + $this->applySort(); + } + if ($this->quickSearch && is_array($this->quickSearch)) { + $this->addQuickSearch($this->quickSearch); + } - if ($this->searchFieldNames) { - $this->addQuickSearch($this->searchFieldNames, true); + if ($this->quickSearch && is_array($this->quickSearch)) { + $this->addQuickSearch($this->quickSearch); } + + return $this->model; } /** * Makes rows of this grid selectable by creating new column on the left with * checkboxes. * - * @return Table\Column\Checkbox + * @return TableColumn\CheckBox */ public function addSelection() { - $this->selection = $this->table->addColumn(null, [Table\Column\Checkbox::class]); + $this->selection = $this->table->addColumn(null, 'CheckBox'); - // Move last column to the beginning in table column array. - array_unshift($this->table->columns, array_pop($this->table->columns)); + // Move element to the beginning + $k = array_search($this->selection, $this->table->columns); + $this->table->columns = [$k => $this->table->columns[$k]] + $this->table->columns; return $this->selection; } /** * Add column with drag handler on each row. - * Drag handler allow to reorder table via drag and drop. - * - * @return Table\Column + * Drag handler allow to reorder table via drag n drop. */ public function addDragHandler() { - $handler = $this->table->addColumn(null, [Table\Column\DragHandler::class]); - + $handler = $this->table->addColumn(null, 'DragHandler'); // Move last column to the beginning in table column array. array_unshift($this->table->columns, array_pop($this->table->columns)); return $handler; } - private function setModelLimitFromPaginator(): void - { - $this->paginator->setTotal((int) ceil($this->model->executeCountQuery() / $this->ipp)); - $this->model->setLimit($this->ipp, ($this->paginator->page - 1) * $this->ipp); - } - - /** - * Before rendering take care of data sorting. - */ - protected function renderView(): void - { - // take care of sorting - if (!$this->table->jsPaginator) { - $this->applySort(); - } - - parent::renderView(); - } - - protected function recursiveRender(): void + public function recursiveRender() { // bind with paginator + // paginator is only supported for SQL persistences (has method 'action') if ($this->paginator) { - $this->setModelLimitFromPaginator(); + if ($this->model->persistence->hasMethod('action')) { + $this->paginator->reload = $this->container; + + $this->paginator->setTotal(ceil($this->model->action('count')->getOne() / $this->ipp)); + + $this->model->setLimit($this->ipp, ($this->paginator->page - 1) * $this->ipp); + } else { + $this->paginator->destroy(); + } } - if ($this->quickSearch instanceof JsSearch) { - $sortBy = $this->getSortBy(); - if ($sortBy) { - $this->container->js(true, $this->quickSearch->js()->atkJsSearch('setUrlArgs', [$this->sortTrigger, $sortBy])); + if ($this->quickSearch instanceof jsSearch) { + if ($sortby = $this->stickyGet($this->name.'_sort')) { + $this->container->js(true, $this->quickSearch->js()->atkJsSearch('setSortArgs', [$this->name.'_sort', $sortby])); } } - parent::recursiveRender(); + return parent::recursiveRender(); } /** * Proxy function for Table::jsRow(). - * - * @return Jquery */ - public function jsRow(): JsExpressionable + public function jsRow() { return $this->table->jsRow(); } diff --git a/src/HtmlTemplate.php b/src/HtmlTemplate.php index 43f74cf91f..60529f67f1 100644 --- a/src/HtmlTemplate.php +++ b/src/HtmlTemplate.php @@ -1,198 +1,289 @@ + * @copyright MIT + * + * @version 3.0 + * + * ==[ Version History ]======================================================= + * 1.0 First public version (released with AModules3 alpha) + * 1.1 Added support for "_top" tag + * Removed support for permanent tags + * Much more comments and other fixes + * 2.0 Reimplemented template parsing, now doing it with regexps + * 3.0 Re-integrated as part of Agile UI under MIT license */ -class HtmlTemplate +class Template implements \ArrayAccess { - use AppScopeTrait; - use WarnDynamicPropertyTrait; + use \atk4\core\AppScopeTrait; - public const TOP_TAG = '_top'; + // {{{ Properties of a template - /** @var array */ - private static array $_realpathCache = []; - /** @var array */ - private static array $_filesCache = []; + /** + * This array contains list of all tags found inside template implementing + * faster access when manipulating the template. + * + * @var array + */ + public $tags = []; - private static ?self $_parseCacheParentTemplate = null; - /** @var array> */ - private static array $_parseCache = []; + /** + * This is a parsed contents of the template organized inside an array. This + * structure makes it very simple to modify any part of the array. + * + * @var array + */ + public $template = []; - /** @var array */ - private array $tagTrees; + /** + * Contains information about where the template was loaded from. + * + * @var string + */ + public $source = null; - public function __construct(string $template = '') - { - $this->loadFromString($template); - } + /** @var string */ + public $default_exception = 'Exception_Template'; - public function _hasTag(string $tag): bool - { - return isset($this->tagTrees[$tag]); - } + // }}} + + // {{{ Core methods - initialization + + // Template creation, interface functions /** - * @param string|list $tag + * Construct template. + * + * @param string $template */ - public function hasTag($tag): bool + public function __construct($template = null) { - // check if all tags exist - if (is_array($tag)) { - foreach ($tag as $t) { - if (!$this->_hasTag($t)) { - return false; - } - } - - return true; + if ($template !== null) { + $this->loadTemplateFromString($template); } - - return $this->_hasTag($tag); } - public function getTagTree(string $tag): TagTree + /** + * Clone template. + */ + public function __clone() { - if (!isset($this->tagTrees[$tag])) { - throw (new Exception('Tag is not defined in template')) - ->addMoreInfo('tag', $tag) - ->addMoreInfo('template_tags', array_diff(array_keys($this->tagTrees), [self::TOP_TAG])); - } + $this->template = unserialize(serialize($this->template)); - return $this->tagTrees[$tag]; + unset($this->tags); + $this->rebuildTags(); } - private function cloneTagTrees(array $tagTrees): array + /** + * Returns relevant exception class. Use this method with "throw". + * + * @param string $message Static text of exception + * @param int $code Optional error code + * + * @return Exception + */ + public function exception($message = 'Undefined Exception', $code = null) { - $res = []; - foreach ($tagTrees as $k => $v) { - $res[$k] = $v->clone($this); + $arg = [ + $message, + 'tags' => implode(', ', array_keys($this->tags)), + 'template' => $this->template, + ]; + + if ($this->source) { + $arg['source'] = $this->source; } - return $res; + return new Exception($arg, $code); } - public function __clone() + // }}} + + // {{{ Tag manipulation + + /** + * Returns true if specified tag is a top-tag of the template. + * + * Since Agile Toolkit 4.3 this tag is always called _top. + * + * @param string $tag + * + * @return bool + */ + public function isTopTag($tag) { - $this->tagTrees = $this->cloneTagTrees($this->tagTrees); + return $tag == '_top'; } /** - * @return static + * This is a helper method which populates an array pointing + * to the place in the template referenced by a said tag. + * + * Because there might be multiple tags and getTagRef is + * returning only one template, it will return the first + * occurrence: + * + * {greeting}hello{/}, {greeting}world{/} + * + * calling getTagRef('greeting',$template) will point + * second argument towards &array('hello'); + * + * @param string $tag + * @param array $template + * + * @return $this */ - public function cloneRegion(string $tag): self + public function getTagRef($tag, &$template) { - $template = new static(); - $template->tagTrees = $template->cloneTagTrees($this->tagTrees); + if ($this->isTopTag($tag)) { + $template = &$this->template; - // rename top tag tree - $topTagTree = $template->tagTrees[$tag]; - unset($template->tagTrees[$tag]); - $template->tagTrees[self::TOP_TAG] = $topTagTree; - $topTag = self::TOP_TAG; - \Closure::bind(function () use ($topTagTree, $topTag) { - $topTagTree->tag = $topTag; - }, null, TagTree::class)(); - - // TODO prune unreachable nodes - // $template->rebuildTagsIndex(); + return $this; + } - if ($this->issetApp()) { - $template->setApp($this->getApp()); + $a = explode('#', $tag); + $tag = array_shift($a); + //$ref = array_shift($a); // unused + if (!isset($this->tags[$tag])) { + throw $this->exception('Tag not found in Template') + ->addMoreInfo('tag', $tag) + ->addMoreInfo('tags', implode(', ', array_keys($this->tags))); } + $template = reset($this->tags[$tag]); - return $template; + return $this; } - protected function _unsetFromTagTree(TagTree $tagTree, int $k): void + /** + * For methods which execute action on several tags, this method + * will return array of templates. You can then iterate + * through the array and update all the template values. + * + * {greeting}hello{/}, {greeting}world{/} + * + * calling getTagRefList('greeting',$template) will point + * second argument towards array(&array('hello'),&array('world')); + * + * If $tag is specified as array, then $templates will + * contain all occurrences of all tags from the array. + * + * @param string|array $tag + * @param array &$template + * + * @return bool + */ + public function getTagRefList($tag, &$template) { - \Closure::bind(function () use ($tagTree, $k) { - if ($k === array_key_last($tagTree->children)) { - array_pop($tagTree->children); - } else { - unset($tagTree->children[$k]); + if (is_array($tag)) { + // TODO: test + $res = []; + foreach ($tag as $t) { + $template = []; + $this->getTagRefList($t, $te); + + foreach ($template as &$tpl) { + $res[] = &$tpl; + } + + return true; } - }, null, TagTree::class)(); - } + } - protected function emptyTagTree(TagTree $tagTree): void - { - foreach ($tagTree->getChildren() as $k => $v) { - if ($v instanceof TagTree) { - $this->emptyTagTree($v); - } else { - $this->_unsetFromTagTree($tagTree, $k); + if ($this->isTopTag($tag)) { + $template = &$this->template; + + return false; + } + + $a = explode('#', $tag); + $tag = array_shift($a); + $ref = array_shift($a); + if (!$ref) { + if (!isset($this->tags[$tag])) { + throw $this->exception('Tag not found in Template') + ->addMoreInfo('tag', $tag); } + $template = $this->tags[$tag]; + + return true; + } + if (!isset($this->tags[$tag][$ref - 1])) { + throw $this->exception('Tag not found in Template') + ->addMoreInfo('tag', $tag); } + $template = [&$this->tags[$tag][$ref - 1]]; + + return true; } /** - * Internal method for setting or appending content in $tag. + * Checks if template has defined a specified tag. * - * If tag contains another tag trees, these tag trees are emptied. + * @param string|array $tag * - * @param string|array|Model $tag - * @param ($tag is array|Model ? never : string|null) $value + * @return bool */ - protected function _setOrAppend($tag, string $value = null, bool $encodeHtml = true, bool $append = false, bool $throwIfNotFound = true): void + public function hasTag($tag) { - if ($tag instanceof Model) { - if (!$encodeHtml) { - throw new Exception('HTML is not allowed to be dangerously set from Model'); - } - - $tag = $this->getApp()->uiPersistence->typecastSaveRow($tag, $tag->get()); + if (is_array($tag)) { + return true; } - // $tag passed as associative array [tag => value] - // in this case we don't throw exception if tags don't exist - if (is_array($tag) && $value === null) { - foreach ($tag as $k => $v) { - $this->_setOrAppend($k, $v, $encodeHtml, $append, false); - } + $a = explode('#', $tag); + $tag = array_shift($a); + //$ref = array_shift($a); // unused - return; - } + return isset($this->tags[$tag]) || $this->isTopTag($tag); + } - if (!is_string($tag) || $tag === '') { - throw (new Exception('Tag must be non-empty string')) - ->addMoreInfo('tag', $tag) - ->addMoreInfo('value', $value); - } + /** + * Re-create tag indexes from scratch for the whole template. + */ + public function rebuildTags() + { + $this->tags = []; - if ($value === null) { - $value = ''; - } + $this->rebuildTagsRegion($this->template); + } - $htmlValue = new HtmlValue(); - if ($encodeHtml) { - $htmlValue->set($value); - } else { - $htmlValue->dangerouslySetHtml($value); - } + /** + * Add tags from a specified region. + * + * @param array $template + */ + protected function rebuildTagsRegion(&$template) + { + foreach ($template as $tag => &$val) { + if (is_numeric($tag)) { + continue; + } - // set or append value - if (!$throwIfNotFound && !$this->hasTag($tag)) { - return; - } + $a = explode('#', $tag); + $key = array_shift($a); + $ref = array_shift($a); - $tagTree = $this->getTagTree($tag); - if (!$append) { - $this->emptyTagTree($tagTree); + $this->tags[$key][$ref] = &$val; + if (is_array($val)) { + $this->rebuildTagsRegion($val); + } } - $tagTree->add($htmlValue); } + // }}} + + // {{{ Manipulating contents of tags + /** * This function will replace region referred by $tag to a new content. * @@ -207,30 +298,46 @@ protected function _setOrAppend($tag, string $value = null, bool $encodeHtml = t * * would read and set multiple region values from $_GET array. * - * @param string|array|Model $tag - * @param ($tag is array|Model ? never : string|null) $value + * @param mixed $tag + * @param string|array $value + * @param bool $encode * * @return $this */ - public function set($tag, string $value = null): self + public function set($tag, $value = null, $encode = true) { - $this->_setOrAppend($tag, $value, true, false); + if (!$tag) { + return $this; + } - return $this; - } + if (is_object($tag)) { + $tag = $this->app->ui_persistence->typecastSaveRow($tag, $tag->get()); + } - /** - * Same as set(), but won't generate exception for non-existing - * $tag. - * - * @param string|array|Model $tag - * @param ($tag is array|Model ? never : string|null) $value - * - * @return $this - */ - public function trySet($tag, string $value = null): self - { - $this->_setOrAppend($tag, $value, true, false, false); + if (is_array($tag) && $value === null) { + foreach ($tag as $s => $v) { + $this->trySet($s, $v, $encode); + } + + return $this; + } + + if (is_array($value)) { + return $this; + } + + if (is_object($value)) { + throw new Exception(['Value should not be an object', 'value'=>$value]); + } + + if ($encode) { + $value = htmlspecialchars($value, ENT_NOQUOTES, 'UTF-8'); + } + + $this->getTagRefList($tag, $template); + foreach ($template as &$ref) { + $ref = [$value]; + } return $this; } @@ -239,61 +346,64 @@ public function trySet($tag, string $value = null): self * Set value of a tag to a HTML content. The value is set without * encoding, so you must be sure to sanitize. * - * @param string|array|Model $tag - * @param ($tag is array|Model ? never : string|null) $value - * - * @return $this + * @param mixed $tag + * @param string|array $value + * @param $this */ - public function dangerouslySetHtml($tag, string $value = null): self + public function setHTML($tag, $value = null) { - $this->_setOrAppend($tag, $value, false, false); - - return $this; + return $this->set($tag, $value, false); } /** - * See dangerouslySetHtml() but won't generate exception for non-existing + * See setHTML() but won't generate exception for non-existing * $tag. * - * @param string|array|Model $tag - * @param ($tag is array|Model ? never : string|null) $value - * - * @return $this + * @param mixed $tag + * @param string|array $value + * @param $this */ - public function tryDangerouslySetHtml($tag, string $value = null): self + public function trySetHTML($tag, $value = null) { - $this->_setOrAppend($tag, $value, false, false, false); - - return $this; + return $this->trySet($tag, $value, false); } /** - * Add more content inside a tag. - * - * @param string|array|Model $tag - * @param ($tag is array|Model ? never : string|null) $value + * Same as set(), but won't generate exception for non-existing + * $tag. * - * @return $this + * @param mixed $tag + * @param string|array $value + * @param bool $encode + * @param $this */ - public function append($tag, ?string $value): self + public function trySet($tag, $value = null, $encode = true) { - $this->_setOrAppend($tag, $value, true, true); + if (is_array($tag)) { + return $this->set($tag, $value, $encode); + } - return $this; + return $this->hasTag($tag) ? $this->set($tag, $value, $encode) : $this; } /** - * Same as append(), but won't generate exception for non-existing - * $tag. - * - * @param string|array|Model $tag - * @param ($tag is array|Model ? never : string|null) $value + * Add more content inside a tag. * - * @return $this + * @param mixed $tag + * @param string|array $value + * @param bool $encode + * @param $this */ - public function tryAppend($tag, ?string $value): self + public function append($tag, $value, $encode = true) { - $this->_setOrAppend($tag, $value, true, true, false); + if ($encode) { + $value = htmlspecialchars($value, ENT_NOQUOTES, 'UTF-8'); + } + + $this->getTagRefList($tag, $template); + foreach ($template as &$ref) { + $ref[] = $value; + } return $this; } @@ -302,59 +412,63 @@ public function tryAppend($tag, ?string $value): self * Add more content inside a tag. The content is appended without * encoding, so you must be sure to sanitize. * - * @param string|array|Model $tag - * @param ($tag is array|Model ? never : string|null) $value + * @param mixed $tag + * @param string|array $value * * @return $this */ - public function dangerouslyAppendHtml($tag, ?string $value): self + public function appendHTML($tag, $value) { - $this->_setOrAppend($tag, $value, false, true); - - return $this; + return $this->append($tag, $value, false); } /** - * Same as dangerouslyAppendHtml(), but won't generate exception for non-existing - * $tag. + * Get value of the tag. Note that this may contain an array + * if tag contains a structure. * - * @param string|array|Model $tag - * @param ($tag is array|Model ? never : string|null) $value + * @param string $tag * * @return $this */ - public function tryDangerouslyAppendHtml($tag, ?string $value): self + public function get($tag) { - $this->_setOrAppend($tag, $value, false, true, false); + $template = []; + $this->getTagRef($tag, $template); - return $this; + return $template; } /** * Empty contents of specified region. If region contains sub-hierarchy, * it will be also removed. * - * @param string|list $tag + * IMPORTANT: This does not dispose of the tags which were previously + * inside the region. This causes some severe pitfalls for the users + * and ideally must be checked and proper errors must be generated. + * + * @param string|array $tag * * @return $this */ - public function del($tag): self + public function del($tag) { if (is_array($tag)) { foreach ($tag as $t) { - $this->del($t); + $this->tryDel($t); } return $this; } + if ($this->isTopTag($tag)) { + $this->loadTemplateFromString(''); - $tagTree = $this->getTagTree($tag); - \Closure::bind(function () use ($tagTree) { - $tagTree->children = []; - }, null, TagTree::class)(); + return $this; + } - // TODO prune unreachable nodes - // $template->rebuildTagsIndex(); + $this->getTagRefList($tag, $template); + foreach ($template as &$ref) { + $ref = []; + } return $this; } @@ -362,213 +476,312 @@ public function del($tag): self /** * Similar to del() but won't throw exception if tag is not present. * - * @param string|list $tag + * @param string|array $tag * * @return $this */ - public function tryDel($tag): self + public function tryDel($tag) { if (is_array($tag)) { - foreach ($tag as $t) { - $this->tryDel($t); - } - - return $this; + return $this->del($tag); } - if ($this->hasTag($tag)) { - $this->del($tag); - } + return $this->hasTag($tag) ? $this->del($tag) : $this; + } - return $this; + // }}} + + // {{{ ArrayAccess support + public function offsetExists($name) + { + return $this->hasTag($name); + } + + public function offsetGet($name) + { + return $this->get($name); } + public function offsetSet($name, $val) + { + $this->set($name, $val); + } + + public function offsetUnset($name) + { + $this->del($name, null); + } + + // }}} + + // {{{ Template Manipulations + /** + * Executes call-back for each matching tag in the template. + * + * @param string|array $tag + * @param callable $callable + * * @return $this */ - public function loadFromFile(string $filename): self + public function eachTag($tag, $callable) { - if ($this->tryLoadFromFile($filename) !== false) { + if (!$this->hasTag($tag)) { return $this; } - throw (new Exception('Unable to read template from file')) - ->addMoreInfo('filename', $filename); + if ($this->getTagRefList($tag, $template)) { + foreach ($template as $key => $templ) { + $ref = $tag.'#'.($key + 1); + $this->tags[$tag][$key] = [call_user_func($callable, $this->recursiveRender($templ), $ref)]; + } + } else { + $this->tags[$tag][0] = [call_user_func($callable, $this->recursiveRender($template), $tag)]; + } + + return $this; } /** - * Same as load(), but will not throw an exception. + * Creates a new template using portion of existing template. * - * @return $this|false + * @param string $tag + * + * @return self */ - public function tryLoadFromFile(string $filename) + public function cloneRegion($tag) { - // realpath() is slow on Windows, so cache it and dedup only directories - $filenameBase = basename($filename); - $filename = dirname($filename); - if (!isset(self::$_realpathCache[$filename])) { - self::$_realpathCache[$filename] = realpath($filename); + if ($this->isTopTag($tag)) { + return clone $this; } - $filename = self::$_realpathCache[$filename]; - if ($filename === false) { - return false; - } - $filename .= '/' . $filenameBase; - if (!isset(self::$_filesCache[$filename])) { - $data = @file_get_contents($filename); - if ($data !== false) { - $data = preg_replace('~(?:\r\n?|\n)$~s', '', $data); // always trim end NL - } - self::$_filesCache[$filename] = $data; - } + $cl = get_class($this); + $n = new $cl(); + $n->app = $this->app; + $n->template = unserialize(serialize(['_top#1' => $this->get($tag)])); + $n->rebuildTags(); + $n->source = 'clone ('.$tag.') of template '.$this->source; - $str = self::$_filesCache[$filename]; - if ($str === false) { - return false; - } + return $n; + } - $this->loadFromString($str); + // }}} + + // {{{ Template Loading + + /** + * Loads template from a specified file. + * + * @param string $filename Template file name + * + * @return $this + */ + public function load($filename) + { + if (!is_readable($filename)) { + throw new Exception([ + 'Unable to read template from file', + 'file' => $filename, + ]); + } + $this->loadTemplateFromString(file_get_contents($filename)); + $this->source = 'loaded from file: '.$filename; return $this; } /** + * Initialize current template from the supplied string. + * + * @param string $str + * * @return $this */ - public function loadFromString(string $str): self + public function loadTemplateFromString($str) { + $this->source = 'string: '.$str; + $this->template = $this->tags = []; + if (!$str) { + return; + } + $this->tag_cnt = []; + + /* First expand self-closing tags {$tag} -> {tag}{/tag} */ + $str = preg_replace('/{\$([\w]+)}/', '{\1}{/\1}', $str); + $this->parseTemplate($str); return $this; } - protected function parseTemplateTree(array &$inputReversed, string $openedTag = null): TagTree - { - $tagTree = new TagTree($this, $openedTag ?? self::TOP_TAG); + // }}} + + // {{{ Template Parsing Engine + + /** + * Used for adding unique tag alternatives. E.g. if your template has + * {$name}{$name}, then first would become 'name#1' and second 'name#2', but + * both would still respond to 'name' tag. + * + * @var array + */ + private $tag_cnt = []; - $chunk = array_pop($inputReversed); - if ($chunk !== '') { - $tagTree->add((new HtmlValue())->dangerouslySetHtml($chunk)); + /** + * Register tags and return unique tag name. + * + * @param string $tag tag name + * + * @return string unique tag name + */ + protected function regTag($tag) + { + if (!isset($this->tag_cnt[$tag])) { + $this->tag_cnt[$tag] = 0; } - while (($tag = array_pop($inputReversed)) !== null) { - $firstChar = substr($tag, 0, 1); - if ($firstChar === '/') { // is closing tag - $tag = substr($tag, 1); - if ($openedTag === null - || ($tag !== '' && $tag !== $openedTag)) { - throw (new Exception('Template parse error: tag was not opened')) - ->addMoreInfo('opened_tag', $openedTag) - ->addMoreInfo('tag', $tag); - } + return $tag.'#'.(++$this->tag_cnt[$tag]); + } - $openedTag = null; + /** + * Recursively find nested tags inside a string, converting them to array. + * + * @param array $input + * @param array $template + * + * @return string|null + */ + protected function parseTemplateRecursive(&$input, &$template) + { + while (list(, $tag) = @each($input)) { - break; + // Closing tag + if ($tag[0] == '/') { + return substr($tag, 1); } - // is new/opening tag - $childTagTree = $this->parseTemplateTree($inputReversed, $tag); - $this->tagTrees[$tag] = $childTagTree; - $tagTree->addTag($tag); + if ($tag[0] == '$') { + $tag = substr($tag, 1); + $full_tag = $this->regTag($tag); + $template[$full_tag] = ''; // empty value + $this->tags[$tag][] = &$template[$full_tag]; + + // eat next chunk + $chunk = @each($input); + if ($chunk[1]) { + $template[] = $chunk[1]; + } - $chunk = array_pop($inputReversed); - if ($chunk !== null && $chunk !== '') { - $tagTree->add((new HtmlValue())->dangerouslySetHtml($chunk)); + continue; } - } - if ($openedTag !== null) { - throw (new Exception('Template parse error: tag is not closed')) - ->addMoreInfo('tag', $openedTag); - } + $full_tag = $this->regTag($tag); + + // Next would be prefix + list(, $prefix) = @each($input); + $template[$full_tag] = $prefix ? [$prefix] : []; + + $this->tags[$tag][] = &$template[$full_tag]; + + $this->parseTemplateRecursive($input, $template[$full_tag]); - return $tagTree; + $chunk = @each($input); + if ($chunk[1]) { + $template[] = $chunk[1]; + } + } } - protected function parseTemplate(string $str): void + /** + * Deploys parse recursion. + * + * @param string $str + */ + protected function parseTemplate($str) { - $cKey = static::class . "\0" . $str; - if (!isset(self::$_parseCache[$cKey])) { - // expand self-closing tags {$tag} -> {tag}{/tag} - $str = preg_replace('~\{\$([\w\-:]+)\}~', '{\1}{/\1}', $str); + $tag = '/{([\/$]?[-_\w]*)}/'; - $input = preg_split('~\{(/?[\w\-:]*)\}~', $str, -1, \PREG_SPLIT_DELIM_CAPTURE); - $inputReversed = array_reverse($input); // reverse to allow to use fast array_pop() + $input = preg_split($tag, $str, -1, PREG_SPLIT_DELIM_CAPTURE); - $this->tagTrees = []; - try { - $this->tagTrees[self::TOP_TAG] = $this->parseTemplateTree($inputReversed); - $tagTrees = $this->tagTrees; + list(, $prefix) = @each($input); + $this->template = [$prefix]; - if (self::$_parseCacheParentTemplate === null) { - $cKeySelfEmpty = self::class . "\0"; - self::$_parseCache[$cKeySelfEmpty] = []; - try { - self::$_parseCacheParentTemplate = new self(); - } finally { - unset(self::$_parseCache[$cKeySelfEmpty]); - } - } - $parentTemplate = self::$_parseCacheParentTemplate; - - \Closure::bind(function () use ($tagTrees, $parentTemplate) { - foreach ($tagTrees as $tagTree) { - $tagTree->parentTemplate = $parentTemplate; - } - }, null, TagTree::class)(); - self::$_parseCache[$cKey] = $tagTrees; - } finally { - $this->tagTrees = []; - } + $this->parseTemplateRecursive($input, $this->template); + } + + // }}} + + // {{{ Template Rendering + + /** + * Render either a whole template or a specified region. Returns + * current contents of a template. + * + * @param string $region + * + * @return string + */ + public function render($region = null) + { + if ($region) { + return $this->recursiveRender($this->get($region)); } - $this->tagTrees = $this->cloneTagTrees(self::$_parseCache[$cKey]); + return $this->recursiveRender($this->template); } - public function toLoadableString(string $region = self::TOP_TAG): string + /** + * Walk through the template array collecting the values + * and returning them as a string. + * + * @param array $template + * + * @return string + */ + protected function recursiveRender(&$template) { - $res = []; - foreach ($this->getTagTree($region)->getChildren() as $v) { - if ($v instanceof HtmlValue) { - $res[] = $v->getHtml(); - } elseif ($v instanceof TagTree) { - $tag = $v->getTag(); - $tagInnerStr = $this->toLoadableString($tag); - $res[] = $tagInnerStr === '' - ? '{$' . $tag . '}' - : '{' . $tag . '}' . $tagInnerStr . '{/' . $tag . '}'; + $output = ''; + foreach ($template as $val) { + if (is_array($val)) { + $output .= $this->recursiveRender($val); } else { - throw (new Exception('Value class has no save support')) - ->addMoreInfo('value_class', get_class($v)); + $output .= $val; } } - return implode('', $res); + return $output; } - public function renderToHtml(string $region = null): string - { - return $this->renderTagTreeToHtml($this->getTagTree($region ?? self::TOP_TAG)); - } + // }}} - protected function renderTagTreeToHtml(TagTree $tagTree): string + // {{{ Debugging functions + + /* + * Returns HTML-formatted code with all tags + * + public function _getDumpTags(&$template) { - $res = []; - foreach ($tagTree->getChildren() as $v) { - if ($v instanceof HtmlValue) { - $res[] = $v->getHtml(); - } elseif ($v instanceof TagTree) { - $res[] = $this->renderTagTreeToHtml($v); - } elseif ($v instanceof self) { // @phpstan-ignore-line - $res[] = $v->renderToHtml(); + $s = ''; + foreach ($template as $key => $val) { + if (is_array($val)) { + $s .= '{'.$key.'}'. + $this->_getDumpTags($val).'{/'.$key.'}'; } else { - throw (new Exception('Unexpected value class')) - ->addMoreInfo('value_class', get_class($v)); + $s .= htmlspecialchars($val); } } - return implode('', $res); + return $s; + } + /*** TO BE REFACTORED ***/ + + /* + * Output all tags + * + public function dumpTags() + { + echo '"'.$this->_getDumpTags($this->template).'"'; } + /*** TO BE REFACTORED ***/ + // }}} } diff --git a/src/Table.php b/src/Table.php index 4129290d99..3c2c9f8fbb 100644 --- a/src/Table.php +++ b/src/Table.php @@ -1,77 +1,109 @@ > Contains list of declared columns. Value will always be a column object. */ + /** + * Contains list of declared columns. Value will always be a column object. + * + * @var array + */ public $columns = []; /** - * Allows you to inject HTML into table using getHtmlTags hook and column callbacks. + * Allows you to inject HTML into table using getHTMLTags hook and column call-backs. * Switch this feature off to increase performance at expense of some row-specific HTML. * * @var bool */ - public $useHtmlTags = true; + public $use_html_tags = true; /** - * Determines a strategy on how totals will be calculated. Do not touch those fields - * directly, instead use addTotals(). + * Setting this to false will hide header row. * - * @var array|false + * @var bool */ - public $totalsPlan = false; - - /** @var bool Setting this to false will hide header row. */ public $header = true; - /** @var array Contains list of totals accumulated during the render process. */ + /** + * Determines a strategy on how totals will be calculated. + * + * Do not touch those fields directly, instead use addTotals() or setTotals(). + * + * @var array + */ + public $totals_plan = []; + + /** + * Contains list of totals accumulated during the render process. + * + * Don't use this property directly. Use addTotals() and setTotals() instead. + * + * @var array + */ public $totals = []; - /** @var HtmlTemplate|null Contain the template for the "Head" type row. */ - public $tHead; + /** + * Contain the template for the "Head" type row. + * + * @var Template + */ + public $t_head; - /** @var HtmlTemplate */ - public $tRowMaster; + /** + * Contain the template for the "Body" type row. + * + * @var Template + */ + public $t_row; - /** @var HtmlTemplate Contain the template for the "Body" type row. */ - public $tRow; + /** + * Contain the template for the "Foot" type row. + * + * @var Template + */ + public $t_totals; - /** @var HtmlTemplate Contain the template for the "Foot" type row. */ - public $tTotals; + /** + * Contains the output to show if table contains no rows. + * + * @var Template + */ + public $t_empty; /** * Set this if you want table to appear as sortable. This does not add any * mechanic of actual sorting - either implement manually or use Grid. * - * @var bool|null + * @var null|bool */ - public $sortable; + public $sortable = null; /** * When $sortable is true, you can specify which column will appear to have @@ -79,40 +111,20 @@ class Table extends Lister * * @var string */ - public $sortBy; + public $sort_by = null; /** - * When $sortable is true, and $sortBy is set, you can set order direction. + * When $sortable is true, and $sort_by is set, you can set this to + * "ascending" or "descending". * - * @var 'asc'|'desc'|null - */ - public $sortDirection; - - /** - * Make action columns in table use - * the collapsing CSS class. - * An action cell that is collapsing will - * only uses as much space as required. - * - * @var bool + * @var string */ - public $hasCollapsingCssActionColumn = true; + public $sort_order = null; - /** - * Create one column object that will be used to render all columns - * in the table unless you have specified a different column object. - */ - protected function initChunks(): void + public function __construct($class = null) { - if (!$this->tHead) { - $this->tHead = $this->template->cloneRegion('Head'); - $this->tRowMaster = $this->template->cloneRegion('Row'); - $this->tTotals = $this->template->cloneRegion('Totals'); - $this->tEmpty = $this->template->cloneRegion('Empty'); - - $this->template->del('Head'); - $this->template->del('Body'); - $this->template->del('Foot'); + if ($class) { + $this->addClass($class); } } @@ -128,239 +140,226 @@ protected function initChunks(): void * cells and will handle other things, like alignment. If you do not specify * column, then it will be selected dynamically based on field type. * - * If you don't want table column to be associated with model field, then - * pass $name parameter as null. - * - * @param string|null $name Data model field name - * @param array|Table\Column $columnDecorator - * @param ($name is null ? array{} : array|Field) $field + * @param string $name Data model field name + * @param array|string|object|null $columnDecorator + * @param array|string|object|null $field * - * @return Table\Column + * @return TableColumn\Generic */ - public function addColumn(?string $name, $columnDecorator = [], $field = []) + public function addColumn($name, $columnDecorator = null, $field = null) { - $this->assertIsInitialized(); - - if ($name !== null && isset($this->columns[$name])) { - throw (new Exception('Table column already exists')) - ->addMoreInfo('name', $name); + if (!$this->_initialized) { + throw new Exception\NoRenderTree($this, 'addColumn()'); } if (!$this->model) { - $this->model = new \Atk4\Ui\Misc\ProxyModel(); + $this->model = new \atk4\ui\misc\ProxyModel(); } - $this->model->assertIsModel(); - // should be vaguely consistent with Form\AbstractLayout::addControl() + // This code should be vaguely consistent with FormLayout\Generic::addField() - if ($name === null) { - $field = null; - } elseif (!$this->model->hasField($name)) { - $field = $this->model->addField($name, $field); - $field->neverPersist = true; - } else { - $field = $this->model->getField($name) - ->setDefaults($field); + if (is_string($field)) { + $field = ['type' => $field]; } - if ($field === null) { - // column is not associated with any model field - // TODO simplify to single $this->decoratorFactory call - $columnDecorator = $this->_addUnchecked(Table\Column::fromSeed($columnDecorator, ['table' => $this])); + if ($name) { + $existingField = $this->model->hasElement($name); } else { - $columnDecorator = $this->decoratorFactory($field, Factory::mergeSeeds($columnDecorator, ['columnData' => $name])); + $existingField = null; } - if ($name === null) { - $this->columns[] = $columnDecorator; + if (!$existingField) { + // Add missing field + if ($field) { + $field = $this->model->addField($name, $field); + $field->never_persist = true; + } else { + $field = $this->model->addField($name); + $field->never_persist = true; + } + } elseif (is_array($field)) { + // Add properties to existing field + $existingField->setDefaults($field); + $field = $existingField; + } elseif (is_object($field)) { + throw new Exception(['Duplicate field', 'name' => $name]); } else { - $this->columns[$name] = $columnDecorator; + $field = $existingField; } - return $columnDecorator; - } - - // TODO do not use elements/add(), elements are only for View based objects - private function _addUnchecked(Table\Column $column): Table\Column - { - return \Closure::bind(function () use ($column) { - return $this->_add($column); - }, $this, AbstractView::class)(); - } - - /** - * Set Popup action for columns filtering. - * - * @param array $cols an array with columns name that need filtering - */ - public function setFilterColumn($cols = null): void - { - if (!$this->model) { - throw new Exception('Model need to be defined in order to use column filtering'); - } - - // set filter to all column when null - if (!$cols) { - foreach ($this->model->getFields() as $key => $field) { - if (isset($this->columns[$key])) { - $cols[] = $field->shortName; - } + if (is_array($columnDecorator) || is_string($columnDecorator)) { + $columnDecorator = $this->decoratorFactory($field, $columnDecorator); + } elseif (!$columnDecorator) { + $columnDecorator = $this->decoratorFactory($field); + } elseif (is_object($columnDecorator)) { + if (!$columnDecorator instanceof \atk4\ui\TableColumn\Generic) { + throw new Exception(['Column decorator must descend from \atk4\ui\TableColumn\Generic', 'columnDecorator' => $columnDecorator]); } + $columnDecorator->table = $this; + $this->_add($columnDecorator); + } else { + throw new Exception(['Value of $columnDecorator argument is incorrect', 'columnDecorator' => $columnDecorator]); } - // create column popup - foreach ($cols as $colName) { - $col = $this->getColumn($colName); + if (is_null($name)) { + $this->columns[] = $columnDecorator; + } elseif (!is_string($name)) { + echo 'about to throw exception.....'; - $pop = $col->addPopup(new Table\Column\FilterPopup(['field' => $this->model->getField($colName), 'reload' => $this->reload, 'colTrigger' => '#' . $col->name . '_ac'])); - if ($pop->isFilterOn()) { - $col->setHeaderPopupIcon('table-filter-on'); - } - // apply condition according to popup form - $this->model = $pop->setFilterCondition($this->model); + throw new Exception(['Name must be a string', 'name' => $name]); + } elseif (isset($this->columns[$name])) { + throw new Exception(['Table already has column with $name. Try using addDecorator()', 'name' => $name]); + } else { + $this->columns[$name] = $columnDecorator; } + + return $columnDecorator; } /** * Add column Decorator. * - * @param array|Table\Column $seed - * - * @return Table\Column + * @param string $name Column name + * @param string|TableColumn/Generic $decorator */ - public function addDecorator(string $name, $seed) + public function addDecorator($name, $decorator) { - if (!isset($this->columns[$name])) { - throw (new Exception('Table column does not exist')) - ->addMoreInfo('name', $name); + if (!$this->columns[$name]) { + throw new Exception(['No such column, cannot decorate', 'name' => $name]); } - - $decorator = $this->_addUnchecked(Table\Column::fromSeed($seed, ['table' => $this])); + $decorator = $this->_add($this->factory($decorator, ['table' => $this], 'TableColumn')); if (!is_array($this->columns[$name])) { $this->columns[$name] = [$this->columns[$name]]; } $this->columns[$name][] = $decorator; - - return $decorator; } /** * Return array of column decorators for particular column. + * + * @param string $name Column name + * + * @return array */ - public function getColumnDecorators(string $name): array + public function getColumnDecorators($name) { $dec = $this->columns[$name]; + if (!is_array($dec)) { + $dec = [$dec]; + } - return is_array($dec) ? $dec : [$dec]; - } - - /** - * Return column instance or first instance if using decorator. - * - * @return Table\Column - */ - protected function getColumn(string $name) - { - // NOTE: It is not guaranteed that we will have only one element here. When adding decorators, the key will not - // contain the column instance anymore but an array with column instance set at 0 indexes and the rest as decorators. - // This is enough for fixing this issue right now. We can work on unifying decorator API in a separate PR. - return is_array($this->columns[$name]) ? $this->columns[$name][0] : $this->columns[$name]; + return $dec; } - /** - * @var array - */ - protected array $typeToDecorator = [ - 'atk4_money' => [Table\Column\Money::class], - 'text' => [Table\Column\Text::class], - 'boolean' => [Table\Column\Status::class, ['positive' => [true], 'negative' => [false]]], - ]; - /** * Will come up with a column object based on the field object supplied. * By default will use default column. * - * @param array|Table\Column $seed + * @param \atk4\data\Field $f Data model field + * @param array $seed Defaults to pass to factory() when decorator is initialized * - * @return Table\Column + * @return TableColumn\Generic */ - public function decoratorFactory(Field $field, $seed = []) + public function decoratorFactory(\atk4\data\Field $f, $seed = []) { - $seed = Factory::mergeSeeds( + $seed = $this->mergeSeeds( $seed, - $field->ui['table'] ?? null, - $this->typeToDecorator[$field->type] ?? null, - [Table\Column::class] + isset($f->ui['table']) ? $f->ui['table'] : null, + isset($this->typeToDecorator[$f->type]) ? $this->typeToDecorator[$f->type] : null, + ['Generic'] ); - return $this->_addUnchecked(Table\Column::fromSeed($seed, ['table' => $this])); + return $this->_add($this->factory($seed, ['table' => $this], 'TableColumn')); } + protected $typeToDecorator = [ + 'password' => 'Password', + 'money' => 'Money', + 'text' => 'Text', + 'boolean' => ['Status', ['positive' => [true], 'negative' => ['false']]], + ]; + /** - * Make columns resizable by dragging column header. + * Adds totals calculation plan. + * You can call this method multiple times to add more than one totals row. * - * The callback function will receive two parameter, a Jquery chain object and a array containing all table columns - * name and size. + * It returns totals plan ID which you can use to do some magic in rendering phase. * - * @param \Closure(Jquery, mixed): (JsExpressionable|View|string|void) $fx a callback function with columns widths as parameter - * @param array $widths ex: [100, 200, 300, 100] - * @param array $resizerOptions column-resizer module options, see https://www.npmjs.com/package/column-resizer + * @param array $plan Array of type [column => strategy] + * @param string|int $plan_id Optional totals plan id * - * @return $this + * @return string|int */ - public function resizableColumn($fx = null, $widths = null, $resizerOptions = []) + public function addTotals($plan = [], $plan_id = null) { - $options = []; - if ($fx !== null) { - $cb = JsCallback::addTo($this); - $cb->set(function (Jquery $chain, string $data) use ($fx) { - return $fx($chain, $this->getApp()->decodeJson($data)); - }, ['widths' => 'widths']); - $options['url'] = $cb->getJsUrl(); - } + // normalize plan + foreach ($plan as $field => $def) { + // title (string or callable) + if (is_string($def) || is_callable($def)) { + $plan[$field] = ['title' => $def]; + } - if ($widths !== null) { - $options['widths'] = $widths; + // "row" strategy + defaults + if (is_array($def) && isset($def[0])) { + $plan[$field]['row'] = $def[0]; + unset($plan[$field][0]); + } } - $options = array_merge($options, $resizerOptions); + // each plan can have some "hidden" columns + // we use them, for example, to count table rows while rendering + $plan['_row_count']['row'] = 'count'; - $this->js(true, $this->js()->atkColumnResizer($options)); + // save normalized plan + if ($plan_id !== null) { + $this->totals_plan[$plan_id] = $plan; + } else { + $this->totals_plan[] = $plan; + $plan_id = max(array_filter(array_keys($this->totals_plan), 'is_int')); + } - return $this; + return $plan_id; } /** - * Add a dynamic paginator, i.e. when user is scrolling content. + * Sets totals calculation plans [$plan_id=>[column=>strategy]]. + * This will overwrite all previously set plans. * - * @param int $ipp number of item per page to start with - * @param array $options an array with JS Scroll plugin options - * @param View $container the container holding the lister for scrolling purpose - * @param string $scrollRegion A specific template region to render. Render output is append to container HTML element. + * @param array $plans * * @return $this */ - public function addJsPaginator($ipp, $options = [], $container = null, $scrollRegion = 'Body') + public function setTotals($plans = []) { - $options = array_merge($options, ['appendTo' => 'tbody']); + // reset + $this->totals_plan = []; + + // add each plan + foreach ($plans as $plan_id => $plan) { + $this->addTotals($plan, $plan_id); + } - return parent::addJsPaginator($ipp, $options, $container, $scrollRegion); + return $this; } /** - * Override works like this:. - * [ - * 'name' => 'Totals for {$num} rows:', - * 'price' => '--', - * 'total' => ['sum'] - * ]. - * - * @param array $plan + * initChunks method will create one column object that will be used to render + * all columns in the table unless you have specified a different + * column object. */ - public function addTotals($plan = []): void + public function initChunks() { - $this->totalsPlan = $plan; + if (!$this->t_head) { + $this->t_head = $this->template->cloneRegion('Head'); + $this->t_row_master = $this->template->cloneRegion('Row'); + $this->t_totals = $this->template->cloneRegion('Totals'); + $this->t_empty = $this->template->cloneRegion('Empty'); + + $this->template->del('Head'); + $this->template->del('Body'); + $this->template->del('Foot'); + } } /** @@ -370,28 +369,44 @@ public function addTotals($plan = []): void * visible model fields. If $columns is set to false, then will not add * columns at all. * - * @param array|null $columns + * @param \atk4\data\Model $m Data model + * @param array|bool $columns + * + * @return \atk4\data\Model */ - public function setModel(Model $model, array $columns = null): void + public function setModel(\atk4\data\Model $m, $columns = null) { - $model->assertIsModel(); - - parent::setModel($model); + parent::setModel($m); if ($columns === null) { - $columns = array_keys($model->getFields('visible')); + $columns = []; + foreach ($m->elements as $name => $element) { + if (!$element instanceof \atk4\data\Field) { + continue; + } + + if ($element->isVisible()) { + $columns[] = $name; + } + } + } elseif ($columns === false) { + return $this->model; } foreach ($columns as $column) { $this->addColumn($column); } + + return $this->model; } - protected function renderView(): void + /** + * {@inheritdoc} + */ + public function renderView() { if (!$this->columns) { - throw (new Exception('Table does not have any columns defined')) - ->addMoreInfo('columns', $this->columns); + throw new Exception(['Table does not have any columns defined', 'columns' => $this->columns]); } if ($this->sortable) { @@ -400,82 +415,63 @@ protected function renderView(): void // Generate Header Row if ($this->header) { - $this->tHead->dangerouslySetHtml('cells', $this->getHeaderRowHtml()); - $this->template->dangerouslySetHtml('Head', $this->tHead->renderToHtml()); + $this->t_head->setHTML('cells', $this->getHeaderRowHTML()); + $this->template->setHTML('Head', $this->t_head->render()); } // Generate template for data row - $this->tRowMaster->dangerouslySetHtml('cells', $this->getDataRowHtml()); - $this->tRowMaster->set('dataId', '{$dataId}'); - $this->tRow = new HtmlTemplate($this->tRowMaster->renderToHtml()); - $this->tRow->setApp($this->getApp()); + $this->t_row_master->setHTML('cells', $this->getDataRowHTML()); + $this->t_row_master['_id'] = '{$_id}'; + $this->t_row = new Template($this->t_row_master->render()); + $this->t_row->app = $this->app; // Iterate data rows - $this->_renderedRowsCount = 0; - - // TODO we should not iterate using $this->model variable, - // then also backup/tryfinally would be not needed - // the same in Lister class - $modelBackup = $this->model; - $tRowBackup = $this->tRow; - try { - foreach ($this->model as $this->model) { - $this->currentRow = $this->model; - $this->tRow = clone $tRowBackup; - if ($this->hook(self::HOOK_BEFORE_ROW) === false) { - continue; - } + $rows = 0; + foreach ($this->model as $this->current_id => $tmp) { + $this->current_row = $this->model->get(); + if ($this->hook('beforeRow') === false) { + continue; + } - if ($this->totalsPlan) { - $this->updateTotals(); - } + if ($this->totals_plan) { + $this->updateTotals(); + } - $this->renderRow(); + $this->renderRow(); - ++$this->_renderedRowsCount; + $rows++; - if ($this->hook(self::HOOK_AFTER_ROW) === false) { - continue; - } - } - } finally { - $this->model = $modelBackup; - $this->tRow = $tRowBackup; + $this->hook('afterRow'); } // Add totals rows or empty message - if ($this->_renderedRowsCount === 0) { - if (!$this->jsPaginator || !$this->jsPaginator->getPage()) { - $this->template->dangerouslyAppendHtml('Body', $this->tEmpty->renderToHtml()); + if (!$rows) { + $this->template->appendHTML('Body', $this->t_empty->render()); + } elseif ($this->totals_plan) { + foreach (array_keys($this->totals_plan) as $plan_id) { + $this->t_totals->setHTML('cells', $this->getTotalsRowHTML($plan_id)); + $this->template->appendHTML('Foot', $this->t_totals->render()); } - } elseif ($this->totalsPlan) { - $this->tTotals->dangerouslySetHtml('cells', $this->getTotalsRowHtml()); - $this->template->dangerouslyAppendHtml('Foot', $this->tTotals->renderToHtml()); } - // stop JsPaginator if there are no more records to fetch - if ($this->jsPaginator && ($this->_renderedRowsCount < $this->ipp)) { - $this->jsPaginator->jsIdle(); - } - - View::renderView(); + return View::renderView(); } /** * Render individual row. Override this method if you want to do more * decoration. */ - public function renderRow(): void + public function renderRow() { - $this->tRow->set($this->model); + $this->t_row->set($this->model); - if ($this->useHtmlTags) { - // prepare row-specific HTML tags - $htmlTags = []; + if ($this->use_html_tags) { + // Prepare row-specific HTML tags. + $html_tags = []; - foreach ($this->hook(Table\Column::HOOK_GET_HTML_TAGS, [$this->model]) as $ret) { + foreach ($this->hook('getHTMLTags', [$this->model]) as $ret) { if (is_array($ret)) { - $htmlTags = array_merge($htmlTags, $ret); + $html_tags = array_merge($html_tags, $ret); } } @@ -483,19 +479,21 @@ public function renderRow(): void if (!is_array($columns)) { $columns = [$columns]; } - $field = is_int($name) ? null : $this->model->getField($name); + $field = $this->model->hasElement($name); foreach ($columns as $column) { - $htmlTags = array_merge($column->getHtmlTags($this->model, $field), $htmlTags); + if (method_exists($column, 'getHTMLTags')) { + $html_tags = array_merge($column->getHTMLTags($this->model, $field), $html_tags); + } } } // Render row and add to body - $this->tRow->dangerouslySetHtml($htmlTags); - $this->tRow->set('dataId', (string) $this->model->getId()); - $this->template->dangerouslyAppendHtml('Body', $this->tRow->renderToHtml()); - $this->tRow->del(array_keys($htmlTags)); + $this->t_row->setHTML($html_tags); + $this->t_row->set('_id', $this->model->id); + $this->template->appendHTML('Body', $this->t_row->render()); + $this->t_row->del(array_keys($html_tags)); } else { - $this->template->dangerouslyAppendHtml('Body', $this->tRow->renderToHtml()); + $this->template->appendHTML('Body', $this->t_row->render()); } } @@ -504,122 +502,154 @@ public function renderRow(): void * click outside of the body. Additionally when you move cursor over the * rows, pointer will be used and rows will be highlighted as you hover. * - * @param JsExpressionable|JsCallbackSetClosure $action Code to execute + * @param jsChain|callable $action Code to execute + * + * @return jQuery */ - public function onRowClick($action): void + public function onRowClick($action) { $this->addClass('selectable'); $this->js(true)->find('tbody')->css('cursor', 'pointer'); - // do not bubble row click event if click stems from row content like checkboxes - // TODO one ->on() call would be better, but we need a method to convert Closure $action into JsExpression first - $preventBubblingJs = new JsExpression(<<<'EOF' - let elem = event.target; - while (elem !== null && elem !== event.currentTarget) { - if (elem.tagName === 'A' || elem.classList.contains('atk4-norowclick') - || (elem.classList.contains('ui') && ['button', 'input', 'checkbox', 'dropdown'].some(v => elem.classList.contains(v)))) { - event.stopImmediatePropagation(); - } - elem = elem.parentElement; - } - EOF); - $this->on('click', 'tbody > tr', $preventBubblingJs, ['preventDefault' => false]); - - $this->on('click', 'tbody > tr', $action); + return $this->on('click', 'tbody>tr', $action); } /** - * Use this to quickly access the and wrap in Jquery. + * Use this to quickly access the and wrap in jQuery. * * $this->jsRow()->data('id'); * - * @return Jquery - */ - public function jsRow(): JsExpressionable - { - return (new Jquery())->closest('tr'); - } - - /** - * Remove a row in table using javascript using a model ID. - * - * @param string $id the model ID where row need to be removed - * @param string $transition the transition effect - * - * @return Jquery + * @return jQuery */ - public function jsRemoveRow($id, $transition = 'fade left'): JsExpressionable + public function jsRow() { - return $this->js()->find('tr[data-id=' . $id . ']')->transition($transition); + return (new jQuery(new jsExpression('this')))->closest('tr'); } /** * Executed for each row if "totals" are enabled to add up values. + * It will calculate requested totals for all total plans. */ - public function updateTotals(): void + public function updateTotals() { - foreach ($this->totalsPlan as $key => $val) { - // if value is array, then we treat it as built-in or closure aggregate method - if (is_array($val)) { - $f = $val[0]; - - // initial value is always 0 - if (!isset($this->totals[$key])) { - $this->totals[$key] = 0; + foreach ($this->totals_plan as $plan_id => $plan) { + $t = &$this->totals[$plan_id]; // shortcut + if (!$t) { + $t = []; + } + + // update totals for each column + foreach ($plan as $key => $def) { + // ignore column if "row" strategy is not set + if (!isset($def['row'])) { + continue; } - if ($f instanceof \Closure) { - $this->totals[$key] += $f($this->model->get($key), $key, $this); - } elseif (is_string($f)) { + // simply initialize array key, but don't set any value + // we can't set initial value to 0, because min/max or some custom totals + // methods can use this 0 as value for comparison and that's wrong + if (!array_key_exists($key, $t)) { + if (isset($def['default'])) { + $d = $def['default']; // shortcut + + $t[$key] = is_callable($d) + ? call_user_func_array($d, [$this->model[$key], $this->model]) + : $d; + } else { + $t[$key] = null; + } + } + + // calc row totals + $f = $def['row']; // shortcut + + // built-in functions + if (is_string($f)) { switch ($f) { case 'sum': - $this->totals[$key] += $this->model->get($key); - + // set initial value + if ($t[$key] === null) { + $t[$key] = 0; + } + // sum + $t[$key] = $t[$key] + $this->model[$key]; break; case 'count': - ++$this->totals[$key]; - + // set initial value + if ($t[$key] === null) { + $t[$key] = 0; + } + // increment + $t[$key]++; break; case 'min': - if ($this->model->get($key) < $this->totals[$key]) { - $this->totals[$key] = $this->model->get($key); + // set initial value + if ($t[$key] === null) { + $t[$key] = $this->model[$key]; + } + // compare + if ($this->model[$key] < $t[$key]) { + $t[$key] = $this->model[$key]; } - break; case 'max': - if ($this->model->get($key) > $this->totals[$key]) { - $this->totals[$key] = $this->model->get($key); + // set initial value + if ($t[$key] === null) { + $t[$key] = $this->model[$key]; + } + // compare + if ($this->model[$key] > $t[$key]) { + $t[$key] = $this->model[$key]; } - break; default: - throw (new Exception('Unsupported table aggregate function')) - ->addMoreInfo('name', $f); + throw new Exception([ + 'Aggregation method does not exist', + 'column' => $key, + 'method' => $f, + ]); } + + continue; } - } + + // Callable support + // Arguments: + // - current total value + // - current field value from model + // - \atk4\data\Model table model with current record loaded + // Should return new total value (for example, current value + current field value) + // NOTE: Keep in mind, that current total value initially can be null ! + if (is_callable($f)) { + $t[$key] = call_user_func_array($f, [$t[$key], $this->model[$key], $this->model]); + + continue; + } + }//foreach $plan } } /** * Responds with the HTML to be inserted in the header row that would * contain captions of all columns. + * + * @return string */ - public function getHeaderRowHtml(): string + public function getHeaderRowHTML() { $output = []; foreach ($this->columns as $name => $column) { + // If multiple formatters are defined, use the first for the header cell if (is_array($column)) { $column = $column[0]; } if (!is_int($name)) { - $field = $this->model->getField($name); - - $output[] = $column->getHeaderCellHtml($field); + $field = $this->model->getElement($name); + $output[] = $column->getHeaderCellHTML($field); } else { - $output[] = $column->getHeaderCellHtml(); + $output[] = $column->getHeaderCellHTML(); } } @@ -628,29 +658,55 @@ public function getHeaderRowHtml(): string /** * Responds with HTML to be inserted in the footer row that would - * contain totals for all columns. + * contain totals for all columns. This generates only one totals + * row for particular totals plan with $plan_id. + * + * @param string|int $plan_id + * + * @return string */ - public function getTotalsRowHtml(): string + public function getTotalsRowHTML($plan_id) { + // shortcuts + $plan = &$this->totals_plan[$plan_id]; + $totals = &$this->totals[$plan_id]; + $output = []; foreach ($this->columns as $name => $column) { - // if no totals plan, then show dash, but keep column formatting - if (!isset($this->totalsPlan[$name])) { - $output[] = $column->getTag('foot', '-'); + $field = $this->model->getElement($name); + // if no totals plan, then add empty cell, but keep column formatting + if (!isset($plan[$name])) { + $output[] = $column->getTotalsCellHTML($field, '', false); continue; } - // if totals plan is set as array, then show formatted value - if (is_array($this->totalsPlan[$name])) { - $field = $this->model->getField($name); - $output[] = $column->getTotalsCellHtml($field, $this->totals[$name]); + // if totals was calculated, then show formatted value + if (array_key_exists($name, $totals)) { + $output[] = $column->getTotalsCellHTML($field, $totals[$name], true); + continue; + } + // if no title set in totals plan, then add empty cell, but keep column formatting + if (!isset($plan[$name]['title'])) { + $output[] = $column->getTotalsCellHTML($field, '', false); continue; } - // otherwise just show it, for example, "Totals:" cell - $output[] = $column->getTag('foot', $this->totalsPlan[$name]); + // if title is set then just show it, for example, "Totals:" cell + // this can be passed as string or callable + $title = ''; + if (is_string($plan[$name]['title'])) { + $title = $plan[$name]['title']; + $title = new Template($title); + $title = $title->set($totals)->render(); + } elseif (is_callable($plan[$name]['title'])) { + $title = call_user_func_array($plan[$name]['title'], [isset($totals[$name]) ? $totals[$name] : null, $totals, $this->model]); + } + + // title can be defined as template and we fill in other total values if needed + + $output[] = $column->getTotalsCellHTML($field, $title, false); } return implode('', $output); @@ -658,13 +714,21 @@ public function getTotalsRowHtml(): string /** * Collects cell templates from all the columns and combine them into row template. + * + * @return string */ - public function getDataRowHtml(): string + public function getDataRowHTML() { $output = []; foreach ($this->columns as $name => $column) { - // if multiple formatters are defined, use the first for the header cell - $field = !is_int($name) ? $this->model->getField($name) : null; + + // If multiple formatters are defined, use the first for the header cell + + if (!is_int($name)) { + $field = $this->model->getElement($name); + } else { + $field = null; + } if (!is_array($column)) { $column = [$column]; @@ -672,22 +736,23 @@ public function getDataRowHtml(): string // we need to smartly wrap things up $cell = null; - $tdAttr = []; - foreach ($column as $cKey => $c) { - if ($cKey !== array_key_last($column)) { + $cnt = count($column); + $td_attr = []; + foreach ($column as $c) { + if (--$cnt) { $html = $c->getDataCellTemplate($field); - $tdAttr = $c->getTagAttributes('body', $tdAttr); + $td_attr = $c->getTagAttributes('body', $td_attr); } else { - // last formatter, ask it to give us whole rendering - $html = $c->getDataCellHtml($field, $tdAttr); + // Last formatter, ask it to give us whole rendering + $html = $c->getDataCellHTML($field, $td_attr); } if ($cell) { if ($name) { // if name is set, we can wrap things - $cell = str_replace('{$' . $name . '}', $cell, $html); + $cell = str_replace('{$'.$name.'}', $cell, $html); } else { - $cell .= ' ' . $html; + $cell = $cell.' '.$html; } } else { $cell = $html; diff --git a/src/Table/Column.php b/src/Table/Column.php index 162ed349b0..2b83e463f5 100644 --- a/src/Table/Column.php +++ b/src/Table/Column.php @@ -1,93 +1,90 @@ 1) { // prevent bad usage - throw new \Error('Too many method arguments'); - } + /** + * The tag value required for getTag when using an header action. + * + * @var array|null + */ + public $headerActionTag = null; + /** + * Constructor. + * + * @param array $defaults + */ + public function __construct($defaults = []) + { $this->setDefaults($defaults); } /** * Add popup to header. - * Use ColumnName for better popup positioning. * - * @param string $icon CSS class for filter icon + * @param Popup $popup + * @param string $id + * @param string $icon + * + * @throws Exception * * @return mixed */ - public function addPopup(Popup $popup = null, $icon = 'table-filter-off') + public function addPopup($popup = null, $icon = 'caret square down') { - $id = $this->name . '_ac'; - - $popup = $this->table->getOwner()->add($popup ?? [Popup::class])->setHoverable(); - - $this->setHeaderPopup($icon, $id); + if (!$this->app) { + throw new Exception('Columns\'s popup need to have a layout.'); + } + if (!$popup) { + $popup = $this->app->add('Popup')->setHoverable(); + } - $popup->triggerBy = '#' . $id; - $popup->popOptions = array_merge( - $popup->popOptions, - [ - 'on' => 'click', - 'position' => 'bottom left', - 'movePopup' => $this->columnData ? true : false, - 'target' => $this->columnData ? 'th[data-column=' . $this->columnData . ']' : false, - 'distanceAway' => -12, - ] - ); - $popup->stopClickEvent = true; + $this->setHeaderPopup($popup, $icon); return $popup; } @@ -95,52 +92,54 @@ public function addPopup(Popup $popup = null, $icon = 'table-filter-off') /** * Setup popup header action. * - * @param string $class the CSS class for filter icon - * @param string $id + * @param Popup $popup + * @param $icon */ - public function setHeaderPopup($class, $id): void + public function setHeaderPopup($popup, $icon = 'caret square down') { - $this->hasHeaderAction = true; + $this->headerAction = true; + $id = $this->name.'_ac'; - $this->headerActionTag = ['div', ['class' => 'atk-table-dropdown'], + $this->headerActionTag = ['div', ['class'=>'atk-table-dropdown'], [ - ['i', ['id' => $id, 'class' => $class . ' icon'], ''], + ['i', ['id' => $id, 'class' => $icon.' icon']], ], ]; - } + $popup->triggerBy = '#'.$id; + $popup->popOptions = array_merge($popup->popOptions, ['on' =>'click', 'position' => 'bottom right', 'movePopup' => false]); + $popup->stopClickEvent = true; - /** - * Set header popup icon. - * - * @param string $icon - */ - public function setHeaderPopupIcon($icon): void - { - $this->headerActionTag = ['div', ['class' => 'atk-table-dropdown'], - [ - ['i', ['id' => $this->name . '_ac', 'class' => $icon . ' icon'], ''], - ], - ]; + if (@$_GET['__atk_reload']) { + //This is part of a reload, need to reactivate popup. + $this->table->js(true, $popup->jsPopup()); + } } /** * Add a dropdown header menu. * - * @param \Closure(string, string): (JsExpressionable|View|string|void) $fx - * @param string $icon - * @param string|null $menuId the menu name + * @param array $items + * @param callable $fx + * @param string $icon + * @param string|null $menuId The menu name. + * + * @throws Exception */ - public function addDropdown(array $items, \Closure $fx, $icon = 'caret square down', $menuId = null): void + public function addDropdown($items, $fx, $icon = 'caret square down', $menuId = null) { - $menuItems = []; + $menuITems = []; foreach ($items as $key => $item) { - $menuItems[] = ['name' => is_int($key) ? $item : $key, 'value' => $item]; + if (is_int($key)) { + $menuITems[] = ['name' => $item, 'value' => $item]; + } else { + $menuITems[] = ['name' => $key, 'value' => $item]; + } } - $cb = $this->setHeaderDropdown($menuItems, $icon, $menuId); + $cb = $this->setHeaderDropdown($menuITems, $icon, $menuId); - $cb->onSelectItem(function (string $menu, string $item) use ($fx) { - return $fx($item, $menu); + $cb->onSelectItem(function ($menu, $item) use ($fx) { + return call_user_func($fx, $item, $menu); }); } @@ -149,43 +148,49 @@ public function addDropdown(array $items, \Closure $fx, $icon = 'caret square do * This method return a callback where you can detect * menu item change via $cb->onMenuItem($item) function. * - * @param array $items + * @param $items + * @param string $icon + * @param string|null $menuId The id of the menu. + * + * @throws Exception * - * @return Column\JsHeaderDropdownCallback + * @return \atk4\ui\jsCallback */ - public function setHeaderDropdown($items, string $icon = 'caret square down', string $menuId = null): JsCallback + public function setHeaderDropdown($items, $icon = 'caret square down', $menuId = null) { - $this->hasHeaderAction = true; - $id = $this->name . '_ac'; - $this->headerActionTag = ['div', ['class' => 'atk-table-dropdown'], [ + $this->headerAction = true; + $id = $this->name.'_ac'; + $this->headerActionTag = ['div', ['class'=>'atk-table-dropdown'], [ - 'div', ['id' => $id, 'class' => 'ui top left pointing dropdown', 'data-menu-id' => $menuId], - [['i', ['class' => $icon . ' icon'], '']], + [ + 'div', ['id' => $id, 'class'=>'ui top right pointing dropdown', 'data-menu-id' => $menuId], + [['i', ['class' => $icon.' icon']]], + ], ], - ]]; + ]; - $cb = Column\JsHeaderDropdownCallback::addTo($this->table); + $cb = $this->table->add(new jsHeader()); - $function = new JsExpression('function (value, text, item) { - if (value === undefined || value === \'\' || value === null) { - return; - } - $(this).api({ - on: \'now\', - url: \'' . $cb->getJsUrl() . '\', - data: { item: value, id: $(this).data(\'menu-id\') } - }); - }'); - - $chain = new Jquery('#' . $id); + $function = "function(value, text, item){ + if (value === undefined || value === '' || value === null) return; + $(this) + .api({ + on:'now', + url:'{$cb->getJSURL()}', + data:{item:value, id:$(this).data('menu-id')} + } + ); + }"; + + $chain = new jQuery('#'.$id); $chain->dropdown([ - 'action' => 'hide', - 'values' => $items, - 'onChange' => $function, - ]); + 'action' => 'hide', + 'values' => $items, + 'onChange' => new jsExpression($function), + ]); - // will stop grid column from being sorted. - $chain->on('click', new JsExpression('function (e) { e.stopPropagation(); }')); + //will stop grid column from being sorted. + $chain->on('click', new jsExpression('function(e){e.stopPropagation();}')); $this->table->js(true, $chain); @@ -229,14 +234,16 @@ public function setAttr($attr, $value, $position = 'body') return $this; } - public function getTagAttributes(string $position, array $attr = []): array + public function getTagAttributes($position, $attr = []) { // "all" applies on all positions - // $position is for specific position classes - foreach (['all', $position] as $key) { - if (isset($this->attr[$key])) { - $attr = array_merge_recursive($attr, $this->attr[$key]); - } + if (isset($this->attr['all'])) { + $attr = array_merge_recursive($attr, $this->attr['all']); + } + + // specific position classes + if (isset($this->attr[$position])) { + $attr = array_merge_recursive($attr, $this->attr[$position]); } return $attr; @@ -246,11 +253,13 @@ public function getTagAttributes(string $position, array $attr = []): array * Returns a suitable cell tag with the supplied value. Applies modifiers * added through addClass and setAttr. * - * @param string $position 'head', 'body' or 'tail' - * @param string|array $value either HTML or array defining HTML structure, see App::getTag help - * @param array $attr extra attributes to apply on the tag + * @param string $position - 'head', 'body' or 'tail' + * @param string $value - what is inside? either html or array defining HTML structure, see App::getTag help + * @param array $attr - extra attributes to apply on the tag + * + * @return string */ - public function getTag(string $position, $value, array $attr = []): string + public function getTag($position, $value, $attr = []) { $attr = $this->getTagAttributes($position, $attr); @@ -258,72 +267,78 @@ public function getTag(string $position, $value, array $attr = []): string $attr['class'] = implode(' ', $attr['class']); } - return $this->getApp()->getTag($position === 'body' ? 'td' : 'th', $attr, $value); + return $this->app->getTag($position == 'body' ? 'td' : 'th', $attr, $value); } /** * Provided with a field definition (from a model) will return a header * cell, fully formatted to be included in a Table. (). * - * @param mixed $value + * @param \atk4\data\Field $f + * + * @return string */ - public function getHeaderCellHtml(Field $field = null, $value = null): string + public function getHeaderCellHTML(\atk4\data\Field $f = null, $value = null) { - $tags = $this->table->hook(self::HOOK_GET_HEADER_CELL_HTML, [$this, $field, $value]); - if ($tags) { - return reset($tags); + if (!$this->table) { + throw new \atk4\ui\Exception(['How $table could not be set??', 'f' => $f, 'value' => $value]); } - - if ($field === null) { - return $this->getTag('head', $this->caption ?? '', $this->table->sortable ? ['class' => ['disabled']] : []); - } - - // if $this->caption is empty, header caption will be overridden by linked field definition - $caption = $this->caption ?? $field->getCaption(); - - $attr = [ - 'data-column' => $this->columnData, - ]; - - $class = 'atk-table-column-header'; - - if ($this->hasHeaderAction) { - $attr['id'] = $this->name . '_th'; - - // add the action tag to the caption - $caption = [$this->headerActionTag, $caption]; + if ($f === null) { + return $this->getTag('head', $this->caption ?: '', $this->table->sortable ? ['class' => ['disabled']] : []); } + // If table is being sorted by THIS column, set the proper class + $attr = []; if ($this->table->sortable) { - $attr['data-sort'] = $field->shortName; - - if ($this->sortable) { - $attr['class'] = ['sortable']; - } + $attr['data-column'] = $f->short_name; - // If table is being sorted by THIS column, set the proper class - if ($this->table->sortBy === $field->shortName) { - $class .= ' sorted ' . ['asc' => 'ascending', 'desc' => 'descending'][$this->table->sortDirection]; + if ($this->table->sort_by === $f->short_name) { + $attr['class'][] = 'sorted '.$this->table->sort_order; - if ($this->table->sortDirection === 'asc') { - $attr['data-sort'] = '-' . $attr['data-sort']; - } elseif ($this->table->sortDirection === 'desc') { - $attr['data-sort'] = ''; + if ($this->table->sort_order === 'ascending') { + $attr['data-column'] = '-'.$f->short_name; + } elseif ($this->table->sort_order === 'descending') { + $attr['data-column'] = ''; } } } - return $this->getTag('head', [['div', ['class' => $class], $caption]], $attr); + if ($this->headerAction) { + $attr = array_merge($attr, ['id' => $this->name.'_th']); + $tag = $this->getTag( + 'head', + [$f->getCaption(), + $this->headerActionTag, + ], + $attr + ); + } else { + $tag = $this->getTag( + 'head', + $f->getCaption(), + $attr + ); + } + + return $tag; } /** * Return HTML for a total value of a specific field. * - * @param mixed $value + * @param \atk4\data\Field $f + * @param mixed $value + * @param bool $typecast Should we typecast value + * + * @return string */ - public function getTotalsCellHtml(Field $field, $value): string + public function getTotalsCellHTML(\atk4\data\Field $f, $value, $typecast = true) { - return $this->getTag('foot', $this->getApp()->uiPersistence->typecastSaveField($field, $value)); + if ($typecast) { + $value = $this->app->ui_persistence->typecastSaveField($f, $value); + } + + return $this->getTag('foot', $value); } /** @@ -336,16 +351,20 @@ public function getTotalsCellHtml(Field $field, $value): string * will also be formatted before inserting, see UI Persistence formatting in the documentation. * * This method will be executed only once per table rendering, if you need to format data manually, - * you should use $this->table->onHook('beforeRow' or 'afterRow', ...); + * you should use $this->table->addHook('formatRow'); + * + * @param \atk4\data\Field $f + * + * @return string */ - public function getDataCellHtml(Field $field = null, array $attr = []): string + public function getDataCellHTML(\atk4\data\Field $f = null, $extra_tags = []) { - return $this->getTag('body', [$this->getDataCellTemplate($field)], $attr); + return $this->getTag('body', [$this->getDataCellTemplate($f)], $extra_tags); } /** * Provided with a field definition will return a string containing a "Template" - * that would produce CONTENTS OF cell when rendered. Example output:. + * that would produce CONTENS OF cell when rendered. Example output:. * * {$name} * @@ -353,23 +372,30 @@ public function getDataCellHtml(Field $field = null, array $attr = []): string * by another template returned by getDataCellTemplate when multiple formatters are * applied to the same column. The first one to be applied is executed first, then * a subsequent ones are executed. + * + * @param \atk4\data\Field $f + * + * @return string */ - public function getDataCellTemplate(Field $field = null): string + public function getDataCellTemplate(\atk4\data\Field $f = null) { - if ($field) { - return '{$' . $field->shortName . '}'; + if ($f) { + return '{$'.$f->short_name.'}'; + } else { + return '{_$'.$this->short_name.'}'; } - - return '{_$' . $this->shortName . '}'; } /** * Return associative array of tags to be filled with pre-rendered HTML on - * a column-basis. Will not be invoked if HTML output is turned off for the table. + * a column-basis. Will not be invoked if html-output is turned off for the table. + * + * @param array $row link to row data + * @param string $field field being rendered * - * @return array + * @return array Associative array with tags and their HTML values. */ - public function getHtmlTags(Model $row, ?Field $field): array + public function getHTMLTags($row, $field) { return []; } diff --git a/src/Table/Column/Link.php b/src/Table/Column/Link.php index 058149d421..06cca99945 100644 --- a/src/Table/Column/Link.php +++ b/src/Table/Column/Link.php @@ -1,161 +1,119 @@ 'id' ]); + * new Link(['order', 'id'=>'id' ]); * or * new Link(['order', 'id' ]); * or - * new Link([['order', 'x' => $myval], 'id']);. + * new Link([['order', 'x'=>$myval], 'id' ]);. */ -class Link extends Table\Column +class Link extends Generic { /** * If $url is set up, we will use pattern-matching to fill-in any part of this * with values of the model. * - * @var string|HtmlTemplate + * @var string|array Destination definition */ - public $url; + public $url = null; /** * If string 'example', then will be passed to $app->url('example') along with any defined arguments. * If set as array, then non-0 key/values will be also passed to the URL: - * $page = ['example', 'type' => '123'];. + * $page = ['example', 'type'=>'123'];. * - * $url = $app->url(['example', 'type' => '123']); + * $url = $app->url(['example', 'type'=>'123']); * * In addition to above "args" refer to values picked up from a current row. * - * @var string|array<0|string, string|int|false>|null + * @var array */ - public $page; + public $page = null; /** * When constructing a URL using 'page', this specifies list of values which will be added - * to the destination URL. For example if you set $args = ['document_id' => 'id'] then row value - * of ['id'] will be added to URL's property "document_id". + * to the destination URL. For example if you set $args = ['document_id'=>'id'] then row value + * of ['id'] will be added to url's property "document_id". * * For a full example: - * $page = ['example', 'type' => 'client']; - * $args = ['contact_id' => 'id']; + * $page = ['example', 'type'=>'client']; + * $args = ['contact_id'=>'id']; * * Link URL will be "example.php?type=client&contact_id=4" * * You can also pass non-key arguments ['id', 'title'] and they will be added up * as ?id=4&title=John%20Smith * - * @var array + * @var array */ public $args = []; - /** @var bool use value as label of the link */ - public $useLabel = true; - - /** @var string|null set element class. */ - public $class; - - /** @var string|null Use icon as label of the link. */ - public $icon; - - /** - * Set html5 target attribute in tag - * possible values: _blank | _parent | _self | _top | frame#name. - * - * @var string|null - */ - public $target; - - /** @var bool add download in the tag to force download from the URL. */ - public $forceDownload = false; - - /** - * @param string|array<0|string, string|int|false> $page - */ - public function __construct($page = [], array $args = [], array $defaults = []) + public function __construct($page = null, $args = []) { if (is_array($page)) { - $defaults['page'] = $page; - } else { - $defaults['url'] = $page; + $page = ['page' => $page]; + } elseif (is_string($page)) { + $page = ['url' => $page]; } - - $defaults['args'] = $args; - - parent::__construct($defaults); + if ($args) { + $page['args'] = $args; + } + parent::__construct($page); } - protected function init(): void + public function setDefaults($properties = [], $strict = false) { - parent::init(); - - if (is_string($this->url)) { - $this->url = new HtmlTemplate($this->url); + if (isset($properties[0])) { + $this->page = array_shift($properties); } - if (is_string($this->page)) { - $this->page = [$this->page]; + if (isset($properties[0])) { + $this->args = array_shift($properties); } + parent::setDefaults($properties); } - public function getDataCellTemplate(Field $field = null): string + public function init() { - $attr = ['href' => '{$c_' . $this->shortName . '}']; - - if ($this->forceDownload) { - $attr['download'] = 'true'; - } - - if ($this->target) { - $attr['target'] = $this->target; - } - - $icon = ''; - if ($this->icon) { - $icon = $this->getApp()->getTag('i', ['class' => $this->icon . ' icon'], ''); - } + parent::init(); - $label = ''; - if ($this->useLabel) { - $label = $field ? ('{$' . $field->shortName . '}') : '[Link]'; + if ($this->url && is_string($this->url)) { + $this->url = new \atk4\ui\Template($this->url); } - - if ($this->class) { - $attr['class'] = $this->class; + if ($this->page && is_string($this->page)) { + $this->page = [$this->page]; } + } - return $this->getApp()->getTag('a', $attr, [$icon, $label]); // TODO $label is not HTML encoded + public function getDataCellTemplate(\atk4\data\Field $f = null) + { + return ''.($f ? ('{$'.$f->short_name.'}') : '[Link]').''; } - public function getHtmlTags(Model $row, ?Field $field): array + public function getHTMLTags($row, $field) { + // Decide on the content if ($this->url) { - $rowValues = $this->getApp()->uiPersistence->typecastSaveRow($row, $row->get()); - - return ['c_' . $this->shortName => $this->url->set($rowValues)->renderToHtml()]; + return ['c_'.$this->short_name => $this->url->set($row->get())->render()]; } - $page = $this->page ?? []; + $p = $this->page ?: []; foreach ($this->args as $key => $val) { - if (is_int($key)) { + if (is_numeric($key)) { $key = $val; } - $page[$key] = $row->get($val); + if ($row->hasElement($val)) { + $p[$key] = $row[$val]; + } } - return ['c_' . $this->shortName => $this->table->url($page)]; + return ['c_'.$this->short_name => $this->table->url($p)]; } } diff --git a/tests/TotalsTest.php b/tests/TotalsTest.php new file mode 100644 index 0000000000..4e953afca5 --- /dev/null +++ b/tests/TotalsTest.php @@ -0,0 +1,105 @@ + [ + 1 => ['id'=>1, 'name'=>'Sock', 'type'=>'clothes', 'price'=>1, 'cnt'=>2, 'amount'=>2, 'balance'=>2], + 2 => ['id'=>2, 'name'=>'Hat', 'type'=>'clothes', 'price'=>5, 'cnt'=>5, 'amount'=>25, 'balance'=>27], + 3 => ['id'=>3, 'name'=>'Car', 'type'=>'transport', 'price'=>200, 'cnt'=>1, 'amount'=>200, 'balance'=>227], + 4 => ['id'=>4, 'name'=>'Bicycle', 'type'=>'transport', 'price'=>50, 'cnt'=>2, 'amount'=>100, 'balance'=>327], + ]]; + + $db = new \atk4\data\Persistence_Array($arr); + $m = new \atk4\data\Model($db, 'table'); + $m->addField('name'); + $m->addField('type'); + $m->addField('price'); + $m->addField('cnt'); + $m->addField('amount'); + $m->addField('balance'); + + $this->table = new \atk4\ui\Table(); + $this->table->init(); + $this->table->setModel($m, ['name', 'type', 'price', 'cnt', 'amount', 'balance']); + } + + /** + * Test built-in totals methods. + */ + public function testBuiltinRowTotals() + { + // add one totals plan to calculate built-in row totals + $this->table->addTotals([ + 'name' => 'Totals:', // Totals: + 'type' => ['count'], // 4 + 'price' => ['min'], // 1 + 'cnt' => ['max'], // 5 + 'amount' => ['sum'], // 327 + ]); + + // need to render to calculate row totals + $this->table->render(); + + // assert + $this->assertEquals([ + 'type' => 4, + 'price' => 1, + 'cnt' => 5, + 'amount' => 327, + '_row_count'=> 4, + ], $this->table->totals[0] + ); + } + + /** + * Test advanced totals methods. + */ + public function testAdvancedRowTotals() + { + // add first totals plan + $this->table->addTotals([ + 'name' => 'Total {$_row_count} rows', // Total 4 rows + 'type' => function ($totals, $model) { + return 'Pay me: '.($totals['price'] * $totals['cnt']); + }, // 25600 + 'price' => [ + function ($total, $value, $model) { + return $total + $value; + }, + ], // 256 - simple sum(price) + 'cnt' => [ + function ($total, $value, $model) { + return max($total, $value); + }, + 'default' => 100, + ], // 100 - uses default value max(100, max(cnt)) + 'amount' => [ + 'sum', + 'default' => function ($value, $model) { + return $value * 1000; + }, + ], // 2327 = 2*1000 + sum(amount) + ], 'first'); + + // need to render to calculate row totals + $this->table->render(); + + // assert + $this->assertEquals([ + //'type' => 25600, + 'price' => 256, + 'cnt' => 100, + 'amount' => 2327, + '_row_count'=> 4, + ], $this->table->totals['first'] + ); + } +}