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:
Clemente Raposo 2020-07-11 23:07:08 +01:00 committed by Dillon-Brown
parent 8a5a002e76
commit 8881bc85f9
13 changed files with 433 additions and 23 deletions

View file

@ -22,7 +22,7 @@ parameters:
key: merge
labelKey: LBL_MERGE_DUPLICATES
params:
min: 1
min: 2
max: 5
acl:
- edit

View file

@ -22,8 +22,8 @@ export interface SelectionDataSource {
export interface BulkActionDataSource {
getBulkActions(): Observable<BulkActionsMap>;
executeBulkAction(action: string): void;
}
export interface BulkActionViewModel {

View 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();

View file

@ -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> {

View file

@ -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);

View file

@ -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();
}
}

View file

@ -0,0 +1,9 @@
export interface BulkActionHandlerData {
[key: string]: any;
}
export abstract class BulkActionHandler {
abstract key: string;
abstract run(data: BulkActionHandlerData): void;
}

View file

@ -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
);

View file

@ -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;
}),
);
}
}

View file

@ -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();

View file

@ -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();
}
/**

View file

@ -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;
}
}

View 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;
}
}