diff --git a/.gitignore b/.gitignore index 73061cf..ad3c5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ vendor/ *GENERATED* + +composer.lock +.php-cs-fixer.cache diff --git a/.project.php_cs b/.project.php_cs index 3782c29..26d2023 100644 --- a/.project.php_cs +++ b/.project.php_cs @@ -12,39 +12,44 @@ $finder = PhpCsFixer\Finder::create() $config = new PhpCsFixer\Config(); return $config->setRules([ - '@PSR12' => true, - - 'indentation_type' => true, - - 'array_syntax' => ['syntax' => 'short'], - 'concat_space' => ['spacing' => 'one'], - 'no_empty_statement' => false, - 'no_whitespace_in_blank_line' => true, - - 'blank_line_after_opening_tag' => true, - 'cast_spaces' => ['space' => 'none'], - 'class_attributes_separation' => [ - 'elements' => [ - 'method' => 'one', - 'property' => 'one', - 'const' => 'one', - ], + '@PSR12' => true, + + 'indentation_type' => true, + + 'array_syntax' => ['syntax' => 'short'], + 'concat_space' => ['spacing' => 'one'], + 'no_empty_statement' => false, + 'no_whitespace_in_blank_line' => true, + + 'blank_line_after_opening_tag' => true, + 'cast_spaces' => ['space' => 'none'], + 'class_attributes_separation' => [ + 'elements' => [ + 'method' => 'one', + 'property' => 'one', + 'const' => 'one', ], - 'function_typehint_space' => true, - 'include' => true, - 'lowercase_cast' => true, - 'lowercase_static_reference' => true, - 'magic_constant_casing' => true, - 'native_function_casing' => true, - 'no_alternative_syntax' => true, - 'ternary_operator_spaces' => true, - 'unary_operator_spaces' => true, - - 'multiline_whitespace_before_semicolons' => [ - 'strategy' => 'new_line_for_chained_calls', - ], - 'trailing_comma_in_multiline' => true, - ]) - ->setIndent(' ') + ], + 'type_declaration_spaces' => true, + 'include' => true, + 'lowercase_cast' => true, + 'lowercase_static_reference' => true, + 'magic_constant_casing' => true, + 'native_function_casing' => true, + 'no_alternative_syntax' => true, + 'ternary_operator_spaces' => true, + 'unary_operator_spaces' => true, + 'array_indentation' => true, + 'statement_indentation' => true, + + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'new_line_for_chained_calls', + ], + 'trailing_comma_in_multiline' => true, + 'no_multiline_whitespace_around_double_arrow' => true, + 'single_space_around_construct' => true, +]) + ->setIndent("\t") ->setFinder($finder) - ; + ->setParallelConfig(\PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) +; diff --git a/Classes/Controller/DatatableController.php b/Classes/Controller/DatatableController.php index 4653c2e..6c5987b 100644 --- a/Classes/Controller/DatatableController.php +++ b/Classes/Controller/DatatableController.php @@ -11,23 +11,70 @@ namespace LiquidLight\ModuleDataListing\Controller; -use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; -use TYPO3\CMS\Backend\View\BackendTemplateView; -use TYPO3\CMS\Extbase\Mvc\View\ViewInterface; -use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Core\Database\Query\QueryBuilder; -use TYPO3\CMS\Core\Database\ConnectionPool; -use TYPO3\CMS\Core\Database\Connection; +use Exception; +use RuntimeException; use TYPO3\CMS\Core\Utility\PathUtility; use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Extbase\Object\ObjectManager; -use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; +use TYPO3\CMS\Extbase\Mvc\View\ViewInterface; +use TYPO3\CMS\Backend\View\BackendTemplateView; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; +use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; abstract class DatatableController extends ActionController { + protected string $configurationName; + + protected string $table; + + protected array $headers; + + protected array $columnSelectOverrides; + + protected string $searchableColumns; + + protected array $joins; + protected $defaultViewObjectName = BackendTemplateView::class; + protected ConnectionPool $connectionPool; + + public function __construct(ConfigurationManagerInterface $configurationManagerInterface, ConnectionPool $connectionPool) + { + $this->connectionPool = $connectionPool; + + $setup = $configurationManagerInterface->getConfiguration( + ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT + ); + + if (!$configuration = $setup['module.']['tx_moduledatalisting.']['configuration.'][$this->configurationName . '.'] ?? false) { + throw new Exception(sprintf( + 'Missing expected SetupTS definition for module.tx_moduledatalisting.configuration.%s', + $this->configurationName, + )); + } + + $this->table = $configuration['table'] ?? $this->table; + $this->headers = $configuration['headers.'] ?? $this->headers ?? []; + $this->columnSelectOverrides = $configuration['columnSelectOverrides.'] ?? $this->columnSelectOverrides ?? []; + $this->joins = $configuration['joins.'] ?? $this->joins ?? []; + $this->searchableColumns = $configuration['searchableColumns'] ?? $this->searchableColumns ?? []; + + foreach ($configuration['additionalColumns.'] ?? [] as $table => $columns) { + foreach ($columns as $column => $label) { + if (array_key_exists($table . $column, $this->headers)) { + continue; + } + $this->headers[$table . $column] = $label; + } + } + + } + /** * Init view and load JS */ @@ -59,61 +106,35 @@ public function initializeView(ViewInterface $view): void /** * Return query builder connection by table */ - protected function getConnection(string $table): Connection + protected function getNewQueryBuilder(?string $table = null): QueryBuilder { - return GeneralUtility::makeInstance(ConnectionPool::class) - ->getConnectionForTable($table) + return $this->connectionPool + ->getConnectionForTable($table ?? $this->table) + ->createQueryBuilder() ; } - /** - * Lookup a usergroup - */ - protected function getHeaders(array $default): array + protected function prepareQuery(array $params): QueryBuilder { - static $headers = []; + $query = $this->getNewQueryBuilder(); - if (count($headers)) { - return $headers; - } - - if (!$additional = $this->getModuleSettings()['additionalColumns.']) { - return $default; - } - - $headers = $default; - - // Apply additional headers - foreach ($additional as $table => $columns) { - foreach ($columns as $column => $label) { - if (!array_key_exists($table . $column, $headers)) { - $headers[$table . $column] = $label; - } - } - } + $query->from($this->table); - return $headers; - } - - protected function getModuleSettings(): ?array - { - static $settings = []; - - if (count($settings)) { - return $settings; - } - - // Load module settings - $setup = GeneralUtility::makeInstance(ObjectManager::class) - ->get(ConfigurationManagerInterface::class) - ->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT) + $query + ->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) ; - if ($settings = $setup['module.'][$this->moduleName . '.']['settings.']) { - return $settings; - } + // Re-apply restrictions + $this + ->applyDeleteFilter($query, $this->table, $this->table) + ->applyJoins($query, $query) + ->applyFilters($query, $params) + ->applySearch($query, $params) + ; - return null; + return $query; } /** @@ -121,69 +142,32 @@ protected function getModuleSettings(): ?array */ protected function getTableData(array $params): array { - $connection = $this->getConnection($this->table); - $queryBuilder = $connection->createQueryBuilder(); - - /** - * Users without attached fees were not returned in the count due to null values - * Removing restrictions and re-apply to fe_users only solves this - * @todo TYPO3 v10+ has a cleaner way of doing this: https://docs.typo3.org/m/typo3/reference-coreapi/10.4/en-us/ApiOverview/Database/RestrictionBuilder/Index.html#limitrestrictionstotables - */ - $queryBuilder - ->getRestrictions() - ->removeAll() - ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) - ; - - // Re-apply restrictions - $query = $queryBuilder - ->select(...array_keys($this->getHeaders($this->headers))) - ->from($this->table) - ->where( - $queryBuilder->expr()->eq( - $this->table . '.deleted', - 0 - ), - ) - ; - - // Apply joins - $query = $this->applyJoins($queryBuilder, $query); - - // Apply filters - if ($params['filters'] ?? false) { - $query = $this->applyFilters($queryBuilder, $query, $params); + $query = $this->prepareQuery($params); + + $selectFields = array_keys($this->headers); + foreach ($selectFields as $field) { + if (isset($this->columnSelectOverrides[$field])) { + $query->addSelectLiteral(sprintf( + '%s as `%s`', + $this->columnSelectOverrides[$field], + $field, + )); + } else { + $query->addSelect($field); + } } // Page if ($params['start'] ?? false) { - $query = $query->setFirstResult($params['start']); + $query->setFirstResult($params['start']); } // Order - $order = $params['order'][0]; - - if (isset($order['column']) && $order['dir']) { - $headerKeys = array_keys($this->getHeaders($this->headers)); - - // Get column to order by and use alias if present - if (strpos($headerKeys[$order['column']], ' as ') !== false) { - $column = explode(' as ', $headerKeys[$order['column']])[1]; - } else { - $column = $headerKeys[$order['column']]; - } - - $query = $query->orderBy($column, $order['dir']); - } else { - $query = $query->orderBy($this->table . '.uid', 'DESC'); - } - - // Apply search - $query = $this->applySearch($queryBuilder, $query, $params); + $this->applyOrder($query, $params); // Page size if ($params['length'] > 0) { - $query = $query + $query ->setMaxResults($params['length']) ; } @@ -201,40 +185,9 @@ protected function getTableData(array $params): array */ protected function getCount(array $params): int { - $connection = $this->getConnection($this->table); - $queryBuilder = $connection->createQueryBuilder(); - - /** - * Users without attached fees were not returned in the count due to null values - * Removing restrictions and re-apply to table only solves this - * @todo TYPO3 v10+ has a cleaner way of doing this: https://docs.typo3.org/m/typo3/reference-coreapi/10.4/en-us/ApiOverview/Database/RestrictionBuilder/Index.html#limitrestrictionstotables - */ - $queryBuilder - ->getRestrictions() - ->removeAll() - ; + $query = $this->prepareQuery($params); - $query = $queryBuilder - ->count($this->table . '.uid') - ->from($this->table) - ->where( - $queryBuilder->expr()->eq( - $this->table . '.deleted', - 0 - ), - ) - ; - - // Apply joins - $query = $this->applyJoins($queryBuilder, $query, $this->table); - - // Apply filters - if ($params['filters']) { - $query = $this->applyFilters($queryBuilder, $query, $params); - } - - // Apply search - $query = $this->applySearch($queryBuilder, $query, $params); + $query->count($this->table . '.uid'); $count = $query ->executeQuery() @@ -247,150 +200,95 @@ protected function getCount(array $params): int /** * Apply search to query */ - protected function applySearch(QueryBuilder $queryBuilder, QueryBuilder $query, array $params): QueryBuilder + protected function applySearch(QueryBuilder $query, array $params): self { if ($params['search']['value']) { - $columnStr = $this->getModuleSettings()['searchableColumns']; - $searchableColumns = GeneralUtility::trimExplode(',', $columnStr); + $searchableColumns = GeneralUtility::trimExplode(',', $this->searchableColumns); - $searchQuery = $queryBuilder->expr()->orX(); + $searchQuery = $query->expr()->orX(); foreach ($searchableColumns as $field) { - $searchQuery->add( - $queryBuilder->expr()->like( - $field, - $queryBuilder->createNamedParameter('%' . $queryBuilder->escapeLikeWildcards($params['search']['value']) . '%') - ) - ); + $param = $query->createNamedParameter('%' . $query->escapeLikeWildcards($params['search']['value']) . '%'); + + $expression = isset($this->columnSelectOverrides[$field]) ? + // If we have a column override we need to filter on that + // override and not the field (alias) itself + sprintf('%s LIKE %s', $this->columnSelectOverrides[$field], $param) : + // Otherwise we can filter directly off the field itself + $query->expr()->like($field, $param); + + $searchQuery->add($expression); } - $query = $query->andWhere($searchQuery); + $query->andWhere($searchQuery); } - return $query; + return $this; } /** * Apply joins to query */ - protected function applyJoins(QueryBuilder $queryBuilder, QueryBuilder $query): QueryBuilder + protected function applyJoins(QueryBuilder $query): self { - $joins = $this->getModuleSettings()['joins.']; - if (!$joins) { - return $query; - } + $joins = $this->joins ?? []; + + $typesAllowed = ['join', 'leftJoin', 'rightJoin', 'innerJoin']; // Apply joins from settings - foreach ($joins as $join) { - // Should we be using an alias for join? - if (array_key_exists('as', $join)) { - $joinTable = $join['as']; - } else { - $joinTable = $join['table']; + foreach ($joins as $alias => $join) { + + foreach (['table', 'type', 'on'] as $property) { + if (!isset($join[$property])) { + throw new RuntimeException(sprintf( + 'Expected join definition %s to contain %s', + $alias, + $property + )); + } } - switch ($type = $join['type']) { - case 'leftJoin': - case 'rightJoin': - $query = $query - ->$type( - $this->table, - $join['table'], - $joinTable, - $queryBuilder->expr()->eq($joinTable . '.' . $join['localIdentifier'], $queryBuilder->quoteIdentifier($this->table . '.' . $join['foreignIdentifier'])) - ) - ; - break; - case 'innerJoin': - // Apply many-to-many joins - if (substr($join['table'], -3) === '_mm') { - // Should we be using an alias for secondary? - if (array_key_exists('secondaryTableAs', $join)) { - $secondaryJoinTable = $join['secondaryTableAs']; - } else { - $secondaryJoinTable = $join['secondaryTable']; - } - - // Do we need to apply an additional where clause to join table? - if (array_key_exists('secondaryWhereField', $join) && array_key_exists('secondaryWhereValue', $join)) { - $query = $query - ->innerJoin( - $this->table, - $join['table'], - $joinTable, - $queryBuilder->expr()->eq($joinTable . '.' . $join['localIdentifier'], $queryBuilder->quoteIdentifier($this->table . '.uid')) - ) - ->innerJoin( - $joinTable, - $join['secondaryTable'], - $secondaryJoinTable, - $queryBuilder->expr()->andX( - $queryBuilder->expr()->eq($secondaryJoinTable . '.' . $join['secondaryLocalIdentifier'], $queryBuilder->quoteIdentifier($joinTable . '.' . $join['secondaryForeignIdentifier'])), - $queryBuilder->expr()->eq($secondaryJoinTable . '.' . $join['secondaryWhereField'], $queryBuilder->createNamedParameter($join['secondaryWhereValue'])) - ) - ) - ; - break; - } - - // Apply mm join without additional where clause - $query = $query - ->innerJoin( - $this->table, - $join['table'], - $joinTable, - $queryBuilder->expr()->eq($joinTable . '.' . $join['localIdentifier'], $queryBuilder->quoteIdentifier($this->table . '.uid')) - ) - ->innerJoin( - $joinTable, - $join['secondaryTable'], - $secondaryJoinTable, - $queryBuilder->expr()->eq($secondaryJoinTable . '.' . $join['secondaryLocalIdentifier'], $queryBuilder->quoteIdentifier($join['table'] . '.' . $join['secondaryForeignIdentifier'])) - ) - ; - break; - } - - // Apply standard join - $query = $query - ->innerJoin( - $this->table, - $join['table'], - $join['table'], - $queryBuilder->expr()->eq($join['table'] . '.' . $join['localIdentifier'], $queryBuilder->quoteIdentifier($this->table . '.' . $join['foreignIdentifier'])) - ) - ; - break; + $alias = substr($alias, 0, -1); // Remove the trailing . from TS + $table = $join['table']; + $type = $join['type']; + $on = $join['on']; + + if (!in_array($type, $typesAllowed, true)) { + throw new RuntimeException(sprintf( + 'Unexpected join definition %s has type of %s', + $alias, + $type, + )); } + + // Perform the join + $query->$type($this->table, $table, $alias, $on); + + $this->applyDeleteFilter($query, $table, $alias); } - foreach ($joins as $join) { - // Don't check the mm tables - if (substr($join['table'], -3) === '_mm') { - continue; - } + return $this; + } - $query = $query - ->where( - $queryBuilder->expr()->orX( - $queryBuilder->expr()->eq( - $joinTable . '.deleted', - 0 - ), - $queryBuilder->expr()->isNull( - $joinTable . '.deleted' - ), - ), - ) - ; + protected function applyDeleteFilter(QueryBuilder $query, string $table, string $alias, bool $restrict = true): self + { + // Exclude anything that is deleted + if ($deleteFiled = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? false) { + $deleteFiled = $alias . '.' . $deleteFiled; + $query->where( + $query->expr()->orX( + $query->expr()->eq($deleteFiled, 0), + $query->expr()->isNull($deleteFiled), + ), + ); } - return $query; + return $this; } /** * Apply filters to query */ - protected function applyFilters(QueryBuilder $queryBuilder, QueryBuilder $query, array $params): QueryBuilder + protected function applyFilters(QueryBuilder $query, array $params): self { foreach ($params['filters'] ?? [] as $field => $filter) { // If filtering by usergroup @@ -399,30 +297,30 @@ protected function applyFilters(QueryBuilder $queryBuilder, QueryBuilder $query, if (is_array($filter) && (count($filter) > 1)) { foreach ($filter as $value) { if ($field === 'usergroup') { - $query = $query + $query ->andWhere( - $queryBuilder->expr()->orX( - $queryBuilder->expr()->like( + $query->expr()->orX( + $query->expr()->like( $field, - $queryBuilder->createNamedParameter($queryBuilder->escapeLikeWildcards($value) . ',%') + $query->createNamedParameter($query->escapeLikeWildcards($value) . ',%') ), - $queryBuilder->expr()->like( + $query->expr()->like( $field, - $queryBuilder->createNamedParameter('%,' . $queryBuilder->escapeLikeWildcards($value) . ',%') + $query->createNamedParameter('%,' . $query->escapeLikeWildcards($value) . ',%') ), - $queryBuilder->expr()->like( + $query->expr()->like( $field, - $queryBuilder->createNamedParameter('%,' . $queryBuilder->escapeLikeWildcards($value)) + $query->createNamedParameter('%,' . $query->escapeLikeWildcards($value)) ) ), ) ; } else { - $query = $query + $query ->andWhere( - $queryBuilder->expr()->eq( + $query->expr()->eq( $field, - $queryBuilder->createNamedParameter( + $query->createNamedParameter( $value ) ) @@ -431,11 +329,11 @@ protected function applyFilters(QueryBuilder $queryBuilder, QueryBuilder $query, } } } else { - $query = $query + $query ->andWhere( - $queryBuilder->expr()->eq( + $query->expr()->eq( $field, - $queryBuilder->createNamedParameter( + $query->createNamedParameter( is_array($filter) ? $filter[0] : $filter ) ) @@ -443,6 +341,56 @@ protected function applyFilters(QueryBuilder $queryBuilder, QueryBuilder $query, ; } } - return $query; + return $this; + } + + /** + * + */ + public function applyOrder(QueryBuilder $query, array $params) + { + + $orders = $params['order'] ?? []; + $columnCount = count($this->headers); + + foreach ($orders as $order) { + + // Prepare the directions + $dir = strtoupper($order['dir'] ?? 'ASC'); + + if (!in_array($dir, ['ASC', 'DESC'], true)) { + continue; + } + + // Prepare the column index + $column = $order['column'] ?? false; + + if (!is_numeric($column)) { + continue; + } + + $column = (int)$column; + + if (!is_int($column)) { + continue; + } elseif (0 > $column || $column >= $columnCount) { + continue; + } + + // Note: SQL order by column index is 1-base + $query->getConcreteQueryBuilder()->addOrderBy($column + 1, $dir); + } + + return $this; + } + + /** + * Default action: index + */ + public function indexAction(): void + { + $this->view->assignMultiple([ + 'headers' => array_values($this->headers), + ]); } } diff --git a/Classes/Controller/FeUsersController.php b/Classes/Controller/FeUsersController.php index 3a2b28d..1f4ae6d 100644 --- a/Classes/Controller/FeUsersController.php +++ b/Classes/Controller/FeUsersController.php @@ -24,7 +24,6 @@ class FeUsersController extends DatatableController /** * Table * - * @var array * @access protected */ protected string $table = 'fe_users'; @@ -63,7 +62,7 @@ class FeUsersController extends DatatableController * * @var string */ - protected string $moduleName = 'tx_moduledatalisting'; + protected string $configurationName = 'fe_users'; /** * Init view @@ -130,6 +129,10 @@ function ($usergroupUid): ?string { // Format unix timestamp fields foreach ($this->dateColumns as $dateColumn) { + if (!isset($row[$dateColumn])) { + continue; + } + $row[$dateColumn] = $row[$dateColumn] ? date('d/m/Y H:i:s', $row[$dateColumn]) : 'N/A'; } @@ -154,8 +157,8 @@ function ($usergroupUid): ?string { */ public function indexAction(): void { + parent::indexAction(); $this->view->assignMultiple([ - 'headers' => array_values(parent::getHeaders($this->headers)), 'groups' => $this->getUsergroups(), ]); } @@ -165,10 +168,7 @@ public function indexAction(): void */ private function getUsergroups(): array { - $connection = $this->getConnection('fe_groups'); - $queryBuilder = $connection->createQueryBuilder(); - - $usergroups = $queryBuilder + $usergroups = $this->getNewQueryBuilder('fe_groups') ->select('title', 'uid') ->from('fe_groups') ->execute() @@ -189,8 +189,7 @@ private function getUsergroupNameByUid(int $usergroupUid): ?string return $cache[$usergroupUid]; } - $connection = $this->getConnection('fe_groups'); - $queryBuilder = $connection->createQueryBuilder(); + $queryBuilder = $this->getNewQueryBuilder(); $usergroup = $queryBuilder ->select('title') diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml new file mode 100644 index 0000000..9ba001a --- /dev/null +++ b/Configuration/Services.yaml @@ -0,0 +1,10 @@ +services: + + _defaults: + autowire: true + autoconfigure: true + public: false + + LiquidLight\ModuleDataListing\: + resource: '../Classes/*' + exclude: '../Classes/Domain/Model/*' diff --git a/Configuration/TCA/Overrides/sys_template.php b/Configuration/TCA/Overrides/sys_template.php new file mode 100644 index 0000000..35b804b --- /dev/null +++ b/Configuration/TCA/Overrides/sys_template.php @@ -0,0 +1,11 @@ + The UID of this row + my_table\.title => The title + } } ``` -### Additional columns +> [!Note] +> The dots (`.`) must be escaped as the full value is needed to perform the SQL select, i.e. `SELECT my_table.uid, my_table.title FROM ...". -It is possible to pull in additional columns from the fe_users table as well as columns from any join tables by making use of the `module.tx_moduledatalisting.settings.additionalColumns` object where: -1. `table` is the name of the table you wish to pull the additional column from (this can be fe_users or any joined tables) -2. `column` is the name of the column you wish to pull in -3. `label` is the label that is used in the datatable header +### Joins -**`setup`** +It is possible to add additional tables to join by making use of the `joins` object where: + +* The key is an alias for the table to use (this can, for the most part, be the name of the table being joined) +* Values defined as follows; + * `type` one of `join`, `leftJoin`, `rightJoin` or `innerJoin` + * `table` the table to join + * `on` the `ON` part of the join (remember to use alias names if they differ from the table's name) + +Example joins; ``` -module.tx_moduledatalisting { - settings { - additionalColumns { - table { - column = label - } - } - } +module.tx_moduledatalisting.configuration.my_configuration { + table = tx_my_table + joins { + tx_my_alias { + table = tx_my_second_table + type = leftJoin + on = tx_my_table.this_id = tx_my_alias.that_id + } + } } ``` ### Searchable columns -The default searchable columns are specified above however it is possible to add and/or remove columns from this list by making use of the `module.tx_moduledatalisting.settings.searchableColumns` object where: -1. `table` is the name of the table you wish to pull the searchable column from (this can be fe_users or any joined tables) -2. `column` is the name of the column you wish to make searchable - -**`setup`** +Datatable listings provide a search box which can be configured to search only a specific set of field. Which fields can be searched is controlled by the `searchableColumns` property, which contains a comma delimited list of fields. You can mutate this value using typoscript's [value modification functions](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Configuration/TypoScript/Syntax/Operators/Index.html#typoscript-syntax-syntax-value-modification). +Example searchableColumns; ``` -module.tx_moduledatalisting { - settings { - searchableColumns := addToList(table.column1,table.column2) - searchableColumns := removeFromList(table.column3) - } -} -``` - -It is also possible to completely reset the searchable columns: -``` -module.tx_moduledatalisting { - settings { - searchableColumns = table.column1,table.column2 - } +module.tx_moduledatalisting.configuration.my_configuration { + # Explicitly set the columns + searchableColumns = table.column1,table.column2 + # Append additional columns + searchableColumns = addToList(table.column3,table.column4) + # Remove columns + searchableColumns = addToList(table.column2,table.column3) } ``` @@ -130,3 +108,94 @@ If you have a local JavaScript file for you custom DataTables, you can remove th + bottom2: 'buttons', + }, ``` +## Upgrading from v1 to v2 + +There number of critical differences between v1 and v2. + +### Overview of Changes + +#### Classes `LiquidLight\ModuleDataListing\Controller\DatatableController` + +* Changed property `$table` to `protected string $table` +* Changed property `$moduleName` to `protected string $configurationName` +* Changed property `$headers` to `protected array $headers` +* New property `protected array $columnSelectOverrides` maps fields to complex SQL; useful for handling computed values. +* Method `protected function getConnection(string $table): Connection` changed to `protected function getConnection(?string $table = null): Connection`. Calling without an argument uses `$this->table`. +* Method `protected function getHeaders(array $default): array` changed to `protected function getHeaders(): array`. Uses `$this->headers` internally, which was otherwise always passed-in. +* Method `indexAction(): void` implemented as per the old sub-class instructions. As a result you no longer need to define `indexAction()` to have default behaviour, you can alternatively call `parent::indexAction()` to expand on the default behaviour. + +> [!Note] +> Previously the `$table`, `$moduleName` and `$headers` properties where not _explicitly_ defined, but where expected to be defined in sub-classes. They are now explicitly defined in this class. If you have previously extended `DatatableController` you will likely need to change your definitions to match. + +#### Class`LiquidLight\ModuleDataListing\Controller\FeUsersController` + +* Property `protected $table` changed to `protected string $table` +* Property `protected $moduleName` changed to `protected string $configurationName`. The value of this property has also been changed to "*fe_users*". + +#### Setup TS + +Previously the configuration of a datatable listing was stored in `module.[ext].settings`; essentially this would limit how many listings could be setup per-extension and stifled extensibility and clutter a key reserved for module-level settings. The values previously defined there (`joins`, `additionalColumns`, etc) have been moved to `module.tx_moduledatalisting.configuration.[configuration_name]`. This coincides with a change to `LiquidLight\ModuleDataListing\Controller\DatatableController` which has had its `$moduleName` property changes to `$configurationName`, which is used to determine which configuration to use from the ones defined in TS. + +The following is now the recommended SetupTS when defining your own listing. + +``` +module.tx_moduledatalisting { + configuration{ + [configuration_name] < .default + [configuration_name] { + ... + } + } +} + +module.[tx_myextension] { + view < module.tx_moduledatalisting.view + view { + templateRootPaths.1725047881 = EXT:[my_extension]/Resources/Private/Backend/Templates/ + layoutRootPaths.1725047881 = EXT:[my_extension]/Resources/Private/Backend/Layouts/ + partialRootPaths.1725047881 = EXT:[my_extension]/Resources/Private/Backend/Partials/ + } +} +``` + +> [!Note] +> The `FeUsersController` class is no longer tied to the "default" configuration, but rather its own. located in `module.tx_moduledatalisting.configuration.fe_users` it still inherits from the `module.tx_moduledatalisting.configuration.default`, as is recommended in the block above. + +Joins are defined and processed differently in version 2. The previous numerical index for joins has been replaced with a string key, that represents the alias of the joined table. The previous `localIdentifier` and `foreignIdentifier` keys have been simplified into a single `on` key, which defined the entire on statement. + +``` +module.tx_moduledatalisting { + configuration { + fe_user_groups < .fe_users + fe_user_groups { + joins { + fe_groups { + type = join + table = fe_groups + on = FIND_IN_SET(fe_groups.uid, fe_users.usergroup) + } + } + } + } +} +``` + +#### Extensibility changes + +In version 1 there was a number of esoteric configurations when extending `DatatableController`: the `$table` class property set the table to use, while joins where defined in typoscript; SQL select and HTML column headers were computed from `$headers` and typoscript; Fields to search where entirely handled in TS. To make these options more consistent they can now all be set in _either_ typoscript or on an extending class, and have priority respectively. + +The following in a breakdown of the class properties and their respective typoscript. Note that class properties are all defined in `DatatableListing` while the typoscript keys are relative to `module.tx_moduledatalisting.configuration.[configuration_name]`, where *`configuration_name`* is derived from a classes `$configurationName` property. This is the *only* place you can set the *`configuration_name`* for a class. + +| Class Property | Typoscript Key | Description | +|--------------------------------|-------------------------------------------------|-------------| +| `string $table` | `table = ` | The SQL table name. Should be present in the TCA either as a table or as part of a column's relationship. | +| `string $searchableColumns` | `searchableColumns = [List of searchable columns]`| Comma delimited list of table columns to perform searches using | +| `array $headers` | `headers.[table\.column] = [Column Header]` | Key-value pair mapping a table's column to it's display header; the same as where the key is a non-ambiguious `$header` property used to require. You will need to escape dots (`.`), i.e. `fe_users\.username = Username`. | +| `array $columnSelectOverrides` | `columnSelectOverrides.[table\.column] = [SQL]` | Key-value pair mapping a table's column to a complex SQL expression, i.e. where the key is a non-ambiguious `fe_users\.last_name = CONCAT(fe_users.last_name, ', ', fe_users.firstname)`. When set the key `headers` and `columnSelectOverrides` values become sorting hints, in the previous example sorting would be by `last_name` | +| `array $joins` | `joins.[alias] { ... }` | As explained above in the "Setup TS" section | + +> [!Note] +> When joining tables you should use the alias in place of the table name for the purposes of `searchableColumns`, `headers`, and `columnSelectOverrides`. + +> [!Note] +> When performing a search on any fields that are defined in `$columnSelectOverrides`, the WHERE condition will include the overridden SQL. This prevents an alias from being used in the case of a complex SQL expression. diff --git a/Resources/Public/JavaScript/ModuleDataListing.js b/Resources/Public/JavaScript/ModuleDataListing.js new file mode 100644 index 0000000..6270645 --- /dev/null +++ b/Resources/Public/JavaScript/ModuleDataListing.js @@ -0,0 +1,281 @@ +/* global define, TYPO3 */ +/* eslint no-unused-vars: "off" */ + +define([ + 'jquery', + 'datatables.net', + 'datatables.net-buttons', + 'datatables.net-buttons-print', + 'datatables.net-buttons-html5' +], function () { + var ModuleDataListing = { + settings: { + // The URL called when updating the datatable + ajaxUrl: '', + // If set, localStorage is used for rembering filters + storageKey: null, + // jQuery selector of your datatable + tableSelector: '#mdl-datatable', + // jQuery selector of button for filtering + filterButtonSelector: '.mdl-filter-search', + // jQuery selector of button for clearing filters + clearButtonSelector: '.mdl-filter-clear', + + // DataTable options + dataTable: { + processing: true, + serverSide: true, + order: [[0, 'desc']], + layout: { + bottom2: 'buttons', + }, + buttons: [ + { + extend: 'csv', + text: 'Download as CSV', + className: 'btn btn-primary' + }, + { + extend: 'copy', + text: 'Copy to clipboard', + className: 'btn btn-default' + } + ], + pagingType: 'full_numbers', + language: { + emptyTable: 'No data available in table' + }, + lengthMenu: [ + [ + 10, + 25, + 50, + 100, + -1 + ], + [ + 10, + 25, + 50, + 100, + 'All' + ] + ], + classes: { + sLength: 'form-group', + sLengthSelect: 'form-control input-sm', + sFilter: 'form-group', + sFilterInput: 'form-control input-sm' + } + } + }, + + // Setting config - merges options + config: function(options = {}) { + ModuleDataListing.settings = ModuleDataListing.utility.mergeObjects(ModuleDataListing.settings, options); + } + }; + + /** + * Local Storage + * + * Only works if `storageKey` is set. + * + * Everything is stored in a single JSON object keyed by + * ModuleDataListing.${settings.storageKey} + */ + ModuleDataListing.storage = { + + /** + * Get an item from localStorage + */ + get: function(key) { + if (!ModuleDataListing.settings.storageKey) { + return null; + } + + let storage = localStorage.getItem( + 'ModuleDataListing.' + ModuleDataListing.settings.storageKey + ); + storage = storage ? JSON.parse(storage) : {}; + + return storage.hasOwnProperty(key) ? storage[key] : null; + }, + + /** + * Add an item to localStorage + */ + set: function(key, data) { + if (!ModuleDataListing.settings.storageKey) { + return; + } + + // Load existing storage + let storage = localStorage.getItem( + 'ModuleDataListing.' + ModuleDataListing.settings.storageKey + ); + + storage = storage ? JSON.parse(storage) : {}; + + // Add our new data + storage[key] = data; + + // Store it + localStorage.setItem( + 'ModuleDataListing.' + ModuleDataListing.settings.storageKey, + JSON.stringify(storage) + ); + }, + + /** + * Empty the localsotrage for this storage key + */ + clear: function() { + localStorage.removeItem( + 'ModuleDataListing.' + ModuleDataListing.settings.storageKey + ) + } + } + + /** + * DataTable functions + * + * Uses https://datatables.net/ + */ + ModuleDataListing.dataTable = { + init: function(filters = null) { + + // See if we can get filters from storage + if(!filters) { + filters = ModuleDataListing.storage.get('filters'); + } + + // Add the dynamic options + const settings = { + search: { + search: ModuleDataListing.storage.get('keywords') + }, + + ajax: { + url: ModuleDataListing.settings.ajaxUrl, + data: { + filters: filters + } + }, + } + + // Initialise the datatable + const table = $(ModuleDataListing.settings.tableSelector) + .DataTable( + ModuleDataListing.utility.mergeObjects(ModuleDataListing.settings.dataTable, settings) + ); + + // Add listener for search box to update storage + table.on('search.dt', function () { + ModuleDataListing.storage.set('keywords', table.search()); + }); + + return table; + }, + + // Remove datatable config + destroy: function() { + $(ModuleDataListing.settings.tableSelector).DataTable().destroy(); + }, + + restart: function(filters = null) { + ModuleDataListing.dataTable.destroy(); + ModuleDataListing.dataTable.init(filters); + } + }; + + /** + * Filter + */ + ModuleDataListing.filters = { + // Filter setup + init: function () { + + // Set any filters from storage + ModuleDataListing.filters.set( + ModuleDataListing.storage.get('filters') + ); + + // Add click listener to search button + $(ModuleDataListing.settings.filterButtonSelector).click(function () { + let filters = ModuleDataListing.filters.get(); + ModuleDataListing.storage.set('filters', filters); + ModuleDataListing.dataTable.restart(filters); + }); + + // Remove all filters when cleared + $(ModuleDataListing.settings.clearButtonSelector).click(function () { + ModuleDataListing.storage.clear(); + ModuleDataListing.filters.clear(); + ModuleDataListing.dataTable.restart({}); + }); + }, + + /** + * Get an object & array of checked items + */ + get: function () { + let filters = {}; + + $('div[data-mdl-filter]').each(function () { + let self = $(this); + let filterData = []; + + self.find('input:checked').each(function () { + filterData.push($(this).val()); + }); + + filters[self.data('mdl-filter')] = filterData; + }); + + return filters; + }, + + /** + * Check any checkbox which were in the filters object + */ + set: function (filters = {}) { + for (let filter in filters) { + let container = $(`div[data-mdl-filter="${filter}"]`); + for (let value of filters[filter]) { + container.find(`input[value="${value}"]`).prop('checked', true); + } + } + }, + + /** + * Uncheck all checkboxes + */ + clear: function () { + $('div[data-mdl-filter] input:checked').each(function() { + $(this).prop('checked', false); + }) + }, + } + + ModuleDataListing.utility = { + // Filter setup + mergeObjects: function (obj1, obj2) { + const result = JSON.parse(JSON.stringify(obj1)); + + for (let key in obj2) { + if (obj2[key] instanceof Array && obj1[key] instanceof Array) { + result[key] = obj2[key]; + } else if (obj2[key] instanceof Object && obj1[key] instanceof Object) { + result[key] = ModuleDataListing.utility.mergeObjects(obj1[key], obj2[key]); + } else { + result[key] = obj2[key]; + } + } + + return result; + } + } + + return ModuleDataListing; +}); diff --git a/UPCOMING.md b/UPCOMING.md index ed5496b..d4f3bfd 100644 --- a/UPCOMING.md +++ b/UPCOMING.md @@ -1,14 +1,7 @@ -# Minor +# Major #### Feature -- Upgrade DataTables to 2.x - -#### Fix - -- Load the ext path correctly -- Check if user ID is in the group before processing - -#### Refactor - -- Lots of code tidy-up, refactoring and indenting correctly +- BREAKING CHANGE: Restructure SetupTS related to `module.tx_moduledatalisting` (see README.md) +- BREAKING CHANGE: `DatatableController` has various changes to properties and methods (see README.md) +- Enabled dependency injection auto-wiring in Services.yaml diff --git a/composer.json b/composer.json index 013b0bf..a972183 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,12 @@ "source": "https://github.com/liquidlight/module_data_listing" }, "require": { - "typo3/cms-core": "^11.5" + "typo3/cms-backend": "^11.5", + "typo3/cms-core": "^11.5", + "typo3/cms-extbase": "^11.5" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.64" }, "autoload": { "psr-4": { @@ -49,6 +54,12 @@ "rm -rf .github", "rm .gitignore", "rm .project.php_cs" + ], + "lint": [ + "@lint:php" + ], + "lint:php": [ + "php-cs-fixer fix . --config=.project.php_cs" ] } } diff --git a/ext_typoscript_setup.typoscript b/ext_typoscript_setup.typoscript deleted file mode 100644 index 8467c48..0000000 --- a/ext_typoscript_setup.typoscript +++ /dev/null @@ -1,5 +0,0 @@ -module.tx_moduledatalisting { - settings { - searchableColumns := addToList(fe_users.first_name,fe_users.last_name,fe_users.username,fe_users.email,fe_users.uid) - } -}