From 59c7d68299dda47c417077124183b4bd4b98bbfd Mon Sep 17 00:00:00 2001 From: Thomas Hunter <thomas.hunter@alfresco.com> Date: Thu, 6 Oct 2022 14:07:47 +0100 Subject: [PATCH] [ACA-3258] Edit rule dialog actions section (#2692) * Add actions service * Create components * Rebasing * Add card view component * Moved actions definition call outside of actions list component * Localisation of parameter and action labels + read only mode for components * Remove action option * Default to one item in array * Handle change of cardview * Linting * Add unit tests * Fix broken unit tests * Fix unknown word * Add private to property --- projects/aca-folder-rules/assets/i18n/en.json | 14 +- .../src/lib/folder-rules.module.ts | 4 + .../manage-rules.smart-component.html | 11 +- .../manage-rules.smart-component.scss | 2 +- .../manage-rules.smart-component.spec.ts | 10 +- .../manage-rules.smart-component.ts | 16 +- .../src/lib/mock/actions.mock.ts | 108 +++++++++++++ .../src/lib/model/rule-action.model.ts | 22 ++- .../rule-action-list.ui-component.html | 31 ++++ .../rule-action-list.ui-component.scss | 15 ++ .../rule-action-list.ui-component.spec.ts | 104 ++++++++++++ .../actions/rule-action-list.ui-component.ts | 77 +++++++++ .../actions/rule-action.ui-component.html | 22 +++ .../actions/rule-action.ui-component.scss | 7 + .../actions/rule-action.ui-component.spec.ts | 87 ++++++++++ .../actions/rule-action.ui-component.ts | 153 ++++++++++++++++++ ...rule-composite-condition.ui-component.html | 8 +- .../edit-rule-dialog.smart-component.html | 16 +- .../edit-rule-dialog.smart-component.scss | 7 + .../edit-rule-dialog.smart-component.spec.ts | 28 ++-- .../edit-rule-dialog.smart-component.ts | 13 +- .../rule-details.ui-component.html | 11 ++ .../rule-details.ui-component.scss | 6 + .../rule-details.ui-component.spec.ts | 11 +- .../rule-details/rule-details.ui-component.ts | 9 +- .../src/lib/services/actions.service.spec.ts | 65 ++++++++ .../src/lib/services/actions.service.ts | 92 +++++++++++ .../lib/services/folder-rules.service.spec.ts | 8 +- 28 files changed, 914 insertions(+), 43 deletions(-) create mode 100644 projects/aca-folder-rules/src/lib/mock/actions.mock.ts create mode 100644 projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.html create mode 100644 projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.scss create mode 100644 projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.spec.ts create mode 100644 projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.ts create mode 100644 projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.html create mode 100644 projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.scss create mode 100644 projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.spec.ts create mode 100644 projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.ts create mode 100644 projects/aca-folder-rules/src/lib/services/actions.service.spec.ts create mode 100644 projects/aca-folder-rules/src/lib/services/actions.service.ts diff --git a/projects/aca-folder-rules/assets/i18n/en.json b/projects/aca-folder-rules/assets/i18n/en.json index 34b394b9b..2ce08d5eb 100644 --- a/projects/aca-folder-rules/assets/i18n/en.json +++ b/projects/aca-folder-rules/assets/i18n/en.json @@ -15,6 +15,7 @@ "NAME": "Name", "DESCRIPTION": "Description", "WHEN": "When", + "PERFORM_ACTIONS": "Perform actions", "OPTIONS": "Other options" }, "PLACEHOLDER": { @@ -70,21 +71,26 @@ "AND": "And", "OR": "Or" }, - "ACTIONS": { + "CONDITION_BUTTONS": { "ADD_CONDITION": "Add condition", - "ADD_GROUP": "Add group", + "ADD_GROUP": "Add condition group", "REMOVE": "Remove" }, "NO_CONDITIONS": "No conditions", - "NO_CONDITIONS_IN_GROUP": "No conditions in the group" + "NO_CONDITIONS_IN_GROUP": "No conditions in the group", + "ACTION_BUTTONS": { + "ADD_ACTION": "Add action", + "REMOVE": "Remove" }, + "ACTION_SELECT_PLACEHOLDER": "Select an action" + }, "MANAGE_RULES": { "TOOLBAR": { "BREADCRUMB": { "RULES": "rules" }, "ACTIONS": { - "NEW_RULE": "New rule" + "CREATE_RULE": "Create rule" } }, "EMPTY_RULES_LIST": { diff --git a/projects/aca-folder-rules/src/lib/folder-rules.module.ts b/projects/aca-folder-rules/src/lib/folder-rules.module.ts index 3a2e6d939..fab75c403 100644 --- a/projects/aca-folder-rules/src/lib/folder-rules.module.ts +++ b/projects/aca-folder-rules/src/lib/folder-rules.module.ts @@ -40,6 +40,8 @@ import { RuleListItemUiComponent } from './rules-list/rule/rule-list-item.ui-com import { RulesListUiComponent } from './rules-list/rules-list.ui-component'; import { RuleTriggersUiComponent } from './rule-details/triggers/rule-triggers.ui-component'; import { RuleOptionsUiComponent } from './rule-details/options/rule-options.ui-component'; +import { RuleActionListUiComponent } from './rule-details/actions/rule-action-list.ui-component'; +import { RuleActionUiComponent } from './rule-details/actions/rule-action.ui-component'; const routes: Routes = [ { @@ -63,6 +65,8 @@ const routes: Routes = [ declarations: [ EditRuleDialogSmartComponent, ManageRulesSmartComponent, + RuleActionListUiComponent, + RuleActionUiComponent, RuleCompositeConditionUiComponent, RuleDetailsUiComponent, RuleSimpleConditionUiComponent, diff --git a/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.html b/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.html index ad9866c39..ef52ac195 100644 --- a/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.html +++ b/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.html @@ -12,7 +12,7 @@ <aca-page-layout-content> <div class="main-content"> - <ng-container *ngIf="isLoading$ | async; else onLoaded"> + <ng-container *ngIf="(rulesLoading$ | async) || (actionsLoading$ | async); else onLoaded"> <mat-progress-bar color="primary" mode="indeterminate"></mat-progress-bar> </ng-container> @@ -25,7 +25,7 @@ class="aca-manage-rules__actions-bar__title__breadcrumb"></adf-breadcrumb> </adf-toolbar-title> - <button mat-flat-button color="primary" (click)="openNewRuleDialog()">{{ 'ACA_FOLDER_RULES.MANAGE_RULES.TOOLBAR.ACTIONS.NEW_RULE' | translate }}</button> + <button mat-flat-button color="primary" (click)="openNewRuleDialog()">{{ 'ACA_FOLDER_RULES.MANAGE_RULES.TOOLBAR.ACTIONS.CREATE_RULE' | translate }}</button> </adf-toolbar> <mat-divider></mat-divider> @@ -44,7 +44,12 @@ </div> <p>{{ selectedRule.description }}</p> </div> - <aca-rule-details [readOnly]="true" [preview]="true" [value]="selectedRule"></aca-rule-details> + <aca-rule-details + [actionDefinitions]="actionDefinitions$ | async" + [readOnly]="true" + [preview]="true" + [value]="selectedRule"> + </aca-rule-details> </div> </div> diff --git a/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.scss b/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.scss index 74c6e3cd3..a8e4850f2 100644 --- a/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.scss +++ b/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.scss @@ -17,7 +17,7 @@ &__container { display: grid; - grid-template-columns: 33% 66%; + grid-template-columns: minmax(200px,1fr) 3fr; padding: 32px; overflow: scroll; diff --git a/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.spec.ts b/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.spec.ts index 02aa2e1fa..c14bbf569 100644 --- a/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.spec.ts +++ b/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.spec.ts @@ -34,12 +34,14 @@ import { dummyRules } from '../mock/rules.mock'; import { By } from '@angular/platform-browser'; import { dummyNodeInfo } from '../mock/node.mock'; import { MatDialog } from '@angular/material/dialog'; +import { ActionsService } from '../services/actions.service'; describe('ManageRulesSmartComponent', () => { let fixture: ComponentFixture<ManageRulesSmartComponent>; let component: ManageRulesSmartComponent; let debugElement: DebugElement; let folderRulesService: FolderRulesService; + let actionsService: ActionsService; beforeEach( waitForAsync(() => { @@ -57,6 +59,7 @@ describe('ManageRulesSmartComponent', () => { component = fixture.componentInstance; debugElement = fixture.debugElement; folderRulesService = TestBed.inject<FolderRulesService>(FolderRulesService); + actionsService = TestBed.inject<ActionsService>(ActionsService); }); }) ); @@ -66,6 +69,7 @@ describe('ManageRulesSmartComponent', () => { folderRulesService.folderInfo$ = of(dummyNodeInfo); folderRulesService.rulesListing$ = of(dummyRules); folderRulesService.loading$ = of(false); + actionsService.loading$ = of(false); fixture.detectChanges(); @@ -87,6 +91,7 @@ describe('ManageRulesSmartComponent', () => { folderRulesService.rulesListing$ = of([]); folderRulesService.loading$ = of(false); folderRulesService.deletedRuleId$ = of(null); + actionsService.loading$ = of(false); fixture.detectChanges(); @@ -106,6 +111,7 @@ describe('ManageRulesSmartComponent', () => { folderRulesService.deletedRuleId$ = of(null); folderRulesService.rulesListing$ = of([]); folderRulesService.loading$ = of(false); + actionsService.loading$ = of(false); fixture.detectChanges(); @@ -120,11 +126,12 @@ describe('ManageRulesSmartComponent', () => { expect(ruleDetails).toBeFalsy(); }); - it('should only show progress bar while loading', () => { + it('should only show progress bar while loading', async () => { folderRulesService.folderInfo$ = of(null); folderRulesService.deletedRuleId$ = of(null); folderRulesService.rulesListing$ = of([]); folderRulesService.loading$ = of(true); + actionsService.loading$ = of(true); fixture.detectChanges(); @@ -145,6 +152,7 @@ describe('ManageRulesSmartComponent', () => { folderRulesService.folderInfo$ = of(dummyNodeInfo); folderRulesService.rulesListing$ = of(dummyRules); folderRulesService.loading$ = of(false); + actionsService.loading$ = of(false); spyOn(component, 'onRuleDelete').and.callThrough(); diff --git a/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.ts b/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.ts index c7b5d4e28..88c02df32 100644 --- a/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.ts +++ b/projects/aca-folder-rules/src/lib/manage-rules/manage-rules.smart-component.ts @@ -30,11 +30,13 @@ import { Observable, Subscription } from 'rxjs'; import { Rule } from '../model/rule.model'; import { ActivatedRoute } from '@angular/router'; import { NodeInfo } from '@alfresco/aca-shared/store'; -import { tap } from 'rxjs/operators'; +import { delay, tap } from 'rxjs/operators'; import { EditRuleDialogSmartComponent } from '../rule-details/edit-rule-dialog.smart-component'; import { MatDialog } from '@angular/material/dialog'; import { ConfirmDialogComponent } from '@alfresco/adf-content-services'; import { NotificationService } from '@alfresco/adf-core'; +import { ActionDefinitionTransformed } from '../model/rule-action.model'; +import { ActionsService } from '../services/actions.service'; @Component({ selector: 'aca-manage-rules', @@ -45,8 +47,10 @@ import { NotificationService } from '@alfresco/adf-core'; }) export class ManageRulesSmartComponent implements OnInit, OnDestroy { rules$: Observable<Rule[]>; - isLoading$: Observable<boolean>; + rulesLoading$: Observable<boolean>; + actionsLoading$: Observable<boolean>; folderInfo$: Observable<NodeInfo>; + actionDefinitions$: Observable<ActionDefinitionTransformed[]>; selectedRule: Rule = null; nodeId: string = null; deletedRuleSubscription$: Subscription; @@ -57,10 +61,12 @@ export class ManageRulesSmartComponent implements OnInit, OnDestroy { private folderRulesService: FolderRulesService, private route: ActivatedRoute, private matDialogService: MatDialog, - private notificationService: NotificationService + private notificationService: NotificationService, + private actionsService: ActionsService ) {} ngOnInit(): void { + this.actionDefinitions$ = this.actionsService.actionDefinitionsListing$; this.rules$ = this.folderRulesService.rulesListing$.pipe( tap((rules) => { if (!rules.includes(this.selectedRule)) { @@ -73,8 +79,10 @@ export class ManageRulesSmartComponent implements OnInit, OnDestroy { this.folderRulesService.loadRules(this.nodeId); } }); - this.isLoading$ = this.folderRulesService.loading$; + this.rulesLoading$ = this.folderRulesService.loading$; + this.actionsLoading$ = this.actionsService.loading$.pipe(delay(0)); this.folderInfo$ = this.folderRulesService.folderInfo$; + this.actionsService.loadActionDefinitions(); this.route.params.subscribe((params) => { this.nodeId = params.nodeId; if (this.nodeId) { diff --git a/projects/aca-folder-rules/src/lib/mock/actions.mock.ts b/projects/aca-folder-rules/src/lib/mock/actions.mock.ts new file mode 100644 index 000000000..88a992c9d --- /dev/null +++ b/projects/aca-folder-rules/src/lib/mock/actions.mock.ts @@ -0,0 +1,108 @@ +/*! + * @license + * Alfresco Example Content Application + * + * Copyright (C) 2005 - 2020 Alfresco Software Limited + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see <http://www.gnu.org/licenses/>. + */ + +import { ActionDefinitionList } from '@alfresco/js-api'; +import { ActionDefinitionTransformed, ActionParameterDefinitionTransformed } from '../model/rule-action.model'; + +export const actionDefListMock: ActionDefinitionList = { + list: { + pagination: { + count: 2, + hasMoreItems: false, + totalItems: 2, + skipCount: 0, + maxItems: 100 + }, + entries: [ + { + applicableTypes: [], + parameterDefinitions: [ + { + name: 'mock-action-parameter-text', + type: 'd:text', + multiValued: false, + mandatory: false, + displayLabel: 'Mock action parameter text' + }, + { + name: 'mock-action-parameter-boolean', + type: 'd:boolean', + multiValued: false, + mandatory: false + } + ], + name: 'mock-action-1-definition', + trackStatus: false, + description: 'This is a mock action', + id: 'mock-action-1-definition', + title: 'Action 1 title' + }, + { + applicableTypes: [], + name: 'mock-action-2-definition', + trackStatus: false, + id: 'mock-action-2-definition' + } + ] + } +}; + +const actionParam1TransformedMock: ActionParameterDefinitionTransformed = { + name: 'mock-action-parameter-text', + type: 'd:text', + multiValued: false, + mandatory: false, + displayLabel: 'Mock action parameter text' +}; + +const actionParam2TransformedMock: ActionParameterDefinitionTransformed = { + name: 'mock-action-parameter-boolean', + type: 'd:boolean', + multiValued: false, + mandatory: false, + displayLabel: 'mock-action-parameter-boolean' +}; + +const action1TransformedMock: ActionDefinitionTransformed = { + id: 'mock-action-1-definition', + name: 'mock-action-1-definition', + description: 'This is a mock action', + title: 'Action 1 title', + applicableTypes: [], + trackStatus: false, + parameterDefinitions: [actionParam1TransformedMock, actionParam2TransformedMock] +}; + +const action2TransformedMock: ActionDefinitionTransformed = { + id: 'mock-action-2-definition', + name: 'mock-action-2-definition', + description: '', + title: 'mock-action-2-definition', + applicableTypes: [], + trackStatus: false, + parameterDefinitions: [] +}; + +export const actionsTransformedListMock: ActionDefinitionTransformed[] = [action1TransformedMock, action2TransformedMock]; diff --git a/projects/aca-folder-rules/src/lib/model/rule-action.model.ts b/projects/aca-folder-rules/src/lib/model/rule-action.model.ts index 1ba35ee4c..8fbeacaa9 100644 --- a/projects/aca-folder-rules/src/lib/model/rule-action.model.ts +++ b/projects/aca-folder-rules/src/lib/model/rule-action.model.ts @@ -25,11 +25,23 @@ export interface RuleAction { actionDefinitionId: string; - params: RuleActionParams; + params: { [key: string]: unknown }; } -export interface RuleActionParams { - 'deep-copy'?: boolean; - 'destination-folder'?: string; - actionContext?: string; +export interface ActionDefinitionTransformed { + id: string; + name: string; + description: string; + title: string; + applicableTypes: string[]; + trackStatus: boolean; + parameterDefinitions: ActionParameterDefinitionTransformed[]; +} + +export interface ActionParameterDefinitionTransformed { + name: string; + type: string; + multiValued: boolean; + mandatory: boolean; + displayLabel: string; } diff --git a/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.html b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.html new file mode 100644 index 000000000..462359061 --- /dev/null +++ b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.html @@ -0,0 +1,31 @@ +<div class="aca-rule-action-list__item " *ngFor="let control of formControls"> + <aca-rule-action + [actionDefinitions]="actionDefinitions" + [readOnly]="readOnly" + [formControl]="control"> + </aca-rule-action> + + <button + mat-icon-button + data-automation-id="rule-action-list-action-menu" + *ngIf="!readOnly" [matMenuTriggerFor]="menu"> + <mat-icon>more_vert</mat-icon> + </button> + + <mat-menu #menu> + <button + mat-menu-item + data-automation-id="rule-action-list-remove-action-button" + [title]="'ACA_FOLDER_RULES.RULE_DETAILS.ACTION_BUTTONS.REMOVE' | translate" + [disabled]="formArray.controls.length <= 1" + (click)="removeAction(control)"> + <mat-icon>delete</mat-icon> + <span>{{ 'ACA_FOLDER_RULES.RULE_DETAILS.ACTION_BUTTONS.REMOVE' | translate }}</span> + </button> + </mat-menu> +</div> + +<button mat-button data-automation-id="rule-action-list-add-action-button" (click)="addAction()" *ngIf="!readOnly"> + <mat-icon>add</mat-icon> + <span>{{ 'ACA_FOLDER_RULES.RULE_DETAILS.ACTION_BUTTONS.ADD_ACTION' | translate }}</span> +</button> diff --git a/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.scss b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.scss new file mode 100644 index 000000000..88bfc1c5a --- /dev/null +++ b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.scss @@ -0,0 +1,15 @@ +.aca-rule-action-list { + &__item { + padding: 4px 8px; + border-radius: 8px; + display: flex; + + & > .aca-rule-action { + flex: 1; + } + + &:nth-child(2n) { + background-color: hsl(0,0%,95%); + } + } +} diff --git a/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.spec.ts b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.spec.ts new file mode 100644 index 000000000..7a2f99223 --- /dev/null +++ b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.spec.ts @@ -0,0 +1,104 @@ +/*! + * @license + * Alfresco Example Content Application + * + * Copyright (C) 2005 - 2020 Alfresco Software Limited + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see <http://www.gnu.org/licenses/>. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CoreTestingModule } from '@alfresco/adf-core'; +import { RuleActionListUiComponent } from './rule-action-list.ui-component'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { RuleActionUiComponent } from './rule-action.ui-component'; + +describe('RuleActionListUiComponent', () => { + let fixture: ComponentFixture<RuleActionListUiComponent>; + let component: RuleActionListUiComponent; + + const getByDataAutomationId = (dataAutomationId: string, index = 0): DebugElement => + fixture.debugElement.queryAll(By.css(`[data-automation-id="${dataAutomationId}"]`))[index]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreTestingModule] + }); + + fixture = TestBed.createComponent(RuleActionListUiComponent); + component = fixture.componentInstance; + + component.writeValue([]); + fixture.detectChanges(); + }); + + it('should default to 1 empty action when an empty array of actions is written', () => { + const acaRuleActions = fixture.debugElement.queryAll(By.directive(RuleActionUiComponent)); + expect(acaRuleActions.length).toBe(1); + }); + + it('should add a new action when the "add action" button is clicked', () => { + const addActionButton = getByDataAutomationId('rule-action-list-add-action-button').nativeElement as HTMLButtonElement; + addActionButton.click(); + fixture.detectChanges(); + + const acaRuleActions = fixture.debugElement.queryAll(By.directive(RuleActionUiComponent)); + expect(acaRuleActions.length).toBe(2); + }); + + it('should disable the remove button if there is only one action', () => { + const menuButton = getByDataAutomationId('rule-action-list-action-menu', 0).nativeElement as HTMLButtonElement; + menuButton.click(); + fixture.detectChanges(); + + const removeActionButton = getByDataAutomationId('rule-action-list-remove-action-button').nativeElement as HTMLButtonElement; + expect(removeActionButton.disabled).toBeTrue(); + }); + + it('should enable the remove button if there is more than one action', () => { + const addActionButton = getByDataAutomationId('rule-action-list-add-action-button').nativeElement as HTMLButtonElement; + addActionButton.click(); + fixture.detectChanges(); + + const menuButton = getByDataAutomationId('rule-action-list-action-menu', 0).nativeElement as HTMLButtonElement; + menuButton.click(); + fixture.detectChanges(); + + const removeActionButton = getByDataAutomationId('rule-action-list-remove-action-button').nativeElement as HTMLButtonElement; + expect(removeActionButton.disabled).toBeFalse(); + }); + + it('should remove an action when the remove action button is clicked', () => { + const addActionButton = getByDataAutomationId('rule-action-list-add-action-button').nativeElement as HTMLButtonElement; + addActionButton.click(); + fixture.detectChanges(); + + const menuButton = getByDataAutomationId('rule-action-list-action-menu', 0).nativeElement as HTMLButtonElement; + menuButton.click(); + fixture.detectChanges(); + + const removeActionButton = getByDataAutomationId('rule-action-list-remove-action-button').nativeElement as HTMLButtonElement; + removeActionButton.click(); + fixture.detectChanges(); + + const acaRuleActions = fixture.debugElement.queryAll(By.directive(RuleActionUiComponent)); + expect(acaRuleActions.length).toBe(1); + }); +}); diff --git a/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.ts b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.ts new file mode 100644 index 000000000..129a03035 --- /dev/null +++ b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action-list.ui-component.ts @@ -0,0 +1,77 @@ +import { Component, forwardRef, Input, OnDestroy, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, FormArray, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ActionDefinitionTransformed, RuleAction } from '../../model/rule-action.model'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'aca-rule-action-list', + templateUrl: './rule-action-list.ui-component.html', + styleUrls: ['./rule-action-list.ui-component.scss'], + encapsulation: ViewEncapsulation.None, + host: { class: 'aca-rule-action-list' }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => RuleActionListUiComponent) + } + ] +}) +export class RuleActionListUiComponent implements ControlValueAccessor, OnDestroy { + @Input() + actionDefinitions: ActionDefinitionTransformed[] = []; + @Input() + readOnly = false; + + formArray = new FormArray([]); + private formArraySubscription: Subscription; + + get formControls(): FormControl[] { + return this.formArray.controls as FormControl[]; + } + + onChange: (actions: RuleAction[]) => void = () => undefined; + onTouch: () => void = () => undefined; + + writeValue(actions: RuleAction[]) { + if (actions.length === 0) { + actions = [ + { + actionDefinitionId: null, + params: {} + } + ]; + } + this.formArray = new FormArray(actions.map((action: RuleAction) => new FormControl(action))); + this.formArraySubscription?.unsubscribe(); + this.formArraySubscription = this.formArray.valueChanges.subscribe((value: any) => { + this.onChange(value); + this.onTouch(); + }); + } + + registerOnChange(fn: (actions: RuleAction[]) => void) { + this.onChange = fn; + } + + registerOnTouched(fn: () => void) { + this.onTouch = fn; + } + + addAction() { + const newAction: RuleAction = { + actionDefinitionId: null, + params: {} + }; + this.formArray.push(new FormControl(newAction)); + } + + ngOnDestroy() { + this.formArraySubscription?.unsubscribe(); + } + + removeAction(control: FormControl) { + const index = this.formArray.value.indexOf(control.value); + this.formArray.removeAt(index); + } +} diff --git a/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.html b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.html new file mode 100644 index 000000000..de1879088 --- /dev/null +++ b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.html @@ -0,0 +1,22 @@ +<form class="aca-rule-action__form" [formGroup]="form"> + + <mat-form-field> + <mat-select + formControlName="actionDefinitionId" + data-automation-id="rule-action-select" + [placeholder]="'ACA_FOLDER_RULES.RULE_DETAILS.ACTION_SELECT_PLACEHOLDER' | translate"> + <mat-option + *ngFor="let actionDefinition of actionDefinitions" + [value]="actionDefinition.id"> + {{ actionDefinition.title }} + </mat-option> + </mat-select> + </mat-form-field> + + <adf-card-view + data-automation-id="rule-action-card-view" + [properties]="cardViewItems" + [editable]="!readOnly"> + </adf-card-view> + +</form> diff --git a/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.scss b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.scss new file mode 100644 index 000000000..65fb675b2 --- /dev/null +++ b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.scss @@ -0,0 +1,7 @@ +.aca-rule-action { + &__form { + display: flex; + flex-direction: row; + gap: 20px; + } +} diff --git a/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.spec.ts b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.spec.ts new file mode 100644 index 000000000..e0c458db9 --- /dev/null +++ b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.spec.ts @@ -0,0 +1,87 @@ +/*! + * @license + * Alfresco Example Content Application + * + * Copyright (C) 2005 - 2020 Alfresco Software Limited + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see <http://www.gnu.org/licenses/>. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CardViewBoolItemModel, CardViewComponent, CardViewTextItemModel, CoreTestingModule } from '@alfresco/adf-core'; +import { RuleActionUiComponent } from './rule-action.ui-component'; +import { actionsTransformedListMock } from '../../mock/actions.mock'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +describe('RuleActionUiComponent', () => { + let fixture: ComponentFixture<RuleActionUiComponent>; + let component: RuleActionUiComponent; + + const getByDataAutomationId = (dataAutomationId: string): DebugElement => + fixture.debugElement.query(By.css(`[data-automation-id="${dataAutomationId}"]`)); + + const changeMatSelectValue = (dataAutomationId: string, value: string) => { + const matSelect = getByDataAutomationId(dataAutomationId).nativeElement; + matSelect.click(); + fixture.detectChanges(); + const matOption = fixture.debugElement.query(By.css(`.mat-option[ng-reflect-value="${value}"]`)).nativeElement; + matOption.click(); + fixture.detectChanges(); + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreTestingModule] + }); + + fixture = TestBed.createComponent(RuleActionUiComponent); + component = fixture.componentInstance; + }); + + it('should populate the dropdown selector with the action definitions', () => { + component.actionDefinitions = actionsTransformedListMock; + fixture.detectChanges(); + + const matSelect = getByDataAutomationId('rule-action-select').nativeElement; + matSelect.click(); + fixture.detectChanges(); + + const matOptions = fixture.debugElement.queryAll(By.css(`mat-option`)); + expect(matOptions.length).toBe(2); + expect(matOptions[0].nativeElement.innerText).toBe('Action 1 title'); + expect(matOptions[1].nativeElement.innerText).toBe('mock-action-2-definition'); + }); + + it('should populate the card view with parameters when an action is selected', () => { + component.actionDefinitions = actionsTransformedListMock; + fixture.detectChanges(); + + const cardView = getByDataAutomationId('rule-action-card-view').componentInstance as CardViewComponent; + expect(cardView.properties.length).toBe(0); + + changeMatSelectValue('rule-action-select', 'mock-action-1-definition'); + expect(cardView.properties.length).toBe(2); + expect(cardView.properties[0]).toBeInstanceOf(CardViewTextItemModel); + expect(cardView.properties[1]).toBeInstanceOf(CardViewBoolItemModel); + + changeMatSelectValue('rule-action-select', 'mock-action-2-definition'); + expect(cardView.properties.length).toBe(0); + }); +}); diff --git a/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.ts b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.ts new file mode 100644 index 000000000..cfea506dc --- /dev/null +++ b/projects/aca-folder-rules/src/lib/rule-details/actions/rule-action.ui-component.ts @@ -0,0 +1,153 @@ +import { Component, forwardRef, Input, OnDestroy, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, FormControl, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ActionDefinitionTransformed, RuleAction } from '../../model/rule-action.model'; +import { CardViewItem } from '@alfresco/adf-core/lib/card-view/interfaces/card-view-item.interface'; +import { CardViewBoolItemModel, CardViewTextItemModel, CardViewUpdateService, UpdateNotification } from '@alfresco/adf-core'; +import { ActionParameterDefinition } from '@alfresco/js-api'; + +@Component({ + selector: 'aca-rule-action', + templateUrl: './rule-action.ui-component.html', + styleUrls: ['./rule-action.ui-component.scss'], + encapsulation: ViewEncapsulation.None, + host: { class: 'aca-rule-action' }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => RuleActionUiComponent) + }, + CardViewUpdateService + ] +}) +export class RuleActionUiComponent implements ControlValueAccessor, OnDestroy { + private _actionDefinitions: ActionDefinitionTransformed[]; + @Input() + get actionDefinitions(): ActionDefinitionTransformed[] { + return this._actionDefinitions; + } + set actionDefinitions(value: ActionDefinitionTransformed[]) { + this._actionDefinitions = value.sort((a, b) => a.title.localeCompare(b.title)); + } + + private _readOnly = false; + @Input() + get readOnly(): boolean { + return this._readOnly; + } + set readOnly(isReadOnly: boolean) { + this.setDisabledState(isReadOnly); + } + + form = new FormGroup({ + actionDefinitionId: new FormControl('copy') + }); + + cardViewItems: CardViewItem[] = []; + + parameters: { [key: string]: unknown } = {}; + + get selectedActionDefinitionId(): string { + return this.form.get('actionDefinitionId').value; + } + + get selectedActionDefinition(): ActionDefinitionTransformed { + return this.actionDefinitions.find((actionDefinition: ActionDefinitionTransformed) => actionDefinition.id === this.selectedActionDefinitionId); + } + + private formSubscription = this.form.valueChanges.subscribe(() => { + this.setDefaultParameters(); + this.setCardViewProperties(); + this.onChange({ + actionDefinitionId: this.selectedActionDefinitionId, + params: this.parameters + }); + this.onTouch(); + }); + + private updateServiceSubscription = this.cardViewUpdateService.itemUpdated$.subscribe((updateNotification: UpdateNotification) => { + this.parameters = { + ...this.parameters, + ...updateNotification.changed + }; + this.onChange({ + actionDefinitionId: this.selectedActionDefinitionId, + params: this.parameters + }); + this.onTouch(); + }); + + onChange: (action: RuleAction) => void = () => undefined; + onTouch: () => void = () => undefined; + + constructor(private cardViewUpdateService: CardViewUpdateService) {} + + writeValue(action: RuleAction) { + this.form.setValue({ + actionDefinitionId: action.actionDefinitionId + }); + this.parameters = { + ...this.parameters, + ...action.params + }; + this.setCardViewProperties(); + } + + registerOnChange(fn: (action: RuleAction) => void) { + this.onChange = fn; + } + + registerOnTouched(fn: any) { + this.onTouch = fn; + } + + ngOnDestroy() { + this.formSubscription.unsubscribe(); + this.updateServiceSubscription.unsubscribe(); + } + + setCardViewProperties() { + this.cardViewItems = (this.selectedActionDefinition?.parameterDefinitions ?? []).map((paramDef) => { + const cardViewPropertiesModel = { + label: paramDef.displayLabel, + key: paramDef.name, + editable: true + }; + switch (paramDef.type) { + case 'd:boolean': + return new CardViewBoolItemModel({ + ...cardViewPropertiesModel, + value: this.parameters[paramDef.name] ?? false + }); + default: + return new CardViewTextItemModel({ + ...cardViewPropertiesModel, + value: this.parameters[paramDef.name] ?? '' + }); + } + }); + } + + setDefaultParameters() { + this.parameters = {}; + (this.selectedActionDefinition?.parameterDefinitions ?? []).forEach((paramDef: ActionParameterDefinition) => { + switch (paramDef.type) { + case 'd:boolean': + this.parameters[paramDef.name] = false; + break; + default: + this.parameters[paramDef.name] = ''; + } + }); + } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this._readOnly = true; + this.form.disable(); + } else { + this._readOnly = false; + this.form.enable(); + } + } +} diff --git a/projects/aca-folder-rules/src/lib/rule-details/conditions/rule-composite-condition.ui-component.html b/projects/aca-folder-rules/src/lib/rule-details/conditions/rule-composite-condition.ui-component.html index 32a4ec642..2f022ec24 100644 --- a/projects/aca-folder-rules/src/lib/rule-details/conditions/rule-composite-condition.ui-component.html +++ b/projects/aca-folder-rules/src/lib/rule-details/conditions/rule-composite-condition.ui-component.html @@ -48,10 +48,10 @@ <mat-menu #menu="matMenu"> <button mat-menu-item - [title]="'ACA_FOLDER_RULES.RULE_DETAILS.ACTIONS.REMOVE' | translate" + [title]="'ACA_FOLDER_RULES.RULE_DETAILS.CONDITION_BUTTONS.REMOVE' | translate" (click)="removeCondition(control)"> <mat-icon>delete</mat-icon> - <span>{{ 'ACA_FOLDER_RULES.RULE_DETAILS.ACTIONS.REMOVE' | translate }}</span> + <span>{{ 'ACA_FOLDER_RULES.RULE_DETAILS.CONDITION_BUTTONS.REMOVE' | translate }}</span> </button> </mat-menu> @@ -60,11 +60,11 @@ <div class="aca-rule-composite-condition__form__actions" *ngIf="!readOnly" data-automation-id="add-actions"> <button mat-button (click)="addSimpleCondition()" data-automation-id="add-condition-button"> <mat-icon>add</mat-icon> - <span>{{ 'ACA_FOLDER_RULES.RULE_DETAILS.ACTIONS.ADD_CONDITION' | translate }}</span> + <span>{{ 'ACA_FOLDER_RULES.RULE_DETAILS.CONDITION_BUTTONS.ADD_CONDITION' | translate }}</span> </button> <button mat-button (click)="addCompositeCondition()" data-automation-id="add-group-button"> <mat-icon>add</mat-icon> - <span>{{ 'ACA_FOLDER_RULES.RULE_DETAILS.ACTIONS.ADD_GROUP' | translate }}</span> + <span>{{ 'ACA_FOLDER_RULES.RULE_DETAILS.CONDITION_BUTTONS.ADD_GROUP' | translate }}</span> </button> </div> </form> diff --git a/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.html b/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.html index b6f2c9a41..c6a1f3134 100644 --- a/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.html +++ b/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.html @@ -8,7 +8,21 @@ </div> <mat-dialog-content class="aca-edit-rule-dialog__content"> - <aca-rule-details (formValidationChanged)="formValid = $event" (formValueChanged)="formValue = $event" [value]="model"></aca-rule-details> + <div class="aca-edit-rule-dialog__content__spinner" *ngIf="loading$ | async; else ruleDetails"> + <mat-progress-spinner + color="primary" + mode="indeterminate"> + </mat-progress-spinner> + </div> + + <ng-template #ruleDetails> + <aca-rule-details + [actionDefinitions]="actionDefinitions$ | async" + [value]="model" + (formValueChanged)="formValue = $event" + (formValidationChanged)="formValid = $event"> + </aca-rule-details> + </ng-template> </mat-dialog-content> <mat-dialog-actions align="end" class="aca-edit-rule-dialog__footer"> diff --git a/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.scss b/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.scss index 2c78b0b0e..34344e4f8 100644 --- a/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.scss +++ b/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.scss @@ -31,6 +31,13 @@ &__content { margin: 0; padding: 0; + + &__spinner { + display: flex; + align-items: center; + justify-content: center; + margin: 20px 0 + } } &__footer { diff --git a/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.spec.ts b/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.spec.ts index 650495ace..9e6c8bdb7 100644 --- a/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.spec.ts +++ b/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.spec.ts @@ -31,9 +31,14 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { CoreTestingModule } from '@alfresco/adf-core'; import { RuleCompositeConditionUiComponent } from './conditions/rule-composite-condition.ui-component'; import { RuleTriggersUiComponent } from './triggers/rule-triggers.ui-component'; +import { RuleActionListUiComponent } from './actions/rule-action-list.ui-component'; +import { RuleActionUiComponent } from './actions/rule-action.ui-component'; +import { ActionsService } from '../services/actions.service'; +import { RuleOptionsUiComponent } from './options/rule-options.ui-component'; describe('EditRuleDialogComponent', () => { let fixture: ComponentFixture<EditRuleDialogSmartComponent>; + let actionsService: ActionsService; const dialogRef = { close: jasmine.createSpy('close'), @@ -43,14 +48,26 @@ describe('EditRuleDialogComponent', () => { const setupBeforeEach = (dialogOptions: EditRuleDialogOptions = {}) => { TestBed.configureTestingModule({ imports: [CoreTestingModule], - declarations: [EditRuleDialogSmartComponent, RuleCompositeConditionUiComponent, RuleDetailsUiComponent, RuleTriggersUiComponent], + declarations: [ + EditRuleDialogSmartComponent, + RuleCompositeConditionUiComponent, + RuleDetailsUiComponent, + RuleTriggersUiComponent, + RuleActionListUiComponent, + RuleActionUiComponent, + RuleOptionsUiComponent + ], providers: [ { provide: MatDialogRef, useValue: dialogRef }, { provide: MAT_DIALOG_DATA, useValue: dialogOptions } ] }); + actionsService = TestBed.inject(ActionsService); + spyOn(actionsService, 'loadActionDefinitions').and.stub(); + fixture = TestBed.createComponent(EditRuleDialogSmartComponent); + fixture.detectChanges(); }; describe('No dialog options passed / indifferent', () => { @@ -59,7 +76,6 @@ describe('EditRuleDialogComponent', () => { }); it('should activate the submit button only when a valid state is received', () => { - fixture.detectChanges(); const submitButton = fixture.debugElement.query(By.css('[data-automation-id="edit-rule-dialog-submit"]')).nativeElement as HTMLButtonElement; const ruleDetails = fixture.debugElement.query(By.directive(RuleDetailsUiComponent)).componentInstance as RuleDetailsUiComponent; ruleDetails.formValidationChanged.emit(true); @@ -73,14 +89,12 @@ describe('EditRuleDialogComponent', () => { }); it('should show a "create" label in the title', () => { - fixture.detectChanges(); const titleElement = fixture.debugElement.query(By.css('[data-automation-id="edit-rule-dialog-title"]')).nativeElement as HTMLDivElement; expect(titleElement.innerText.trim()).toBe('ACA_FOLDER_RULES.EDIT_RULE_DIALOG.CREATE_TITLE'); }); it('should show a "create" label in the submit button', () => { - fixture.detectChanges(); const titleElement = fixture.debugElement.query(By.css('[data-automation-id="edit-rule-dialog-submit"]')).nativeElement as HTMLButtonElement; expect(titleElement.innerText.trim()).toBe('ACA_FOLDER_RULES.EDIT_RULE_DIALOG.CREATE'); @@ -90,9 +104,7 @@ describe('EditRuleDialogComponent', () => { describe('With dialog options passed', () => { const dialogOptions: EditRuleDialogOptions = { model: { - id: 'rule-id', - name: 'Rule name', - description: 'This is the description of the rule' + id: 'rule-id' } }; @@ -101,14 +113,12 @@ describe('EditRuleDialogComponent', () => { }); it('should show an "update" label in the title', () => { - fixture.detectChanges(); const titleElement = fixture.debugElement.query(By.css('[data-automation-id="edit-rule-dialog-title"]')).nativeElement as HTMLDivElement; expect(titleElement.innerText.trim()).toBe('ACA_FOLDER_RULES.EDIT_RULE_DIALOG.UPDATE_TITLE'); }); it('should show an "update" label in the submit button', () => { - fixture.detectChanges(); const titleElement = fixture.debugElement.query(By.css('[data-automation-id="edit-rule-dialog-submit"]')).nativeElement as HTMLButtonElement; expect(titleElement.innerText.trim()).toBe('ACA_FOLDER_RULES.EDIT_RULE_DIALOG.UPDATE'); diff --git a/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.ts b/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.ts index 9eeb06541..9fb69a8d4 100644 --- a/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.ts +++ b/projects/aca-folder-rules/src/lib/rule-details/edit-rule-dialog.smart-component.ts @@ -23,9 +23,10 @@ * along with Alfresco. If not, see <http://www.gnu.org/licenses/>. */ -import { Component, EventEmitter, Inject, Output, ViewEncapsulation } from '@angular/core'; +import { Component, EventEmitter, Inject, OnInit, Output, ViewEncapsulation } from '@angular/core'; import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { Rule } from '../model/rule.model'; +import { ActionsService } from '../services/actions.service'; export interface EditRuleDialogOptions { model?: Partial<Rule>; @@ -38,13 +39,15 @@ export interface EditRuleDialogOptions { encapsulation: ViewEncapsulation.None, host: { class: 'aca-edit-rule-dialog' } }) -export class EditRuleDialogSmartComponent { +export class EditRuleDialogSmartComponent implements OnInit { formValid = false; model: Partial<Rule>; formValue: Partial<Rule>; @Output() submitted = new EventEmitter<Partial<Rule>>(); + actionDefinitions$ = this.actionsService.actionDefinitionsListing$; + loading$ = this.actionsService.loading$; - constructor(@Inject(MAT_DIALOG_DATA) public options: EditRuleDialogOptions) { + constructor(@Inject(MAT_DIALOG_DATA) public options: EditRuleDialogOptions, private actionsService: ActionsService) { this.model = this.options?.model || {}; } @@ -63,4 +66,8 @@ export class EditRuleDialogSmartComponent { onSubmit() { this.submitted.emit(this.formValue); } + + ngOnInit() { + this.actionsService.loadActionDefinitions(); + } } diff --git a/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.html b/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.html index bc165dfee..5906c9c08 100644 --- a/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.html +++ b/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.html @@ -45,6 +45,17 @@ <hr> + <div class="aca-rule-details__form__row aca-rule-details__form__actions"> + <div class="label">{{ 'ACA_FOLDER_RULES.RULE_DETAILS.LABEL.PERFORM_ACTIONS' | translate }}</div> + <aca-rule-action-list + formControlName="actions" + [actionDefinitions]="actionDefinitions" + [readOnly]="readOnly"> + </aca-rule-action-list> + </div> + + <hr> + <div class="aca-rule-details__form__row"> <div class="label">{{ 'ACA_FOLDER_RULES.RULE_DETAILS.LABEL.OPTIONS' | translate }}</div> <aca-rule-options [form]="form" [preview]="preview" data-automation-id="rule-details-options-component"></aca-rule-options> diff --git a/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.scss b/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.scss index a2788dc35..fff9c0de2 100644 --- a/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.scss +++ b/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.scss @@ -58,5 +58,11 @@ margin-left: 16px; color: inherit; } + + &__actions { + .aca-rule-action-list { + flex: 1; + } + } } } diff --git a/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.spec.ts b/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.spec.ts index 839aa249e..1cafd93d6 100644 --- a/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.spec.ts +++ b/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.spec.ts @@ -32,6 +32,8 @@ import { RuleCompositeConditionUiComponent } from './conditions/rule-composite-c import { RuleTriggersUiComponent } from './triggers/rule-triggers.ui-component'; import { MatCheckbox } from '@angular/material/checkbox'; import { RuleOptionsUiComponent } from './options/rule-options.ui-component'; +import { RuleActionListUiComponent } from './actions/rule-action-list.ui-component'; +import { RuleActionUiComponent } from './actions/rule-action.ui-component'; describe('RuleDetailsUiComponent', () => { let fixture: ComponentFixture<RuleDetailsUiComponent>; @@ -56,7 +58,14 @@ describe('RuleDetailsUiComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [CoreTestingModule], - declarations: [RuleCompositeConditionUiComponent, RuleDetailsUiComponent, RuleTriggersUiComponent, RuleOptionsUiComponent] + declarations: [ + RuleCompositeConditionUiComponent, + RuleDetailsUiComponent, + RuleTriggersUiComponent, + RuleOptionsUiComponent, + RuleActionListUiComponent, + RuleActionUiComponent + ] }); fixture = TestBed.createComponent(RuleDetailsUiComponent); diff --git a/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.ts b/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.ts index 7cd0db06d..94b7ae8e6 100644 --- a/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.ts +++ b/projects/aca-folder-rules/src/lib/rule-details/rule-details.ui-component.ts @@ -30,6 +30,7 @@ import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators'; import { Rule } from '../model/rule.model'; import { ruleCompositeConditionValidator } from './validators/rule-composite-condition.validator'; import { FolderRulesService } from '../services/folder-rules.service'; +import { ActionDefinitionTransformed } from '../model/rule-action.model'; @Component({ selector: 'aca-rule-details', @@ -68,7 +69,8 @@ export class RuleDetailsUiComponent implements OnInit, OnDestroy { isAsynchronous: newValue.isAsynchronous || FolderRulesService.emptyRule.isAsynchronous, errorScript: newValue.errorScript || FolderRulesService.emptyRule.errorScript, isInheritable: newValue.isInheritable || FolderRulesService.emptyRule.isInheritable, - isEnabled: newValue.isEnabled || FolderRulesService.emptyRule.isEnabled + isEnabled: newValue.isEnabled || FolderRulesService.emptyRule.isEnabled, + actions: newValue.actions || FolderRulesService.emptyRule.actions }; if (this.form) { this.form.setValue(newValue); @@ -78,6 +80,8 @@ export class RuleDetailsUiComponent implements OnInit, OnDestroy { } @Input() preview: boolean; + @Input() + actionDefinitions: ActionDefinitionTransformed[] = []; @Output() formValidationChanged = new EventEmitter<boolean>(); @@ -129,7 +133,8 @@ export class RuleDetailsUiComponent implements OnInit, OnDestroy { isAsynchronous: new UntypedFormControl(this.value.isAsynchronous), errorScript: new UntypedFormControl(this.value.errorScript), isInheritable: new UntypedFormControl(this.value.isInheritable), - isEnabled: new UntypedFormControl(this.value.isEnabled) + isEnabled: new UntypedFormControl(this.value.isEnabled), + actions: new UntypedFormControl(this.value.actions) }); this.readOnly = this._readOnly; diff --git a/projects/aca-folder-rules/src/lib/services/actions.service.spec.ts b/projects/aca-folder-rules/src/lib/services/actions.service.spec.ts new file mode 100644 index 000000000..302f5c465 --- /dev/null +++ b/projects/aca-folder-rules/src/lib/services/actions.service.spec.ts @@ -0,0 +1,65 @@ +/*! + * @license + * Alfresco Example Content Application + * + * Copyright (C) 2005 - 2020 Alfresco Software Limited + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see <http://www.gnu.org/licenses/>. + */ + +import { ActionsService } from './actions.service'; +import { TestBed } from '@angular/core/testing'; +import { CoreTestingModule } from '@alfresco/adf-core'; +import { ActionsApi } from '@alfresco/js-api'; +import { actionDefListMock, actionsTransformedListMock } from '../mock/actions.mock'; +import { take } from 'rxjs/operators'; + +describe('ActionsService', () => { + let actionsService: ActionsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreTestingModule], + providers: [ActionsService] + }); + + actionsService = TestBed.inject(ActionsService); + }); + + it('should load the data into the observable', async () => { + spyOn(ActionsApi.prototype, 'listActions').and.returnValue(Promise.resolve(actionDefListMock)); + const actionsPromise = actionsService.actionDefinitionsListing$.pipe(take(2)).toPromise(); + + actionsService.loadActionDefinitions(); + + const actionsList = await actionsPromise; + expect(actionsList).toEqual(actionsTransformedListMock); + }); + + it('should set loading to true while the request is being sent', async () => { + spyOn(ActionsApi.prototype, 'listActions').and.returnValue(Promise.resolve(actionDefListMock)); + const loadingTruePromise = actionsService.loading$.pipe(take(2)).toPromise(); + const loadingFalsePromise = actionsService.loading$.pipe(take(3)).toPromise(); + + actionsService.loadActionDefinitions(); + + expect(await loadingTruePromise).toBeTrue(); + expect(await loadingFalsePromise).toBeFalse(); + }); +}); diff --git a/projects/aca-folder-rules/src/lib/services/actions.service.ts b/projects/aca-folder-rules/src/lib/services/actions.service.ts new file mode 100644 index 000000000..8294b1324 --- /dev/null +++ b/projects/aca-folder-rules/src/lib/services/actions.service.ts @@ -0,0 +1,92 @@ +/*! + * @license + * Alfresco Example Content Application + * + * Copyright (C) 2005 - 2020 Alfresco Software Limited + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see <http://www.gnu.org/licenses/>. + */ + +import { Injectable } from '@angular/core'; +import { ActionDefinition, ActionDefinitionEntry, ActionDefinitionList, ActionParameterDefinition, ActionsApi } from '@alfresco/js-api'; +import { AlfrescoApiService } from '@alfresco/adf-core'; +import { BehaviorSubject, from } from 'rxjs'; +import { ActionDefinitionTransformed, ActionParameterDefinitionTransformed } from '../model/rule-action.model'; +import { finalize, map } from 'rxjs/operators'; + +@Injectable({ providedIn: 'root' }) +export class ActionsService { + private actionDefinitionsListingSource = new BehaviorSubject<ActionDefinitionTransformed[]>([]); + actionDefinitionsListing$ = this.actionDefinitionsListingSource.asObservable(); + private loadingSource = new BehaviorSubject<boolean>(false); + loading$ = this.loadingSource.asObservable(); + + private _actionsApi: ActionsApi; + get actionsApi(): ActionsApi { + if (!this._actionsApi) { + this._actionsApi = new ActionsApi(this.apiService.getInstance()); + } + return this._actionsApi; + } + + constructor(private apiService: AlfrescoApiService) {} + + loadActionDefinitions() { + this.loadingSource.next(true); + from(this.actionsApi.listActions()) + .pipe( + map((list: ActionDefinitionList) => list.list.entries.map((entry) => this.transformActionDefinition(entry))), + finalize(() => this.loadingSource.next(false)) + ) + .subscribe((obj: ActionDefinitionTransformed[]) => { + this.actionDefinitionsListingSource.next(obj); + }); + } + + private transformActionDefinition(obj: ActionDefinition | ActionDefinitionEntry): ActionDefinitionTransformed { + if (this.isActionDefinitionEntry(obj)) { + obj = obj.entry; + } + return { + id: obj.id, + name: obj.name ?? '', + description: obj.description ?? '', + title: obj.title ?? obj.name ?? '', + applicableTypes: obj.applicableTypes ?? [], + trackStatus: obj.trackStatus ?? false, + parameterDefinitions: (obj.parameterDefinitions ?? []).map((paramDef: ActionParameterDefinition) => + this.transformActionParameterDefinition(paramDef) + ) + }; + } + + private transformActionParameterDefinition(obj: ActionParameterDefinition): ActionParameterDefinitionTransformed { + return { + name: obj.name ?? '', + type: obj.type ?? '', + multiValued: obj.multiValued ?? false, + mandatory: obj.mandatory ?? false, + displayLabel: obj.displayLabel ?? obj.name ?? '' + }; + } + + private isActionDefinitionEntry(obj): obj is ActionDefinitionEntry { + return typeof obj.entry !== 'undefined'; + } +} diff --git a/projects/aca-folder-rules/src/lib/services/folder-rules.service.spec.ts b/projects/aca-folder-rules/src/lib/services/folder-rules.service.spec.ts index 87f33740a..fa99c3f79 100644 --- a/projects/aca-folder-rules/src/lib/services/folder-rules.service.spec.ts +++ b/projects/aca-folder-rules/src/lib/services/folder-rules.service.spec.ts @@ -146,11 +146,9 @@ describe('FolderRulesService', () => { .and.returnValue(Promise.resolve(dummyRules[0])); }); - it('should send correct POST request and return created rule', function () { - folderRulesService.createRule(nodeId, dummyRules[0]).then((result) => { - expect(folderRulesService.createRule).toHaveBeenCalledWith(nodeId, dummyRules[0]); - expect(result).toEqual(dummyRules[0]); - }); + it('should send correct POST request and return created rule', async () => { + const result = await folderRulesService.createRule(nodeId, dummyRules[0]); + expect(result).toEqual(dummyRules[0]); }); }); });