mirror of
https://github.com/SuiteCRM/SuiteCRM-Core.git
synced 2025-09-04 10:14:13 +08:00
Add Merge bulk action
- Add base frontend bulk action handling -- Add max min validations -- Add generic backend bulk action process call - Add front end redirect handler -- Can be re-used by several bulk actions if needed - Add MergeRecordsBulkAction.php backend handler -- Handle the the bulk action process request -- Tells front end if extra handling is needed - Fix bug list view metadata sent to the front end
This commit is contained in:
parent
8a5a002e76
commit
8881bc85f9
13 changed files with 433 additions and 23 deletions
|
@ -22,7 +22,7 @@ parameters:
|
|||
key: merge
|
||||
labelKey: LBL_MERGE_DUPLICATES
|
||||
params:
|
||||
min: 1
|
||||
min: 2
|
||||
max: 5
|
||||
acl:
|
||||
- edit
|
||||
|
|
|
@ -22,8 +22,8 @@ export interface SelectionDataSource {
|
|||
|
||||
export interface BulkActionDataSource {
|
||||
getBulkActions(): Observable<BulkActionsMap>;
|
||||
|
||||
executeBulkAction(action: string): void;
|
||||
|
||||
}
|
||||
|
||||
export interface BulkActionViewModel {
|
||||
|
|
17
core/app/src/services/message/message.service.spec.mock.ts
Normal file
17
core/app/src/services/message/message.service.spec.mock.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import {MessageService} from '@services/message/message.service';
|
||||
|
||||
class MessageServiceSpy extends MessageService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
addSuccessMessageByKey(labelKey: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
addDangerMessageByKey(labelKey: string): number {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const messageServiceMock = new MessageServiceSpy();
|
|
@ -14,6 +14,7 @@ export interface Process {
|
|||
async: boolean;
|
||||
type: string;
|
||||
options: ProcessOptions;
|
||||
data?: ProcessOptions;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
|
@ -36,17 +37,6 @@ export class ProcessService {
|
|||
protected graphqlName = 'process';
|
||||
protected coreName = 'Process';
|
||||
|
||||
protected fieldsMetadata = {
|
||||
fields: [
|
||||
'id',
|
||||
'_id',
|
||||
'status',
|
||||
'async',
|
||||
'type',
|
||||
'options'
|
||||
]
|
||||
};
|
||||
|
||||
protected createFieldsMetadata = {
|
||||
fields: [
|
||||
'_id',
|
||||
|
@ -54,6 +44,7 @@ export class ProcessService {
|
|||
'async',
|
||||
'type',
|
||||
'messages',
|
||||
'data'
|
||||
]
|
||||
};
|
||||
|
||||
|
@ -68,9 +59,9 @@ export class ProcessService {
|
|||
* Submit and action/process request
|
||||
* Returns observable
|
||||
*
|
||||
* @returns Observable<any>
|
||||
* @param type
|
||||
* @param options
|
||||
* @param {string} type to create
|
||||
* @param {object} options to send
|
||||
* @returns {object} Observable<any>
|
||||
*/
|
||||
public submit(type: string, options: ProcessOptions): Observable<Process> {
|
||||
return this.create(type, options);
|
||||
|
@ -84,9 +75,9 @@ export class ProcessService {
|
|||
/**
|
||||
* Create a process on the backend
|
||||
*
|
||||
* @returns Observable<any>
|
||||
* @param type
|
||||
* @param options
|
||||
* @param {string} type to create
|
||||
* @param {object} options to send
|
||||
* @returns {object} Observable<any>
|
||||
*/
|
||||
protected create(type: string, options: ProcessOptions): Observable<Process> {
|
||||
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import {RedirectBulkAction} from '@services/process/processes/bulk-action/actions/redirect/redirect.bulk-action';
|
||||
import {messageServiceMock} from '@services/message/message.service.spec.mock';
|
||||
import {Router} from '@angular/router';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {RouterTestingModule} from '@angular/router/testing';
|
||||
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
],
|
||||
}).compileComponents().then();
|
||||
|
||||
const router = TestBed.get(Router); // Just if we need to test Route Service functionality
|
||||
|
||||
|
||||
export const redirectBulkActionMock = new RedirectBulkAction(router, messageServiceMock);
|
|
@ -0,0 +1,36 @@
|
|||
import {BulkActionHandler, BulkActionHandlerData} from '@services/process/processes/bulk-action/bulk-action.model';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Router} from '@angular/router';
|
||||
import {MessageService} from '@services/message/message.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class RedirectBulkAction extends BulkActionHandler {
|
||||
key = 'redirect';
|
||||
|
||||
constructor(
|
||||
protected router: Router,
|
||||
protected message: MessageService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
run(data: BulkActionHandlerData): void {
|
||||
|
||||
if (!data || !data.route) {
|
||||
this.message.addDangerMessageByKey('LBL_MISSING_HANDLER_DATA_ROUTE');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = {
|
||||
queryParams: {}
|
||||
};
|
||||
|
||||
if (data.queryParams) {
|
||||
params.queryParams = data.queryParams;
|
||||
}
|
||||
|
||||
this.router.navigate([data.route], params).then();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export interface BulkActionHandlerData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export abstract class BulkActionHandler {
|
||||
abstract key: string;
|
||||
|
||||
abstract run(data: BulkActionHandlerData): void;
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import {Observable, of} from 'rxjs';
|
||||
import {shareReplay} from 'rxjs/operators';
|
||||
import {RecordMutationGQL} from '@services/api/graphql-api/api.record.create';
|
||||
import {FetchResult} from 'apollo-link';
|
||||
import {ProcessService} from '@services/process/process.service';
|
||||
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';
|
||||
|
||||
export const bulkActionMockData = {
|
||||
'bulk-merge': {
|
||||
data: {
|
||||
createProcess: {
|
||||
process: {
|
||||
_id: 'bulk-password',
|
||||
status: 'success',
|
||||
async: false,
|
||||
type: 'bulk-password',
|
||||
messages: [],
|
||||
data: {}
|
||||
},
|
||||
clientMutationId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
class BulkActionProcessMutationGQLSpy extends RecordMutationGQL {
|
||||
constructor() {
|
||||
super(null);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
public create(
|
||||
graphqlModuleName: string,
|
||||
coreModuleName: string,
|
||||
input: { [key: string]: any },
|
||||
metadata: { fields: string[] }
|
||||
): Observable<FetchResult<any>> {
|
||||
|
||||
return of(bulkActionMockData[input.options.action]).pipe(shareReplay());
|
||||
}
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
}
|
||||
|
||||
const processServiceMock = new ProcessService(new BulkActionProcessMutationGQLSpy());
|
||||
|
||||
export const bulkActionProcessMock = new BulkActionProcess(
|
||||
processServiceMock,
|
||||
appStateStoreMock,
|
||||
messageServiceMock,
|
||||
redirectBulkActionMock
|
||||
);
|
|
@ -0,0 +1,91 @@
|
|||
import {Injectable} from '@angular/core';
|
||||
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 {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';
|
||||
|
||||
export interface BulkActionProcessInput {
|
||||
action: string;
|
||||
module: string;
|
||||
criteria?: SearchCriteria;
|
||||
ids?: string[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class BulkActionProcess {
|
||||
|
||||
actions: { [key: string]: BulkActionHandler } = {};
|
||||
|
||||
constructor(
|
||||
private processService: ProcessService,
|
||||
private appStateStore: AppStateStore,
|
||||
protected message: MessageService,
|
||||
protected redirectActionHandler: RedirectBulkAction
|
||||
) {
|
||||
this.actions[redirectActionHandler.key] = redirectActionHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send bulk action request
|
||||
*
|
||||
* @param {string} action to submit
|
||||
* @param {string} data to send
|
||||
* @returns {{}} Observable<Process>
|
||||
*/
|
||||
public run(action: string, data: BulkActionProcessInput): Observable<Process> {
|
||||
const options = {
|
||||
...data
|
||||
};
|
||||
|
||||
this.appStateStore.updateLoading(action, true);
|
||||
|
||||
return this.processService
|
||||
.submit(action, options)
|
||||
.pipe(
|
||||
take(1),
|
||||
tap((process: Process) => {
|
||||
this.appStateStore.updateLoading(action, false);
|
||||
|
||||
let handler = 'addSuccessMessageByKey';
|
||||
if (process.status === 'error') {
|
||||
handler = 'addDangerMessageByKey';
|
||||
}
|
||||
|
||||
if (process.messages) {
|
||||
process.messages.forEach(message => {
|
||||
this.message[handler](message);
|
||||
});
|
||||
}
|
||||
|
||||
if (process.status === 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.data && !process.data.handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionHandler: BulkActionHandler = this.actions[process.data.handler];
|
||||
|
||||
if (!actionHandler) {
|
||||
this.message.addDangerMessageByKey('LBL_MISSING_HANDLER');
|
||||
return;
|
||||
}
|
||||
|
||||
actionHandler.run(process.data.params);
|
||||
|
||||
}),
|
||||
catchError(err => {
|
||||
this.message.addDangerMessageByKey('LBL_BULK_ACTION_ERROR');
|
||||
this.appStateStore.updateLoading(action, false);
|
||||
throw err;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -11,6 +11,8 @@ import {metadataStoreMock} from '@store/metadata/metadata.store.spec.mock';
|
|||
import {mockModuleNavigation} from '@services/navigation/module-navigation/module-navigation.service.spec.mock';
|
||||
import {localStorageServiceMock} from '@services/local-storage/local-storage.service.spec.mock';
|
||||
import {deepClone} from '@base/utils/object-utils';
|
||||
import {bulkActionProcessMock} from '@services/process/processes/bulk-action/bulk-action.spec.mock';
|
||||
import {messageServiceMock} from '@services/message/message.service.spec.mock';
|
||||
|
||||
/* eslint-disable camelcase, @typescript-eslint/camelcase */
|
||||
export const listviewMockData = {
|
||||
|
@ -159,7 +161,9 @@ export const listviewStoreMock = new ListViewStore(
|
|||
navigationMock,
|
||||
mockModuleNavigation,
|
||||
metadataStoreMock,
|
||||
localStorageServiceMock
|
||||
localStorageServiceMock,
|
||||
bulkActionProcessMock,
|
||||
messageServiceMock
|
||||
);
|
||||
|
||||
listviewStoreMock.init('accounts').pipe(take(1)).subscribe();
|
||||
|
|
|
@ -24,6 +24,8 @@ import {ModuleNavigation} from '@services/navigation/module-navigation/module-na
|
|||
import {ChartTypesMap, 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';
|
||||
import {MessageService} from '@services/message/message.service';
|
||||
|
||||
export interface FieldMap {
|
||||
[key: string]: any;
|
||||
|
@ -191,7 +193,9 @@ export class ListViewStore extends ViewStore
|
|||
protected navigationStore: NavigationStore,
|
||||
protected moduleNavigation: ModuleNavigation,
|
||||
protected metadataStore: MetadataStore,
|
||||
protected localStorage: LocalStorageService
|
||||
protected localStorage: LocalStorageService,
|
||||
protected bulkAction: BulkActionProcess,
|
||||
protected message: MessageService,
|
||||
) {
|
||||
|
||||
super(appStateStore, languageStore, navigationStore, moduleNavigation, metadataStore);
|
||||
|
@ -442,8 +446,50 @@ export class ListViewStore extends ViewStore
|
|||
}
|
||||
|
||||
executeBulkAction(action: string): void {
|
||||
// To implement
|
||||
console.log(action);
|
||||
const selection = this.internalState.selection;
|
||||
const definition = this.metadata.listView.bulkActions[action];
|
||||
const actionName = `bulk-${action}`;
|
||||
|
||||
this.message.removeMessages();
|
||||
|
||||
if (definition.params.min && selection.count < definition.params.min) {
|
||||
let message = this.appStrings.LBL_TOO_FEW_SELECTED;
|
||||
message = message.replace('{min}', definition.params.min);
|
||||
this.message.addDangerMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (definition.params.max && selection.count > definition.params.max) {
|
||||
let message = this.appStrings.LBL_TOO_MANY_SELECTED;
|
||||
message = message.replace('{max}', definition.params.max);
|
||||
this.message.addDangerMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
action: actionName,
|
||||
module: this.internalState.module,
|
||||
criteria: null,
|
||||
ids: null
|
||||
} as BulkActionProcessInput;
|
||||
|
||||
|
||||
if (selection.all && selection.count > this.internalState.records.length) {
|
||||
data.criteria = this.internalState.criteria;
|
||||
}
|
||||
|
||||
if (selection.all && selection.count <= this.internalState.records.length) {
|
||||
data.ids = [];
|
||||
this.internalState.records.forEach(record => {
|
||||
data.ids.push(record.id);
|
||||
});
|
||||
}
|
||||
|
||||
if (!selection.all) {
|
||||
data.ids = Object.keys(selection.selected);
|
||||
}
|
||||
|
||||
this.bulkAction.run(actionName, data).subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -60,6 +60,12 @@ class Process
|
|||
*/
|
||||
protected $options;
|
||||
|
||||
/**
|
||||
* @ApiProperty
|
||||
* @var array|null
|
||||
*/
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* Get Id
|
||||
* @return string|null
|
||||
|
@ -186,4 +192,25 @@ class Process
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data
|
||||
* @return array|null
|
||||
*/
|
||||
public function getData(): ?array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data
|
||||
* @param array|null $data
|
||||
* @return Process
|
||||
*/
|
||||
public function setData(?array $data): Process
|
||||
{
|
||||
$this->data = $data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
116
core/src/Service/BulkActions/MergeRecordsBulkAction.php
Normal file
116
core/src/Service/BulkActions/MergeRecordsBulkAction.php
Normal file
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service\BulkActions;
|
||||
|
||||
use ApiPlatform\Core\Exception\InvalidArgumentException;
|
||||
use App\Entity\Process;
|
||||
use App\Service\ModuleNameMapperInterface;
|
||||
use App\Service\ProcessHandlerInterface;
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class MergeRecordsBulkAction implements ProcessHandlerInterface, LoggerAwareInterface
|
||||
{
|
||||
protected const MSG_OPTIONS_NOT_FOUND = 'Process options is not defined';
|
||||
protected const PROCESS_TYPE = 'bulk-merge';
|
||||
|
||||
/**
|
||||
* @var LoggerInterface
|
||||
*/
|
||||
private $logger;
|
||||
|
||||
/**
|
||||
* @var ModuleNameMapperInterface
|
||||
*/
|
||||
private $moduleNameMapper;
|
||||
|
||||
/**
|
||||
* MergeRecordsBulkAction constructor.
|
||||
* @param ModuleNameMapperInterface $moduleNameMapper
|
||||
*/
|
||||
public function __construct(ModuleNameMapperInterface $moduleNameMapper)
|
||||
{
|
||||
$this->moduleNameMapper = $moduleNameMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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['ids'])) {
|
||||
throw new InvalidArgumentException(self::MSG_OPTIONS_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function run(Process $process)
|
||||
{
|
||||
$options = $process->getOptions();
|
||||
|
||||
$responseData = [
|
||||
'handler' => 'redirect',
|
||||
'params' => [
|
||||
'route' => 'merge-records/index',
|
||||
'queryParams' => [
|
||||
'action_module' => $this->moduleNameMapper->toLegacy($options['module']),
|
||||
'uid' => implode(',', $options['ids']),
|
||||
'return_module' => $this->moduleNameMapper->toLegacy($options['module']),
|
||||
'return_action' => 'index',
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$process->setStatus('success');
|
||||
$process->setMessages([]);
|
||||
$process->setData($responseData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setLogger(LoggerInterface $logger): void
|
||||
{
|
||||
$this->logger = $logger;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue