Add Csv export bulk action

- Add CsvExportBulkAction process handler to provide the download information
- Extract legacy filter handling code to the LegacyFilterMapper.php service
- Add ExportBulkAction angular service to download the file using the info provided
This commit is contained in:
Clemente Raposo 2020-07-27 20:16:26 +01:00 committed by Dillon-Brown
parent 8881bc85f9
commit 2a4e209614
9 changed files with 411 additions and 95 deletions

View file

@ -15,7 +15,6 @@ parameters:
labelKey: LBL_EXPORT
params:
min: 1
max: 5
acl:
- export
merge:

View file

@ -0,0 +1,16 @@
import {messageServiceMock} from '@services/message/message.service.spec.mock';
import {TestBed} from '@angular/core/testing';
import {ExportBulkAction} from '@services/process/processes/bulk-action/actions/export/export.bulk-action';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {HttpClient} from '@angular/common/http';
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule
],
}).compileComponents().then();
const http = TestBed.get(HttpClient);
export const exportBulkActionMock = new ExportBulkAction(messageServiceMock, http);

View file

@ -0,0 +1,68 @@
import {BulkActionHandler, BulkActionHandlerData} from '@services/process/processes/bulk-action/bulk-action.model';
import {Injectable} from '@angular/core';
import {MessageService} from '@services/message/message.service';
import {HttpClient} from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class ExportBulkAction extends BulkActionHandler {
key = 'export';
constructor(
protected message: MessageService,
protected http: HttpClient
) {
super();
}
run(data: BulkActionHandlerData): void {
if (!data || !data.url || !data.formData) {
this.message.addDangerMessageByKey('LBL_MISSING_HANDLER_DATA_ROUTE');
return;
}
const options = {
responseType: 'blob',
observe: 'response',
} as { [key: string]: any };
if (data.queryParams) {
options.params = data.queryParams;
}
this.download(data.url, data.formData);
}
/**
* Download file
*
* NOTE: using a hidden form instead of js for better memory management see:
* https://github.com/eligrey/FileSaver.js/wiki/Saving-a-remote-file#using-a-form-element-other-than-get-methods
*
* @param {string} url for download
* @param {object} formData to submit
*/
protected download(url: string, formData: { [key: string]: string }): void {
const form = document.createElement('form');
form.setAttribute('id', 'export-download');
form.setAttribute('method', 'post');
form.setAttribute('action', url);
form.setAttribute('target', '_self');
form.setAttribute('style', 'display: none;');
Object.keys(formData).forEach(key => {
const hiddenField = document.createElement('input');
hiddenField.setAttribute('name', key);
hiddenField.setAttribute('value', formData[key]);
form.appendChild(hiddenField);
});
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
}

View file

@ -7,6 +7,7 @@ import {appStateStoreMock} from '@store/app-state/app-state.store.spec.mock';
import {BulkActionProcess} from '@services/process/processes/bulk-action/bulk-action';
import {messageServiceMock} from '@services/message/message.service.spec.mock';
import {redirectBulkActionMock} from '@services/process/processes/bulk-action/actions/redirect/redirect.bulk-action.spec.mock';
import {exportBulkActionMock} from '@services/process/processes/bulk-action/actions/export/export.bulk-action.spec.mock';
export const bulkActionMockData = {
'bulk-merge': {
@ -52,5 +53,6 @@ export const bulkActionProcessMock = new BulkActionProcess(
processServiceMock,
appStateStoreMock,
messageServiceMock,
redirectBulkActionMock
redirectBulkActionMock,
exportBulkActionMock
);

View file

@ -3,15 +3,17 @@ import {Observable} from 'rxjs';
import {catchError, take, tap} from 'rxjs/operators';
import {Process, ProcessService} from '@services/process/process.service';
import {AppStateStore} from '@store/app-state/app-state.store';
import {SearchCriteria} from '@store/list-view/list-view.store';
import {SearchCriteria, SortingSelection} from '@store/list-view/list-view.store';
import {MessageService} from '@services/message/message.service';
import {BulkActionHandler} from '@services/process/processes/bulk-action/bulk-action.model';
import {RedirectBulkAction} from '@services/process/processes/bulk-action/actions/redirect/redirect.bulk-action';
import {ExportBulkAction} from '@services/process/processes/bulk-action/actions/export/export.bulk-action';
export interface BulkActionProcessInput {
action: string;
module: string;
criteria?: SearchCriteria;
sort?: SortingSelection;
ids?: string[];
}
@ -26,9 +28,11 @@ export class BulkActionProcess {
private processService: ProcessService,
private appStateStore: AppStateStore,
protected message: MessageService,
protected redirectActionHandler: RedirectBulkAction
protected redirectActionHandler: RedirectBulkAction,
protected exportBulkAction: ExportBulkAction
) {
this.actions[redirectActionHandler.key] = redirectActionHandler;
this.actions[exportBulkAction.key] = exportBulkAction;
}
/**

View file

@ -10,9 +10,7 @@ import {
SelectionDataSource,
SelectionStatus
} from '@components/bulk-action-menu/bulk-action-menu.component';
import {
ChartTypesDataSource
} from '@components/chart/chart.component';
import {ChartTypesDataSource} from '@components/chart/chart.component';
import {ListGQL} from '@store/list-view/api.list.get';
import {PageSelection, PaginationCount, PaginationDataSource} from '@components/pagination/pagination.model';
import {SystemConfigStore} from '@store/system-config/system-config.store';
@ -21,7 +19,7 @@ import {AppData, ViewStore} from '@store/view/view.store';
import {LanguageStore} from '@store/language/language.store';
import {NavigationStore} from '@store/navigation/navigation.store';
import {ModuleNavigation} from '@services/navigation/module-navigation/module-navigation.service';
import {ChartTypesMap, BulkActionsMap, Metadata, MetadataStore} from '@store/metadata/metadata.store.service';
import {BulkActionsMap, Metadata, MetadataStore} from '@store/metadata/metadata.store.service';
import {LocalStorageService} from '@services/local-storage/local-storage.service';
import {SortDirection} from '@components/sort-button/sort-button.model';
import {BulkActionProcess, BulkActionProcessInput} from '@services/process/processes/bulk-action/bulk-action';
@ -466,16 +464,25 @@ export class ListViewStore extends ViewStore
return;
}
const displayedFields = [];
this.metadata.listView.fields.forEach(value => {
displayedFields.push(value.fieldName);
});
const data = {
action: actionName,
module: this.internalState.module,
criteria: null,
ids: null
sort: null,
ids: null,
fields: displayedFields
} as BulkActionProcessInput;
if (selection.all && selection.count > this.internalState.records.length) {
data.criteria = this.internalState.criteria;
data.sort = this.internalState.sort;
}
if (selection.all && selection.count <= this.internalState.records.length) {

View file

@ -3,6 +3,7 @@
namespace SuiteCRM\Core\Legacy;
use App\Entity\ListView;
use App\Service\LegacyFilterMapper;
use App\Service\ListViewProviderInterface;
use App\Service\ModuleNameMapperInterface;
use BeanFactory;
@ -24,10 +25,11 @@ class ListViewHandler extends LegacyHandler implements ListViewProviderInterface
* @var ModuleNameMapperInterface
*/
private $moduleNameMapper;
/**
* @var array
* @var LegacyFilterMapper
*/
private $filterOperatorMap;
private $legacyFilterMapper;
/**
* SystemConfigHandler constructor.
@ -37,7 +39,6 @@ class ListViewHandler extends LegacyHandler implements ListViewProviderInterface
* @param string $defaultSessionName
* @param LegacyScopeState $legacyScopeState
* @param ModuleNameMapperInterface $moduleNameMapper
* @param array $filterOperatorMap
*/
public function __construct(
string $projectDir,
@ -46,11 +47,11 @@ class ListViewHandler extends LegacyHandler implements ListViewProviderInterface
string $defaultSessionName,
LegacyScopeState $legacyScopeState,
ModuleNameMapperInterface $moduleNameMapper,
array $filterOperatorMap
LegacyFilterMapper $legacyFilterMapper
) {
parent::__construct($projectDir, $legacyDir, $legacySessionName, $defaultSessionName, $legacyScopeState);
$this->moduleNameMapper = $moduleNameMapper;
$this->filterOperatorMap = $filterOperatorMap;
$this->legacyFilterMapper = $legacyFilterMapper;
}
/**
@ -179,13 +180,13 @@ class ListViewHandler extends LegacyHandler implements ListViewProviderInterface
): array {
$type = $criteria['type'] ?? 'advanced';
$mapped = $this->mapFilters($criteria, $type);
$mapped = $this->legacyFilterMapper->mapFilters($criteria, $type);
$baseCriteria = [
'searchFormTab' => "${type}_search",
'query' => 'true',
'orderBy' => $sort['orderBy'] ?? 'date_entered',
'sortOrder' => $sort['sortOrder'] ?? 'DESC'
'orderBy' => $this->legacyFilterMapper->getOrderBy($sort),
'sortOrder' => $this->legacyFilterMapper->getSortOrder($sort)
];
$legacyCriteria = array_merge($baseCriteria, $mapped);
@ -207,8 +208,7 @@ class ListViewHandler extends LegacyHandler implements ListViewProviderInterface
int $limit,
array $criteria = [],
array $filterFields = []
): array
{
): array {
$params = $this->getSortingParams($criteria);
$legacyListView = $this->getLegacyListView($bean);
@ -226,82 +226,6 @@ class ListViewHandler extends LegacyHandler implements ListViewProviderInterface
return $listViewData->getListViewData($bean, $where, $offset, $limit, $filter_fields, $params);
}
/**
* Map Filters to legacy
* @param array $criteria
* @param string $type
* @return array
*/
protected function mapFilters(array $criteria, string $type): array
{
$mapped = [];
if (empty($criteria['filters'])) {
return $mapped;
}
foreach ($criteria['filters'] as $key => $item) {
if (empty($item['operator'])) {
continue;
}
if (empty($this->filterOperatorMap[$item['operator']])) {
continue;
}
$mapConfig = $this->filterOperatorMap[$item['operator']];
foreach ($mapConfig as $mappedKey => $mappedValue) {
$legacyKey = $this->mapFilterKey($type, $key, $mappedKey);
$legacyValue = $this->mapFilterValue($mappedValue, $item);
$mapped[$legacyKey] = $legacyValue;
}
}
return $mapped;
}
/**
* Map Filter key to legacy
* @param string $type
* @param string $key
* @param string $mappedKey
* @return string|string[]
*/
protected function mapFilterKey(string $type, string $key, string $mappedKey): string
{
return str_replace(array('{field}', '{type}'), array($key, $type), $mappedKey);
}
/**
* Map Filter value to legacy
* @param string $mappedValue
* @param array $item
* @return mixed|string|string[]
*/
protected function mapFilterValue(string $mappedValue, array $item)
{
if ($mappedValue === 'values') {
if (count($item['values']) === 1) {
$legacyValue = $item['values'][0];
} else {
$legacyValue = $item['values'];
}
return $legacyValue;
}
$operator = $item['operator'] ?? '';
$start = $item['start'] ?? '';
$end = $item['end'] ?? '';
return str_replace(['{operator}', '{start}', '{end}'], [$operator, $start, $end], $mappedValue);
}
/**
* Get list view defs
* @param ViewList $legacyListView

View file

@ -0,0 +1,182 @@
<?php
namespace App\Service\BulkActions;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use App\Entity\Process;
use App\Service\LegacyFilterMapper;
use App\Service\ModuleNameMapperInterface;
use App\Service\ProcessHandlerInterface;
class CsvExportBulkAction implements ProcessHandlerInterface
{
protected const MSG_OPTIONS_NOT_FOUND = 'Process options is not defined';
protected const PROCESS_TYPE = 'bulk-export';
/**
* @var ModuleNameMapperInterface
*/
private $moduleNameMapper;
/**
* @var LegacyFilterMapper
*/
private $legacyFilterMapper;
/**
* CsvExportBulkAction constructor.
* @param ModuleNameMapperInterface $moduleNameMapper
* @param LegacyFilterMapper $legacyFilterMapper
*/
public function __construct(ModuleNameMapperInterface $moduleNameMapper, LegacyFilterMapper $legacyFilterMapper)
{
$this->moduleNameMapper = $moduleNameMapper;
$this->legacyFilterMapper = $legacyFilterMapper;
}
/**
* @inheritDoc
*/
public function getProcessType(): string
{
return self::PROCESS_TYPE;
}
/**
* @inheritDoc
*/
public function requiredAuthRole(): string
{
return 'ROLE_USER';
}
/**
* @inheritDoc
*/
public function configure(Process $process): void
{
//This process is synchronous
//We aren't going to store a record on db
//thus we will use process type as the id
$process->setId(self::PROCESS_TYPE);
$process->setAsync(false);
}
/**
* @inheritDoc
*/
public function validate(Process $process): void
{
if (empty($process->getOptions())) {
throw new InvalidArgumentException(self::MSG_OPTIONS_NOT_FOUND);
}
$options = $process->getOptions();
if (empty($options['module']) || empty($options['action'])) {
throw new InvalidArgumentException(self::MSG_OPTIONS_NOT_FOUND);
}
if (empty($options['fields'])) {
throw new InvalidArgumentException(self::MSG_OPTIONS_NOT_FOUND);
}
if (empty($options['ids']) && empty($options['criteria'])) {
throw new InvalidArgumentException(self::MSG_OPTIONS_NOT_FOUND);
}
}
/**
* @inheritDoc
*/
public function run(Process $process)
{
$options = $process->getOptions();
$responseData = $this->getDownloadData($options);
$process->setStatus('success');
$process->setMessages([]);
$process->setData($responseData);
}
/**
* @param array|null $options
* @return array
*/
protected function getDownloadData(?array $options): array
{
$responseData = [
'handler' => 'export',
'params' => [
'url' => 'legacy/index.php?entryPoint=export',
'formData' => []
]
];
if (!empty($options['ids'])) {
$responseData = $this->getIdBasedRequestData($options, $responseData);
return $responseData;
}
if (!empty($options['criteria'])) {
$responseData = $this->getCriteriaBasedRequestData($options, $responseData);
}
return $responseData;
}
/**
* Get request data based on a list of ids
* @param array|null $options
* @param array $responseData
* @return array
*/
protected function getIdBasedRequestData(?array $options, array $responseData): array
{
$responseData['params']['formData'] = [
'uid' => implode(',', $options['ids']),
'module' => $this->moduleNameMapper->toLegacy($options['module']),
'action' => 'index'
];
return $responseData;
}
/**
* Get Request data based on a search criteria
* @param array|null $options
* @param array $responseData
* @return array
*/
protected function getCriteriaBasedRequestData(?array $options, array $responseData): array
{
$responseData['params']['url'] .= '&module=' . $this->moduleNameMapper->toLegacy($options['module']);
$downloadData = [
'module' => $this->moduleNameMapper->toLegacy($options['module']),
'action' => 'index',
"searchFormTab" => "advanced_search",
"query" => "true",
"saved_search_name" => "",
"search_module" => "",
"saved_search_action" => "",
"displayColumns" => strtoupper(implode('|', $options['fields'])),
"orderBy" => strtoupper($this->legacyFilterMapper->getOrderBy($options['sort'])),
"sortOrder" => $this->legacyFilterMapper->getSortOrder($options['sort']),
"button" => "Search"
];
$type = $options['criteria']['type'] ?? 'advanced';
$mapped = $this->legacyFilterMapper->mapFilters($options['criteria'], $type);
$downloadData = array_merge($downloadData, $mapped);
$responseData['params']['formData'] = [
'current_post' => json_encode($downloadData)
];
return $responseData;
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace App\Service;
class LegacyFilterMapper
{
/**
* @var array
*/
private $filterOperatorMap;
/**
* LegacyFilterMapper constructor.
* @param array $filterOperatorMap
*/
public function __construct(array $filterOperatorMap)
{
$this->filterOperatorMap = $filterOperatorMap;
}
/**
* Map Filters to legacy
* @param array $criteria
* @param string $type
* @return array
*/
public function mapFilters(array $criteria, string $type): array
{
$mapped = [];
if (empty($criteria['filters'])) {
return $mapped;
}
foreach ($criteria['filters'] as $key => $item) {
if (empty($item['operator'])) {
continue;
}
if (empty($this->filterOperatorMap[$item['operator']])) {
continue;
}
$mapConfig = $this->filterOperatorMap[$item['operator']];
foreach ($mapConfig as $mappedKey => $mappedValue) {
$legacyKey = $this->mapFilterKey($type, $key, $mappedKey);
$legacyValue = $this->mapFilterValue($mappedValue, $item);
$mapped[$legacyKey] = $legacyValue;
}
}
return $mapped;
}
/**
* Get order by
* @param array $sort
* @return string
*/
public function getOrderBy(array $sort): string
{
return $sort['orderBy'] ?? 'date_entered';
}
/**
* Get sort order
* @param array $sort
* @return string
*/
public function getSortOrder(array $sort): string
{
return $sort['sortOrder'] ?? 'DESC';
}
/**
* Map Filter key to legacy
* @param string $type
* @param string $key
* @param string $mappedKey
* @return string|string[]
*/
protected function mapFilterKey(string $type, string $key, string $mappedKey): string
{
return str_replace(array('{field}', '{type}'), array($key, $type), $mappedKey);
}
/**
* Map Filter value to legacy
* @param string $mappedValue
* @param array $item
* @return mixed|string|string[]
*/
protected function mapFilterValue(string $mappedValue, array $item)
{
if ($mappedValue === 'values') {
if (count($item['values']) === 1) {
$legacyValue = $item['values'][0];
} else {
$legacyValue = $item['values'];
}
return $legacyValue;
}
$operator = $item['operator'] ?? '';
$start = $item['start'] ?? '';
$end = $item['end'] ?? '';
return str_replace(['{operator}', '{start}', '{end}'], [$operator, $start, $end], $mappedValue);
}
}