From 4c68ff7a3344b682f67c400822d331fad7ad5a43 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Mon, 5 Feb 2018 01:06:38 -0500 Subject: [PATCH] feat: Add DashboardAction Closes #185 --- docs/_partials/pages/dashboard/elements.rst | 16 +++ docs/_partials/pages/dashboard/viewblocks.rst | 7 + docs/contents.rst | 8 ++ .../customizing-the-dashboard-page.rst | 136 ++++++++++++++++++ src/Action/DashboardAction.php | 43 ++++++ src/Dashboard/Dashboard.php | 86 +++++++++++ src/Dashboard/Module/ActionLinkItem.php | 48 +++++++ src/Dashboard/Module/LinkItem.php | 103 +++++++++++++ src/Listener/Traits/IndexTypeTrait.php | 6 + src/Listener/ViewListener.php | 32 ++++- src/Template/Cell/DashboardTable/display.ctp | 22 +++ src/Template/Scaffold/dashboard.ctp | 16 +++ src/View/Cell/DashboardTableCell.php | 33 +++++ src/View/CrudView.php | 4 +- src/View/Helper/CrudViewHelper.php | 4 +- tests/TestCase/Dashboard/DashboardTest.php | 53 +++++++ .../Dashboard/Module/ActionLinkItemTest.php | 46 ++++++ .../Dashboard/Module/LinkItemTest.php | 71 +++++++++ webroot/css/local.css | 4 + 19 files changed, 726 insertions(+), 12 deletions(-) create mode 100644 docs/_partials/pages/dashboard/elements.rst create mode 100644 docs/_partials/pages/dashboard/viewblocks.rst create mode 100644 docs/dashboard-pages/customizing-the-dashboard-page.rst create mode 100644 src/Action/DashboardAction.php create mode 100644 src/Dashboard/Dashboard.php create mode 100644 src/Dashboard/Module/ActionLinkItem.php create mode 100644 src/Dashboard/Module/LinkItem.php create mode 100644 src/Template/Cell/DashboardTable/display.ctp create mode 100644 src/Template/Scaffold/dashboard.ctp create mode 100644 src/View/Cell/DashboardTableCell.php create mode 100644 tests/TestCase/Dashboard/DashboardTest.php create mode 100644 tests/TestCase/Dashboard/Module/ActionLinkItemTest.php create mode 100644 tests/TestCase/Dashboard/Module/LinkItemTest.php diff --git a/docs/_partials/pages/dashboard/elements.rst b/docs/_partials/pages/dashboard/elements.rst new file mode 100644 index 00000000..81eff7ec --- /dev/null +++ b/docs/_partials/pages/dashboard/elements.rst @@ -0,0 +1,16 @@ +Available Elements +------------------ + +All the *CrudView* templates are built from several elements that can be +overridden by creating them in your own ``src/Template/Element`` folder. The +following sections will list all the elements that can be overridden for each +type of action. + +In general, if you want to override a template, it is a good idea to copy the +original implementation from +``vendor/friendsofcake/crud-view/src/Template/Element`` + +action-header + Create ``src/Template/Element/action-header.ctp`` to have full control over + what is displayed at the top of the page. This is shared across all page + types. diff --git a/docs/_partials/pages/dashboard/viewblocks.rst b/docs/_partials/pages/dashboard/viewblocks.rst new file mode 100644 index 00000000..a73bf5a9 --- /dev/null +++ b/docs/_partials/pages/dashboard/viewblocks.rst @@ -0,0 +1,7 @@ +Available Viewblocks +-------------------- + +The following custom view blocks are available for use within forms: + +- ``dashboard.before``: Rendered before the entire dashboard is rendered. +- ``dashboard.after``: Rendered after the entire dashboard is rendered. diff --git a/docs/contents.rst b/docs/contents.rst index 75dca1a3..56f376f8 100644 --- a/docs/contents.rst +++ b/docs/contents.rst @@ -35,6 +35,14 @@ Contents Sidebar Navigation Utility Navigation +.. _dashboard-pages-docs: + +.. toctree:: + :maxdepth: 3 + :caption: Dashboard Pages + + Customizing the Dashboard + .. _index-pages-docs: .. toctree:: diff --git a/docs/dashboard-pages/customizing-the-dashboard-page.rst b/docs/dashboard-pages/customizing-the-dashboard-page.rst new file mode 100644 index 00000000..d7454e5a --- /dev/null +++ b/docs/dashboard-pages/customizing-the-dashboard-page.rst @@ -0,0 +1,136 @@ +Customizing the Dashboard Page +============================== + +The "dashboard" can be used to display a default landing page for CrudView-powered +admin sites. It is made of several ``\Cake\View\Cell`` +instances, and can be extended to display items other than what is shipped with CrudView. + +To use the "Dashboard", the custom ``DashboardAction`` needs to be mapped: + +.. code-block:: php + + public function initialize() + { + parent::initialize(); + + $this->Crud->mapAction('dashboard', 'CrudView.Dashboard'); + } + +Browsing to this mapped action will result in a blank page. To customize it, a +``\CrudView\Dashboard\Dashboard`` can be configured on the ``scaffold.dashboard`` key: + +.. code-block:: php + + public function dashboard() + { + $dashboard = new \CrudView\Dashboard\Dashboard(); + $this->Crud->action()->setConfig('scaffold.dashboard', $dashboard); + return $this->Crud->execute(); + } + + +The ``\CrudView\Dashboard\Dashboard`` instance takes two arguments: + +- ``title``: The title for the dashboard view. Defaults to ``Dashboard``. +- ``columns`` A number of columns to display on the view. Defaults to ``1``. + +.. code-block:: php + + public function dashboard() + { + // setting both the title and the number of columns + $dashboard = new \CrudView\Dashboard\Dashboard(__('Site Administration'), 12); + $this->Crud->action()->setConfig('scaffold.dashboard', $dashboard); + return $this->Crud->execute(); + } + +Adding Cells to the Dashboard +------------------------------- + +Any number of cells may be added to the Dashboard. All cells *must* extend the +``\Cake\View\Cell`` class. + +Cells can be added via the ``Dashboard::addToColumn()`` method. It takes a cell +instance and a column number as arguments. + +.. code-block:: php + + // assuming the `CellTrait` is in use, we can generate a cell via `$this->cell()` + $someCell = $this->cell('SomeCell'); + $dashboard = new \CrudView\Dashboard\Dashboard(__('Site Administration'), 2); + + // add to the first column + $dashboard->addToColumn($someCell); + + // configure the column to add to + $dashboard->addToColumn($someCell, 2); + +CrudView ships with the ``DashboardTable`` cell by default. + +CrudView.DashboardTable +~~~~~~~~~~~~~~~~~~~~~~~ + +This can be used to display links to items in your application or offiste. + +.. code-block:: php + + public function dashboard() + { + // setting both the title and the number of columns + $dashboard = new \CrudView\Dashboard\Dashboard(__('Site Administration'), 1); + $dashboard->addToColumn($this->cell('CrudView.DashboardTable', [ + 'title' => 'Important Links' + ])); + + $this->Crud->action()->setConfig('scaffold.dashboard', $dashboard); + return $this->Crud->execute(); + } + +In the above example, only a title to the ``DashboardTable``, which will +show a single subheading for your Dashboard. + +In addition to showing a title, it is also possible to show a list of links. This can +be done by adding a ``links`` key with an array of ``LinkItem`` objects as the value. +Links containing urls for external websites will open in a new window by default. + +.. code-block:: php + + public function dashboard() + { + // setting both the title and the number of columns + $dashboard = new \CrudView\Dashboard\Dashboard(__('Site Administration'), 1); + $dashboard->addToColumn($this->cell('CrudView.DashboardTable', [ + 'title' => 'Important Links', + 'links' => [ + new LinkItem('Example', 'https://example.com', ['target' => '_blank']), + ], + ])); + + $this->Crud->action()->setConfig('scaffold.dashboard', $dashboard); + return $this->Crud->execute(); + } + +There is also a special kind of ``LinkItem`` called an ``ActionLinkItem``. This +has a fourth argument is an array of ``LinkItem`` objects. It can be used to show +embedded action links on the same row. + +.. code-block:: php + + public function dashboard() + { + $dashboard = new \CrudView\Dashboard\Dashboard(__('Site Administration'), 1); + $dashboard->addToColumn($this->cell('CrudView.DashboardTable', [ + 'title' => 'Important Links', + 'links' => [ + new ActionLinkItem('Posts', ['controller' => 'Posts'], [], [ + new LinkItem('Add', ['controller' => 'Posts', 'action' => 'add']), + ]), + ], + ])); + + $this->Crud->action()->setConfig('scaffold.dashboard', $dashboard); + return $this->Crud->execute(); + } + +.. include:: /_partials/pages/dashboard/viewblocks.rst +.. include:: /_partials/pages/dashboard/elements.rst diff --git a/src/Action/DashboardAction.php b/src/Action/DashboardAction.php new file mode 100644 index 00000000..163e35d1 --- /dev/null +++ b/src/Action/DashboardAction.php @@ -0,0 +1,43 @@ + true, + 'view' => null, + ]; + + /** + * HTTP GET handler + * + * @return void|\Cake\Network\Response + */ + protected function _get() + { + $pageTitle = $this->getConfig('scaffold.page_title', __d('CrudView', 'Dashboard')); + $this->setConfig('scaffold.page_title', $pageTitle); + $this->setConfig('scaffold.autoFields', false); + $this->setConfig('scaffold.fields', ['dashboard']); + $this->setConfig('scaffold.actions', []); + + $dashboard = $this->getConfig('scaffold.dashboard', new Dashboard($pageTitle)); + $subject = $this->_subject([ + 'success' => true, + 'dashboard' => $dashboard, + ]); + + $this->_trigger('beforeRender', $subject); + + $controller = $this->_controller(); + $controller->set('dashboard', $subject->dashboard); + $controller->set('viewVar', 'dashboard'); + $controller->set('title', $subject->dashboard->get('title')); + } +} diff --git a/src/Dashboard/Dashboard.php b/src/Dashboard/Dashboard.php new file mode 100644 index 00000000..cb4599e2 --- /dev/null +++ b/src/Dashboard/Dashboard.php @@ -0,0 +1,86 @@ +set('title', $title); + $this->set('children', []); + $this->set('columns', $columns); + } + + /** + * Returns the children from a given column + * + * @param int $column a column number + * @return array + */ + public function getColumnChildren($column) + { + $children = $this->get('children'); + if (isset($children[$column])) { + return $children[$column]; + } + + return []; + } + + /** + * Adds a Cell to a given column + * + * @param Cell $module instance of Cell + * @param int $column a column number + * @return $this + */ + public function addToColumn(Cell $module, $column = 1) + { + $children = $this->get('children'); + $children[$column][] = $module; + $this->set('children', $children); + + return $this; + } + + /** + * columns property setter + * + * @param int $value A column count + * @return int + * @throws \InvalidArgumentException the column count is invalid + */ + protected function _setColumns($value) + { + $columnMap = [ + 1 => 12, + 2 => 6, + 3 => 4, + 4 => 3, + 6 => 2, + 12 => 1, + ]; + if (!in_array($value, [1, 2, 3, 4, 6, 12])) { + throw new InvalidArgumentException('Valid columns value must be one of [1, 2, 3, 4, 6, 12]'); + } + + $this->set('columnClass', sprintf('col-md-%d', $columnMap[$value])); + + return $value; + } +} diff --git a/src/Dashboard/Module/ActionLinkItem.php b/src/Dashboard/Module/ActionLinkItem.php new file mode 100644 index 00000000..7bfc08f8 --- /dev/null +++ b/src/Dashboard/Module/ActionLinkItem.php @@ -0,0 +1,48 @@ +` tags. + * Can be an array if $url is null. If $url is null, $title will be used as both the URL and title. + * @param string|array|null $url Cake-relative URL or array of URL parameters, or + * external URL (starts with http://) + * @param array $options Array of options and HTML attributes. + * @param array $actions Array of ActionItems + */ + public function __construct($title, $url, $options = [], array $actions = []) + { + parent::__construct($title, $url, $options); + $this->set('actions', $actions); + } + + /** + * options property setter + * + * @param array $actions Array of options and HTML attributes. + * @return array + */ + protected function _setActions($actions) + { + return (new Collection($actions))->map(function ($value) { + $options = (array)$value->get('options') + ['class' => ['btn btn-default']]; + $value->set('options', $options); + + return $value; + })->toArray(); + } +} diff --git a/src/Dashboard/Module/LinkItem.php b/src/Dashboard/Module/LinkItem.php new file mode 100644 index 00000000..e458c312 --- /dev/null +++ b/src/Dashboard/Module/LinkItem.php @@ -0,0 +1,103 @@ +` tags. + * + * @var string + **/ + protected $title; + + /** + * Cake-relative URL or array of URL parameters, or + * external URL (starts with http://) + * + * @var string|array|null + */ + protected $url = null; + + /** + * Array of options and HTML attributes. + * + * @var array + **/ + protected $options = []; + + /** + * Constructor + * + * @param string|array $title The content to be wrapped by `` tags. + * Can be an array if $url is null. If $url is null, $title will be used as both the URL and title. + * @param string|array|null $url Cake-relative URL or array of URL parameters, or + * external URL (starts with http://) + * @param array $options Array of options and HTML attributes. + */ + public function __construct($title, $url, $options = []) + { + $this->set('title', $title); + $this->set('url', $url); + $this->set('options', $options); + } + + /** + * title property setter + * + * @param string|array|null $title A title for the link + * @return string + */ + protected function _setTitle($title) + { + if (empty($title)) { + throw new InvalidArgumentException('Missing title for LinkItem action'); + } + + return $title; + } + /** + * url property setter + * + * @param string|array|null $url Cake-relative URL or array of URL parameters, or + * external URL (starts with http://) + * @return string|array + */ + protected function _setUrl($url) + { + if ($url === null || empty($url)) { + throw new InvalidArgumentException('Invalid url specified for LinkItem'); + } + + return $url; + } + + /** + * options property setter + * + * @param array $options Array of options and HTML attributes. + * @return string|array + */ + protected function _setOptions($options) + { + if (empty($options)) { + $options = []; + } + + $url = $this->get('url'); + if (!is_array($url)) { + $isHttp = substr($url, 0, 7) === 'http://'; + $isHttps = substr($url, 0, 8) === 'https://'; + if ($isHttp || $isHttps) { + $options += ['target' => '_blank']; + } + } + + return $options; + } +} diff --git a/src/Listener/Traits/IndexTypeTrait.php b/src/Listener/Traits/IndexTypeTrait.php index 010ebb74..599ef77d 100644 --- a/src/Listener/Traits/IndexTypeTrait.php +++ b/src/Listener/Traits/IndexTypeTrait.php @@ -1,6 +1,8 @@ _action() instanceof IndexAction)) { + return; + } + $controller = $this->_controller(); $controller->set('indexFinderScopes', $this->_getIndexFinderScopes()); $controller->set('indexFormats', $this->_getIndexFormats()); diff --git a/src/Listener/ViewListener.php b/src/Listener/ViewListener.php index 06a3c489..0d2c25bf 100644 --- a/src/Listener/ViewListener.php +++ b/src/Listener/ViewListener.php @@ -3,6 +3,7 @@ use Cake\Collection\Collection; use Cake\Core\Configure; +use Cake\Database\Exception; use Cake\Event\Event; use Cake\Utility\Hash; use Cake\Utility\Inflector; @@ -45,7 +46,7 @@ public function beforeFind(Event $event) $this->associations = $this->_associations(array_keys($related)); } - if (!$event->getSubject()->query->contain()) { + if (!$event->getSubject()->query->getContain()) { $event->getSubject()->query->contain($related); } } @@ -262,15 +263,22 @@ protected function _getPageVariables() $data = [ 'modelClass' => $controller->modelClass, - 'modelSchema' => $table->getSchema(), - 'displayField' => $table->getDisplayField(), 'singularHumanName' => Inflector::humanize(Inflector::underscore(Inflector::singularize($controller->modelClass))), 'pluralHumanName' => Inflector::humanize(Inflector::underscore($controller->getName())), 'singularVar' => Inflector::singularize($controller->getName()), 'pluralVar' => Inflector::variable($controller->getName()), - 'primaryKey' => $table->getPrimaryKey(), ]; + try { + $data += [ + 'modelSchema' => $table->getSchema(), + 'displayField' => $table->getDisplayField(), + 'primaryKey' => $table->getPrimaryKey(), + ]; + } catch (Exception $e) { + // May be empty if there is no table object for the action + } + if ($scope === 'entity') { $data += [ 'primaryKeyValue' => $this->_primaryKeyValue() @@ -562,11 +570,21 @@ protected function _associations(array $whitelist = []) * * If no value can be found, NULL is returned * - * @return mixed + * @return array|string */ protected function _primaryKeyValue() { - return $this->_deriveFieldFromContext($this->_table()->getPrimaryKey()); + $fields = (array)$this->_table()->getPrimaryKey(); + $values = []; + foreach ($fields as $field) { + $values[] = $this->_deriveFieldFromContext($field); + } + + if (count($values) === 1) { + return $values[0]; + } + + return $values; } /** @@ -676,7 +694,7 @@ protected function _getFormTabGroups(array $fields = []) $groupedFields = (new Collection($groups))->unfold()->toList(); $unGroupedFields = array_diff(array_keys($fields), $groupedFields); - if ($unGroupedFields) { + if (!empty($unGroupedFields)) { $primayGroup = $action->getConfig('scaffold.form_primary_tab') ?: __d('crud', 'Primary'); $groups = [$primayGroup => $unGroupedFields] + $groups; diff --git a/src/Template/Cell/DashboardTable/display.ctp b/src/Template/Cell/DashboardTable/display.ctp new file mode 100644 index 00000000..8494fd27 --- /dev/null +++ b/src/Template/Cell/DashboardTable/display.ctp @@ -0,0 +1,22 @@ + +

+ + + + + + + + + get('actions')) : ?> + + + + + +
Html->link($link->get('title'), $link->get('url'), $link->get('options')) ?> + get('actions') as $action) : ?> + Html->link($action->get('title'), $action->get('url'), $action->get('options')) ?> + +
+ diff --git a/src/Template/Scaffold/dashboard.ctp b/src/Template/Scaffold/dashboard.ctp new file mode 100644 index 00000000..6f08d99b --- /dev/null +++ b/src/Template/Scaffold/dashboard.ctp @@ -0,0 +1,16 @@ +fetch('dashboard.before'); ?> + +
+ element('action-header') ?> +
+ get('columns')) as $columnNumber): ?> +
+ getColumnChildren($columnNumber) as $module) : ?> + + +
+ +
+
+ +fetch('dashboard.after'); ?> diff --git a/src/View/Cell/DashboardTableCell.php b/src/View/Cell/DashboardTableCell.php new file mode 100644 index 00000000..9668aaa1 --- /dev/null +++ b/src/View/Cell/DashboardTableCell.php @@ -0,0 +1,33 @@ +set('title', $title); + if (!empty($links)) { + $this->set('links', $links); + } + } +} diff --git a/src/View/CrudView.php b/src/View/CrudView.php index a340c455..7225817d 100644 --- a/src/View/CrudView.php +++ b/src/View/CrudView.php @@ -60,11 +60,9 @@ public function implementedEvents() /** * Handler for View.beforeLayout event. * - * @param \Cake\Event\Event $event The View.beforeLayout event - * @param string $layoutFileName Layout filename. * @return void */ - public function beforeLayout(Event $event, $layoutFileName) + public function beforeLayout() { $this->_loadAssets(); } diff --git a/src/View/Helper/CrudViewHelper.php b/src/View/Helper/CrudViewHelper.php index 3d0e577e..248d80a7 100644 --- a/src/View/Helper/CrudViewHelper.php +++ b/src/View/Helper/CrudViewHelper.php @@ -121,7 +121,7 @@ public function fieldValue(Entity $data, $field) * @param string $field Name of field. * @param mixed $value The value that the field should have within related data. * @param array $options Options array. - * @return string formatted value + * @return array|bool|null|int|string */ public function introspect($field, $value, array $options = []) { @@ -249,7 +249,7 @@ public function formatString($field, $value) * Format display field value. * * @param string $value Display field value. - * @param array $options Field options. + * @param array $options Options array. * @return string */ public function formatDisplayField($value, array $options) diff --git a/tests/TestCase/Dashboard/DashboardTest.php b/tests/TestCase/Dashboard/DashboardTest.php new file mode 100644 index 00000000..3e53eba0 --- /dev/null +++ b/tests/TestCase/Dashboard/DashboardTest.php @@ -0,0 +1,53 @@ +assertEquals($expected, $dashboard->get('title')); + + $expected = []; + $this->assertEquals($expected, $dashboard->get('children')); + + $expected = 1; + $this->assertEquals($expected, $dashboard->get('columns')); + + $dashboard = new Dashboard('Test Title'); + $expected = 'Test Title'; + $this->assertEquals($expected, $dashboard->get('title')); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Valid columns value must be one of [1, 2, 3, 4, 6, 12] + */ + public function testInvalidConstruct() + { + $dashboard = new Dashboard(null, 0); + } + + public function testColumnChildren() + { + $dashboard = new Dashboard; + $expected = []; + $this->assertEquals($expected, $dashboard->getColumnChildren(1)); + + $cell = new DashboardTableCell; + $return = $dashboard->addToColumn($cell); + $this->assertEquals($dashboard, $return); + $this->assertEquals([$cell], $dashboard->getColumnChildren(1)); + + $expected = []; + $this->assertEquals($expected, $dashboard->getColumnChildren(2)); + } +} diff --git a/tests/TestCase/Dashboard/Module/ActionLinkItemTest.php b/tests/TestCase/Dashboard/Module/ActionLinkItemTest.php new file mode 100644 index 00000000..95140b6f --- /dev/null +++ b/tests/TestCase/Dashboard/Module/ActionLinkItemTest.php @@ -0,0 +1,46 @@ +assertEquals($expected, $item->get('title')); + + $expected = 'https://google.com'; + $this->assertEquals($expected, $item->get('url')); + + $expected = ['target' => '_blank']; + $this->assertEquals($expected, $item->get('options')); + + $expected = []; + $this->assertEquals($expected, $item->get('actions')); + } + + public function testActions() + { + $item = new ActionLinkItem('Title', 'https://google.com'); + $expected = 0; + $this->assertCount($expected, $item->get('actions')); + + $linkItem = new LinkItem('Add', ['controller' => 'Posts', 'action' => 'add']); + $item = new ActionLinkItem('Posts', ['controller' => 'Posts'], [], [$linkItem]); + + $expected = [$linkItem]; + $this->assertEquals($expected, $item->get('actions')); + + $actions = $item->get('actions'); + $expected = ['class' => ['btn btn-default']]; + $this->assertEquals($expected, $actions[0]->get('options')); + } +} diff --git a/tests/TestCase/Dashboard/Module/LinkItemTest.php b/tests/TestCase/Dashboard/Module/LinkItemTest.php new file mode 100644 index 00000000..cbcd82b0 --- /dev/null +++ b/tests/TestCase/Dashboard/Module/LinkItemTest.php @@ -0,0 +1,71 @@ +assertEquals($expected, $item->get('title')); + + $expected = 'https://google.com'; + $this->assertEquals($expected, $item->get('url')); + + $expected = ['target' => '_blank']; + $this->assertEquals($expected, $item->get('options')); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Missing title for LinkItem action + */ + public function testInvalidTitle() + { + $item = new LinkItem('', null); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Invalid url specified for LinkItem + */ + public function testInvalidNullUrl() + { + $item = new LinkItem('Title', null); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Invalid url specified for LinkItem + */ + public function testInvalidEmptyUrl() + { + $item = new LinkItem('Title', ''); + } + + public function testOptions() + { + $item = new LinkItem('Title', ['controller' => 'Posts']); + $expected = []; + $this->assertEquals($expected, $item->get('options')); + + $item = new LinkItem('Title', '/posts'); + $expected = []; + $this->assertEquals($expected, $item->get('options')); + + $item = new LinkItem('Title', 'http://google.com'); + $expected = ['target' => '_blank']; + $this->assertEquals($expected, $item->get('options')); + + $item = new LinkItem('Title', 'https://google.com'); + $expected = ['target' => '_blank']; + $this->assertEquals($expected, $item->get('options')); + } +} diff --git a/webroot/css/local.css b/webroot/css/local.css index 61ab5220..b2b73514 100644 --- a/webroot/css/local.css +++ b/webroot/css/local.css @@ -101,3 +101,7 @@ div.actions-wrapper { .pagination-container .pagination { margin: 0; } +table tr td.actions { + white-space: nowrap; + width: 1px; +}