diff --git a/config/bootstrap.php b/config/bootstrap.php index 6da31ba..e241b1c 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -194,6 +194,11 @@ ServerRequest::addDetector('tablet', function ($request) { return $detector->isTablet(); }); +ServerRequest::addDetector('csv', [ + 'accept' => ['text/csv',], + 'param' => '_ext', + 'value' => 'csv', +]); /* * You can set whether the ORM uses immutable or mutable Time types. diff --git a/config/routes.php b/config/routes.php index 32712cd..c4f9c10 100644 --- a/config/routes.php +++ b/config/routes.php @@ -45,7 +45,7 @@ use Cake\Routing\RouteBuilder; /** @var \Cake\Routing\RouteBuilder $routes */ $routes->setRouteClass(DashedRoute::class); $routes->scope('/', function (RouteBuilder $builder) { - $builder->setExtensions(['json']); + $builder->setExtensions(['json', 'csv']); // Register scoped middleware for in scopes. $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([ 'httponly' => true, diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 5a69186..8774d32 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -19,7 +19,7 @@ use App\Utility\UI\IndexSetting; class CRUDComponent extends Component { - public $components = ['RestResponse']; + public $components = ['RestResponse', 'APIRearrange']; public function initialize(array $config): void { @@ -107,7 +107,7 @@ class CRUDComponent extends Component } $data = $this->Controller->paginate($query, $this->Controller->paginate ?? []); $totalCount = $this->Controller->getRequest()->getAttribute('paging')[$this->TableAlias]['count']; - if ($this->Controller->ParamHandler->isRest()) { + if ($this->Controller->ParamHandler->isRest() || $this->request->is('csv')) { if (isset($options['hidden'])) { $data->each(function($value, $key) use ($options) { $hidden = is_array($options['hidden']) ? $options['hidden'] : [$options['hidden']]; @@ -138,9 +138,21 @@ class CRUDComponent extends Component return $this->attachMetaTemplatesIfNeeded($value, $metaTemplates); }); } - $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json', false, false, false, [ - 'X-Total-Count' => $totalCount, - ]); + if ($this->request->is('csv')) { + require_once(ROOT . '/src/Lib/Tools/CsvConverter.php'); + $rearranged = $this->APIRearrange->rearrangeForAPI($data, ['smartFlattenMetafields' => true]); + $rearranged = $rearranged->map(function($e) { + return $e->toArray(); + })->toList(); + $data = \App\Lib\Tools\CsvConverter::flattenJSON($rearranged, []); + $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'csv', false, false, false, [ + 'X-Total-Count' => $totalCount, + ]); + } else { + $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json', false, false, false, [ + 'X-Total-Count' => $totalCount, + ]); + } } else { $this->Controller->setResponse($this->Controller->getResponse()->withHeader('X-Total-Count', $totalCount)); if (isset($options['afterFind'])) { @@ -281,7 +293,7 @@ class CRUDComponent extends Component */ public function getResponsePayload() { - if ($this->Controller->ParamHandler->isRest()) { + if ($this->Controller->ParamHandler->isRest() || $this->request->is('csv')) { return $this->Controller->restResponsePayload; } else if ($this->Controller->ParamHandler->isAjax() && $this->request->is(['post', 'put'])) { return $this->Controller->ajaxResponsePayload; @@ -1305,6 +1317,7 @@ class CRUDComponent extends Component } $query = $this->setMetaFieldFilters($query, $filteringMetaFields); } + $activeFilters['_here'] = $this->request->getRequestTarget(); $this->Controller->set('activeFilters', $activeFilters); return $query; diff --git a/src/Controller/Component/RestResponseComponent.php b/src/Controller/Component/RestResponseComponent.php index 19a522b..10f36cd 100644 --- a/src/Controller/Component/RestResponseComponent.php +++ b/src/Controller/Component/RestResponseComponent.php @@ -4,6 +4,7 @@ namespace App\Controller\Component; use Cake\Controller\Component; use Cake\Core\Configure; +use Cake\Utility\Hash; use Cake\Utility\Inflector; class RestResponseComponent extends Component diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 6ca72db..6fdbcdc 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -11,7 +11,7 @@ use Cake\Http\Exception\NotFoundException; class UsersController extends AppController { - public $filterFields = ['Individuals.uuid', 'username', 'Individuals.email', 'Individuals.first_name', 'Individuals.last_name', 'Organisations.name', 'Organisation.nationality']; + public $filterFields = ['Individuals.uuid', 'username', 'Individuals.email', 'Individuals.first_name', 'Individuals.last_name', 'Organisations.name', 'Organisations.nationality']; public $quickFilterFields = ['Individuals.uuid', ['username' => true], ['Individuals.first_name' => true], ['Individuals.last_name' => true], 'Individuals.email']; public $containFields = ['Individuals', 'Roles', 'UserSettings', 'Organisations', 'OrgGroups']; @@ -63,6 +63,11 @@ class UsersController extends AppController $this->set('validOrgIDsFOrEdition', $validOrgIDsFOrEdition); } + public function filtering() + { + $this->CRUD->filtering(); + } + public function add() { $currentUser = $this->ACL->getUser(); diff --git a/src/Lib/Tools/CsvConverter.php b/src/Lib/Tools/CsvConverter.php new file mode 100644 index 0000000..8ed8ac1 --- /dev/null +++ b/src/Lib/Tools/CsvConverter.php @@ -0,0 +1,84 @@ + $item) { + $csv .= self::getRow($headers, $item); + } + + return $csv; + } + + private static function collectHeaders(array $items): array + { + $allHeaders = []; + foreach ($items as $item) { + $headers = Hash::flatten($item); + foreach ($headers as $head => $value) { + if (str_starts_with($head, '_')) { + continue; + } + if (is_array($value) && empty($value)) { + continue; + } + $allHeaders[$head] = 1; + } + } + return array_keys($allHeaders); + } + + private static function getRow(array $headers, array $item): string + { + $tmp = []; + foreach ($headers as $header) { + $value = Hash::get($item, $header); + if (!isset($value)) { + $value = ''; + } + if (is_bool($value)) { + $value = !empty($value) ? '1' : '0'; + } + $tmp[] = '"' . $value . '"'; + } + $row = implode(',', $tmp) . PHP_EOL; + return $row; + } + + private static function quoteArray(array $arr): array + { + return array_map(function($item) { + return '"' . $item . '"'; + }, $arr); + } + + private static function array_is_list(array $arr): bool + { + if ($arr === []) { + return true; + } + return array_keys($arr) === range(0, count($arr) - 1); + } +} diff --git a/src/Model/Entity/AppModel.php b/src/Model/Entity/AppModel.php index 2d4a553..1cf31bc 100644 --- a/src/Model/Entity/AppModel.php +++ b/src/Model/Entity/AppModel.php @@ -78,6 +78,15 @@ class AppModel extends Entity $this->meta_fields[$i]['template_namespace'] = $templates[$templateDirectoryId]['namespace']; } } + if (!empty($options['smartFlattenMetafields'])) { + $smartFlatten = []; + foreach ($this->meta_fields as $metafield) { + $key = "{$metafield['template_name']}_v{$metafield['template_version']}:{$metafield['field']}"; + $value = $metafield['value']; + $smartFlatten[$key] = $value; + } + $this->meta_fields = $smartFlatten; + } // if ((!isset($options['includeMetatemplate']) || empty($options['includeMetatemplate'])) && !empty($this->MetaTemplates)) { if ((!isset($options['includeMetatemplate']) || empty($options['includeMetatemplate']))) { unset($this->MetaTemplates); diff --git a/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php b/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php index 58110d1..c8e90e1 100644 --- a/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php +++ b/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php @@ -185,6 +185,9 @@ class BootstrapDropdownMenu extends BootstrapGeneric $classes = array_merge($classes, $entry['class']); } $params = $entry['attrs'] ?? []; + if (!empty($entry['onclick'])) { + $params['onclick'] = $entry['onclick']; + } $params['href'] = '#'; if (!empty($entry['menu'])) { diff --git a/templates/Users/index.php b/templates/Users/index.php index 4071a5a..13b434d 100644 --- a/templates/Users/index.php +++ b/templates/Users/index.php @@ -17,15 +17,21 @@ echo $this->element('genericElements/IndexTable/index_table', [ ] ] ], + [ + 'type' => 'context_filters', + 'context_filters' => $filteringContexts + ], [ 'type' => 'search', 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', - 'searchKey' => 'value' + 'searchKey' => 'value', + 'allowFilering' => true ], [ 'type' => 'table_action', + 'table_setting_id' => 'user_index', ] ] ], diff --git a/templates/element/genericElements/ListTopBar/group_search.php b/templates/element/genericElements/ListTopBar/group_search.php index 0cfb2f9..9fa7d04 100644 --- a/templates/element/genericElements/ListTopBar/group_search.php +++ b/templates/element/genericElements/ListTopBar/group_search.php @@ -24,7 +24,10 @@ $filteringButton = ''; if (!empty($data['allowFilering'])) { $activeFilters = !empty($activeFilters) ? $activeFilters : []; - $numberActiveFilters = count($activeFilters); + $activeFiltersFiltered = array_filter($activeFilters, function ($k) { + return !str_starts_with($k, '_'); + }, ARRAY_FILTER_USE_KEY); + $numberActiveFilters = count($activeFiltersFiltered); if (!empty($activeFilters['filteringMetaFields'])) { $numberActiveFilters += count($activeFilters['filteringMetaFields']) - 1; } @@ -34,7 +37,7 @@ 'title' => __('Filter index'), 'id' => sprintf('toggleFilterButton-%s', h($tableRandomValue)) ]; - if (count($activeFilters) > 0) { + if (count($activeFiltersFiltered) > 0) { $buttonConfig['badge'] = [ 'variant' => 'light', 'text' => $numberActiveFilters, diff --git a/templates/element/genericElements/ListTopBar/group_table_action.php b/templates/element/genericElements/ListTopBar/group_table_action.php index 7a4df9b..44d3394 100644 --- a/templates/element/genericElements/ListTopBar/group_table_action.php +++ b/templates/element/genericElements/ListTopBar/group_table_action.php @@ -5,6 +5,10 @@ use App\Utility\UI\IndexSetting; if (empty($data['table_setting_id']) && empty($model)) { throw new Exception(__('`table_setting_id` must be set in order to use the `table_action` table topbar')); } + +$now = date("Y-m-d_H-i-s"); +$downloadFilename = sprintf('%s_%s', $data['table_setting_id'] ?? h($model), $now); + $data['table_setting_id'] = !empty($data['table_setting_id']) ? $data['table_setting_id'] : IndexSetting::getIDFromTable($model); $tableSettings = IndexSetting::getTableSetting($loggedUser, $data['table_setting_id']); $compactDisplay = !empty($tableSettings['compact_display']); @@ -52,6 +56,15 @@ $indexColumnMenu = array_merge( $metaTemplateColumnMenu ); +$indexDownloadMenu = [ + ['header' => true, 'text' => 'JSON', 'icon' => 'file-code'], + ['text' => __('Download all'), 'onclick' => sprintf('downloadIndexTable(this, "%s")', $downloadFilename . '.json'), ], + ['text' => __('Download filtered table'), 'onclick' => sprintf('downloadIndexTable(this, "%s", true)', $downloadFilename . '.json'), ], + ['header' => true, 'text' => 'CSV', 'icon' => 'file-csv', ], + ['text' => __('Download all'), 'onclick' => sprintf('downloadIndexTable(this, "%s")', $downloadFilename . '.csv'), ], + ['text' => __('Download filtered table'), 'onclick' => sprintf('downloadIndexTable(this, "%s", true)', $downloadFilename . '.csv'), ], +]; + $compactDisplayHtml = $this->element('/genericElements/ListTopBar/group_table_action/compactDisplay', [ 'table_data' => $table_data, 'tableSettings' => $tableSettings, @@ -68,8 +81,6 @@ $numberOfElementHtml = $this->element('/genericElements/ListTopBar/group_table_a ?> Bootstrap->dropdownMenu([ 'dropdown-class' => 'ms-1', 'alignment' => 'end', @@ -95,9 +106,8 @@ $numberOfElementHtml = $this->element('/genericElements/ListTopBar/group_table_a [ 'text' => __('Download'), 'icon' => 'download', - 'attrs' => [ - 'onclick' => sprintf('downloadIndexTable(this, "%s")', $downloadFilename), - ], + 'keepOpen' => true, + 'menu' => $indexDownloadMenu, ], [ 'html' => $compactDisplayHtml, diff --git a/webroot/js/api-helper.js b/webroot/js/api-helper.js index d01a941..a91704f 100644 --- a/webroot/js/api-helper.js +++ b/webroot/js/api-helper.js @@ -14,6 +14,9 @@ class AJAXApi { static genericRequestConfigGETJSON = { headers: new Headers(Object.assign({}, AJAXApi.genericRequestHeaders, {Accept: 'application/json'})) } + static genericRequestConfigGETCSV = { + headers: new Headers(Object.assign({}, AJAXApi.genericRequestHeaders, {Accept: 'text/csv'})) + } /** * @namespace @@ -32,6 +35,7 @@ class AJAXApi { }, successToastOptions: { }, + fetchOptions: {}, } options = {} loadingOverlay = false @@ -45,6 +49,13 @@ class AJAXApi { this.mergeOptions(options) } + /** + * Merge the configuration object to be used by fetch based on the options passed in the constructor + */ + mergeFetchConfig(base) { + return Object.assign({}, base, this.options.fetchOptions) + } + /** * Based on the current configuration, provide feedback to the user via toast, console or do not * @param {Object} toastOptions - The options supported by Toaster#defaultOptions @@ -149,11 +160,25 @@ class AJAXApi { * @return {Promise} Promise object resolving to the fetched HTML */ static async quickFetchJSON(url, options={}) { - const constAlteredOptions = Object.assign({}, {provideFeedback: false}, options) + const constAlteredOptions = Object.assign({}, { provideFeedback: false, }, options) const tmpApi = new AJAXApi(constAlteredOptions) return tmpApi.fetchJSON(url, constAlteredOptions.skipRequestHooks) } + /** + * @param {string} url - The URL to fetch + * @param {Object} [options={}] - The options supported by AJAXApi#defaultOptions + * @return {Promise} Promise object resolving to the fetched CSV + */ + static async quickFetchCSV(url, options={}) { + const constAlteredOptions = Object.assign({}, { + provideFeedback: false, + fetchOptions: AJAXApi.genericRequestConfigGETCSV + }, options) + const tmpApi = new AJAXApi(constAlteredOptions) + return tmpApi.fetchURL(url, constAlteredOptions.skipRequestHooks) + } + /** * @param {string} url - The URL to fetch * @param {Object} [options={}] - The options supported by AJAXApi#defaultOptions @@ -213,7 +238,7 @@ class AJAXApi { } let toReturn try { - const response = await fetch(url, AJAXApi.genericRequestConfigGET); + const response = await fetch(url, this.mergeFetchConfig(AJAXApi.genericRequestConfigGET)); if (!response.ok) { throw new Error(`Network response was not ok. \`${response.statusText}\``) } diff --git a/webroot/js/main.js b/webroot/js/main.js index f79c4eb..b61a517 100644 --- a/webroot/js/main.js +++ b/webroot/js/main.js @@ -206,21 +206,34 @@ function deleteBookmark(bookmark, forSidebar=false) { }).catch((e) => { }) } -function downloadIndexTable(downloadButton, filename) { +function downloadIndexTable(downloadButton, filename, filtered) { const $dropdownMenu = $(downloadButton).closest('.dropdown') const tableRandomValue = $dropdownMenu.attr('data-table-random-value') const $container = $dropdownMenu.closest('div[id^="table-container-"]') const $table = $container.find(`table[data-table-random-value="${tableRandomValue}"]`) const $filterButton = $(`#toggleFilterButton-${tableRandomValue}`) const activeFilters = $filterButton.data('activeFilters') - const additionalUrlParams = $filterButton.data('additionalUrlParams') ? $filterButton.data('additionalUrlParams') : '' - const searchParam = jQuery.param(activeFilters); - const url = $table.data('reload-url') + additionalUrlParams + '?' + searchParam + // const additionalUrlParams = $filterButton.data('additionalUrlParams') ? $filterButton.data('additionalUrlParams') : '' + // const searchParam = jQuery.param(activeFilters); + // const url = $table.data('reload-url') + additionalUrlParams + '?' + searchParam + let url = $table.data('reload-url') + if (filtered) { + url = activeFilters._here + } let options = {} - const downloadPromise = AJAXApi.quickFetchJSON(url, options) + let downloadPromise; + if (filename.endsWith('.csv')) { + downloadPromise = AJAXApi.quickFetchCSV(url, options) + } else { + downloadPromise = AJAXApi.quickFetchJSON(url, options) + } UI.overlayUntilResolve($dropdownMenu, downloadPromise) downloadPromise.then((data) => { - download(filename, JSON.stringify(data, undefined, 4)) + if (filename.endsWith('.csv')) { + download(filename, data) + } else { + download(filename, JSON.stringify(data, undefined, 4)) + } }) }