Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
* Github: [PR-578](https://github.com/pegasystems/angular-sdk-components/pull/578)
* **Added left and right alignment support for vertical multi-step assignment navigation.**
* Github: [PR-576](https://github.com/pegasystems/angular-sdk-components/pull/576)
* **Added support for Data Object actions in the case view, and Submit/Cancel controls in the Data Object modal.**
* Github: [PR-577](https://github.com/pegasystems/angular-sdk-components/pull/577)

### **Bug fixes**
* **Fixed DataReference not making an api call on state change.**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { DeferLoadComponent } from '../../_components/infra/defer-load/defer-loa
import { ErrorBoundaryComponent } from '../../_components/infra/error-boundary/error-boundary.component';
import { FlowContainerComponent } from '../../_components/infra/Containers/flow-container/flow-container.component';
import { ModalViewContainerComponent } from '../../_components/infra/Containers/modal-view-container/modal-view-container.component';
import { DataViewActionButtonsComponent } from '../../_components/infra/Containers/modal-view-container/data-view-action-buttons/data-view-action-buttons.component';
import { MultiStepComponent } from '../../_components/infra/multi-step/multi-step.component';
import { NavbarComponent } from '../../_components/infra/navbar/navbar.component';
import { ReferenceComponent } from '../../_components/infra/reference/reference.component';
Expand Down Expand Up @@ -161,6 +162,7 @@ const pegaSdkComponentMap = {
Currency: CurrencyComponent,
DashboardFilter: DashboardFilterComponent,
DataReference: DataReferenceComponent,
DataViewActionButtons: DataViewActionButtonsComponent,
Date: DateComponent,
DateTime: DateTimeComponent,
Decimal: DecimalComponent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<mat-grid-list cols="2" rowHeight="4.25rem">
<mat-grid-tile>
<button mat-raised-button variant="contained" color="secondary" [disabled]="bDisabled$" (click)="onCancel()">
{{ localizedVal('Cancel', localeCategory) }}
</button>
</mat-grid-tile>
<mat-grid-tile>
<button mat-raised-button variant="contained" color="primary" [disabled]="bDisabled$" (click)="onSubmit()">
{{ localizedVal(primaryLabel$, localeCategory) }}
</button>
</mat-grid-tile>
</mat-grid-list>
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { DataViewActionButtonsComponent } from './data-view-action-buttons.component';

const RESOURCE_STATUS = { CREATE: 'CREATE', UPDATE: 'UPDATE', OPEN_FLOW_ACTION: 'OPEN_FLOW_ACTION' };
const DATA_EVENTS = { DATA_OBJECT_CREATED: 'DataObjectCreated', DATA_OBJECT_UPDATED: 'DataObjectUpdated' };

describe('DataViewActionButtonsComponent', () => {
let component: DataViewActionButtonsComponent;
let fixture: ComponentFixture<DataViewActionButtonsComponent>;
let actionsApi: {
createDataObject: jasmine.Spy;
updateDataObject: jasmine.Spy;
submitDataObjectAction: jasmine.Spy;
cancelDataObject: jasmine.Spy;
};
let publishSpy: jasmine.Spy;

beforeEach(async () => {
publishSpy = jasmine.createSpy('publish');
// PCore is read in a field initializer, so it must exist before the component is created.
(globalThis as any).PCore = {
getLocaleUtils: () => ({ getLocaleValue: (value: string) => value }),
getConstants: () => ({ RESOURCE_STATUS, PUB_SUB_EVENTS: { DATA_EVENTS } }),
getPubSubUtils: () => ({ publish: publishSpy })
};

await TestBed.configureTestingModule({
imports: [DataViewActionButtonsComponent],
providers: [provideZonelessChangeDetection()]
}).compileComponents();

fixture = TestBed.createComponent(DataViewActionButtonsComponent);
component = fixture.componentInstance;

actionsApi = {
createDataObject: jasmine.createSpy('createDataObject').and.returnValue(Promise.resolve()),
updateDataObject: jasmine.createSpy('updateDataObject').and.returnValue(Promise.resolve()),
submitDataObjectAction: jasmine.createSpy('submitDataObjectAction').and.returnValue(Promise.resolve()),
cancelDataObject: jasmine.createSpy('cancelDataObject').and.returnValue({})
};

component.pConn$ = { getActionsApi: () => actionsApi } as any;
component.context$ = 'app/modal_1';
component.dataRecordKeys$ = '{"pyGUID":"abc-123"}';
component.classID$ = 'My-Data-Class';
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('labels the primary button "Update" when editing an existing record', () => {
component.dataObjectAction$ = RESOURCE_STATUS.UPDATE;
expect(component.primaryLabel$).toBe('Update');
});

it('labels the primary button "Submit" when creating a record', () => {
component.dataObjectAction$ = RESOURCE_STATUS.CREATE;
expect(component.primaryLabel$).toBe('Submit');
});

it('labels the primary button "Submit" for a record action', () => {
component.dataObjectAction$ = RESOURCE_STATUS.OPEN_FLOW_ACTION;
expect(component.primaryLabel$).toBe('Submit');
});

it('creates the record and publishes the created event', async () => {
component.dataObjectAction$ = RESOURCE_STATUS.CREATE;

component.onSubmit();
await fixture.whenStable();

expect(actionsApi.createDataObject).toHaveBeenCalledWith('app/modal_1');
expect(publishSpy).toHaveBeenCalledWith(DATA_EVENTS.DATA_OBJECT_CREATED, { classId: 'My-Data-Class', data: undefined });
});

it('parses the serialised record keys before updating', async () => {
component.dataObjectAction$ = RESOURCE_STATUS.UPDATE;

component.onSubmit();
await fixture.whenStable();

expect(actionsApi.updateDataObject).toHaveBeenCalledWith('app/modal_1', { pyGUID: 'abc-123' });
expect(publishSpy).toHaveBeenCalledWith(DATA_EVENTS.DATA_OBJECT_UPDATED, { classId: 'My-Data-Class' });
});

it('submits a record action with its parsed keys and action ID', async () => {
component.dataObjectAction$ = RESOURCE_STATUS.OPEN_FLOW_ACTION;
component.actionID$ = 'updateCVV';

component.onSubmit();
await fixture.whenStable();

expect(actionsApi.submitDataObjectAction).toHaveBeenCalledWith('app/modal_1', { pyGUID: 'abc-123' }, 'updateCVV');
});

it('disables both buttons while a save is in flight', () => {
let resolveSave: () => void = () => {};
actionsApi.createDataObject.and.returnValue(
new Promise<void>(resolve => {
resolveSave = resolve;
})
);
component.dataObjectAction$ = RESOURCE_STATUS.CREATE;

component.onSubmit();

expect(component.bDisabled$).toBeTrue();
resolveSave();
});

it('re-enables the buttons and publishes nothing when the save is rejected', async () => {
actionsApi.createDataObject.and.returnValue(Promise.reject(new Error('rejected')));
component.dataObjectAction$ = RESOURCE_STATUS.CREATE;

component.onSubmit();
await fixture.whenStable();

expect(publishSpy).not.toHaveBeenCalled();
expect(component.bDisabled$).toBeFalse();
});

it('discards the edit through the engine on cancel', () => {
component.onCancel();

expect(actionsApi.cancelDataObject).toHaveBeenCalledWith('app/modal_1');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, Input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatGridListModule } from '@angular/material/grid-list';

@Component({
selector: 'app-data-view-action-buttons',
templateUrl: './data-view-action-buttons.component.html',
imports: [CommonModule, MatGridListModule, MatButtonModule]
})
export class DataViewActionButtonsComponent {
@Input() pConn$: typeof PConnect;
@Input() context$: string;
@Input() dataObjectAction$: string;
@Input() actionID$ = '';
// The container item publishes the record keys JSON-serialised.
@Input() dataRecordKeys$ = '';
@Input() classID$ = '';

localizedVal = PCore.getLocaleUtils().getLocaleValue;
localeCategory = 'Data Object';
bDisabled$ = false;

constructor(private cdRef: ChangeDetectorRef) {}

get primaryLabel$(): string {
return this.dataObjectAction$ === PCore.getConstants().RESOURCE_STATUS.UPDATE ? 'Update' : 'Submit';
}

onCancel() {
// The engine drops the container item, which collapses the modal.
this.pConn$.getActionsApi().cancelDataObject(this.context$);
}

onSubmit() {
const { RESOURCE_STATUS, PUB_SUB_EVENTS } = PCore.getConstants();
const { DATA_OBJECT_CREATED, DATA_OBJECT_UPDATED } = PUB_SUB_EVENTS.DATA_EVENTS;
const actionsApi = this.pConn$.getActionsApi();
const { publish } = PCore.getPubSubUtils();

let submitAction: Promise<any>;
let publishCompleted: (data?: any) => void;

switch (this.dataObjectAction$) {
case RESOURCE_STATUS.UPDATE:
submitAction = actionsApi.updateDataObject(this.context$, this.getRecordKeys());
publishCompleted = () => publish(DATA_OBJECT_UPDATED, { classId: this.classID$ });
break;
case RESOURCE_STATUS.OPEN_FLOW_ACTION:
submitAction = actionsApi.submitDataObjectAction(this.context$, this.getRecordKeys(), this.actionID$);
publishCompleted = () => publish(DATA_OBJECT_UPDATED, { classId: this.classID$, actionID: this.actionID$ });
break;
default:
submitAction = actionsApi.createDataObject(this.context$);
publishCompleted = data => publish(DATA_OBJECT_CREATED, { classId: this.classID$, data });
}

this.bDisabled$ = true;

submitAction
.then(data => {
publishCompleted(data);
})
.catch(() => {
// Keep the modal open so the server error banner is visible and the edit can be corrected.
})
.finally(() => {
this.bDisabled$ = false;
// Change detection is zoneless, so this async reset needs to be flagged explicitly.
this.cdRef.markForCheck();
});
}

private getRecordKeys(): Record<string, any> {
return this.dataRecordKeys$ ? JSON.parse(this.dataRecordKeys$) : {};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ <h3 *ngIf="title$ != ''">{{ title$ }}</h3>
[outputEvents]="{ closeActionsDialog: closeActionsDialog }"
></component-mapper>
</div>
<div *ngIf="bIsDataObjectRecord$">
<component-mapper
name="DataViewActionButtons"
[props]="{
pConn$: createdViewPConn$,
context$,
dataObjectAction$,
actionID$: dataObjectActionID$,
dataRecordKeys$,
classID$: dataObjectClassID$
}"
></component-mapper>
</div>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,61 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ChangeDetectorRef } from '@angular/core';
import { FormBuilder } from '@angular/forms';

import { ModalViewContainerComponent } from './modal-view-container.component';

describe('ModalViewContainerComponent', () => {
let component: ModalViewContainerComponent;
let fixture: ComponentFixture<ModalViewContainerComponent>;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ModalViewContainerComponent]
}).compileComponents();
}));
let markForCheckSpy: jasmine.Spy;

beforeEach(() => {
fixture = TestBed.createComponent(ModalViewContainerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
(globalThis as any).PCore = {
getConstants: () => ({ PAGE: 'PAGE' })
};

markForCheckSpy = jasmine.createSpy('markForCheck');
const cdRef = { markForCheck: markForCheckSpy } as unknown as ChangeDetectorRef;

component = new ModalViewContainerComponent({} as any, cdRef, {} as any, new FormBuilder());
component.pConn$ = { getStateProps: () => ({}) } as any;
component.itemKey$ = 'app/modal_1';
component.stateProps$ = {};
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('surfaces httpMessages captured by the bridge as an urgent banner', () => {
component.angularPConnectData.httpMessages = ['Save failed'];

const banners = component.getBanners();

expect(banners.length).toBe(1);
expect(banners[0].messages).toEqual(['Save failed']);
expect(banners[0].variant).toBe('urgent');
});

it('produces no banner when there are no httpMessages', () => {
component.angularPConnectData.httpMessages = undefined;

expect(component.getBanners()).toEqual([]);
});

it('refreshes banners and flags change detection when they change', () => {
component.angularPConnectData.httpMessages = ['Save failed'];

component.refreshBanners();

expect(component.banners.length).toBe(1);
expect(markForCheckSpy).toHaveBeenCalled();
});

it('does not flag change detection when the banners are unchanged', () => {
component.banners = component.getBanners();
markForCheckSpy.calls.reset();

component.refreshBanners();

expect(markForCheckSpy).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export class ModalViewContainerComponent implements OnInit, OnDestroy {
localeCategory = 'Data Object';
isMultiRecord = false;
actionsDialog = false;
// Single-record data object modals own the new footer; multi-record modals keep their own.
bIsDataObjectRecord$ = false;
dataObjectAction$ = '';
dataObjectActionID$ = '';
dataRecordKeys$ = '';
dataObjectClassID$ = '';

constructor(
private angularPConnect: AngularPConnectService,
Expand Down Expand Up @@ -228,6 +234,12 @@ export class ModalViewContainerComponent implements OnInit, OnDestroy {
const dataObjectAction = routingInfo.items[latestItem.context].resourceStatus;
this.isMultiRecord = routingInfo.items[latestItem.context].isMultiRecordData;
this.context$ = latestItem.context;
this.dataObjectAction$ = dataObjectAction;
this.dataObjectActionID$ = routingInfo.items[latestItem.context].actionID ?? '';
// `key` arrives JSON-serialised; DataViewActionButtons parses it before calling the APIs.
this.dataRecordKeys$ = latestItem.key ?? '';
this.dataObjectClassID$ = newComp.getValue('.classID') ?? '';
this.bIsDataObjectRecord$ = isDataObject && !this.isMultiRecord;
this.title$ = this.getHeadingValue(
latestItem,
isDataObject,
Expand Down Expand Up @@ -272,6 +284,7 @@ export class ModalViewContainerComponent implements OnInit, OnDestroy {
// for when non modal
this.modalVisibleChange.emit(this.bShowModal$);

this.bIsDataObjectRecord$ = false;
this.oCaseInfo = {};
this.cdRef.markForCheck();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div>
<div><component-mapper name="AlertBanner" [props]="{ banners: bannerService.banners }" [parent]="this"></component-mapper></div>
<div><component-mapper name="AlertBanner" [props]="{ banners: bannerService.getBanners(itemKey$) }" [parent]="this"></component-mapper></div>
<div *ngIf="bHasNavigation$" class="psdk-stepper">
<component-mapper
name="MultiStep"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export class AssignmentComponent implements OnInit, OnDestroy, OnChanges {
if (this.angularPConnectData.unsubscribeFn) {
this.angularPConnectData.unsubscribeFn();
}

this.bannerService.clearBanners(this.itemKey$);
}

// Callback passed when subscribing to store change
Expand Down Expand Up @@ -315,7 +317,7 @@ export class AssignmentComponent implements OnInit, OnDestroy, OnChanges {

buttonClick(sAction, sButtonType) {
this.snackBarRef?.dismiss();
this.bannerService.clearBanners();
this.bannerService.clearBanners(this.itemKey$);
PCore.getPubSubUtils().publish('clearBannerMessages');
// right now, done on an individual basis, setting bReInit to true
// upon the next flow container state change, will cause the flow container
Expand Down
Loading
Loading