From 3094c2252917ac4ba810dd92e07225543bf5ce94 Mon Sep 17 00:00:00 2001 From: sambhu Date: Mon, 21 Sep 2026 21:38:07 +0530 Subject: [PATCH 1/3] feat(case-view): add data object actions and modal submit controls --- CHANGELOG.md | 2 + .../src/lib/_bridge/angular-pconnect.ts | 10 +- .../_bridge/helpers/sdk-pega-component-map.ts | 2 + .../data-view-action-buttons.component.html | 12 + ...data-view-action-buttons.component.spec.ts | 130 +++++++++ .../data-view-action-buttons.component.ts | 77 ++++++ .../modal-view-container.component.html | 13 + .../modal-view-container.component.spec.ts | 59 ++++- .../modal-view-container.component.ts | 30 ++- .../case-view/case-view.component.html | 14 +- .../case-view/case-view.component.spec.ts | 129 ++++++++- .../template/case-view/case-view.component.ts | 55 +++- .../self-service-case-view.component.html | 14 +- .../self-service-case-view.component.spec.ts | 107 +++++++- .../self-service-case-view.component.ts | 55 +++- .../lib/_types/DataObjectAction.interface.ts | 26 ++ .../angular-sdk-components/src/public-api.ts | 2 + .../angular-sdk-components/tsconfig.spec.json | 4 +- .../plan.md | 152 +++++++++++ .../spec.md | 110 ++++++++ .../tasks.md | 248 ++++++++++++++++++ 21 files changed, 1205 insertions(+), 46 deletions(-) create mode 100644 packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.html create mode 100644 packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.spec.ts create mode 100644 packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.ts create mode 100644 packages/angular-sdk-components/src/lib/_types/DataObjectAction.interface.ts create mode 100644 specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/plan.md create mode 100644 specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/spec.md create mode 100644 specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 44649c02..84e279f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ * Github: [PR-545](https://github.com/pegasystems/angular-sdk-components/pull/545) * **Fixed DataReference field value rendering in the Details template.** * Github: [PR-562](https://github.com/pegasystems/angular-sdk-components/pull/562) +* **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 the issue where views are not rendering in Details Template.** diff --git a/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts b/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts index da8d80a0..8751c75a 100644 --- a/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts +++ b/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts @@ -9,6 +9,8 @@ export interface AngularPConnectData { compID?: string; unsubscribeFn?: Function; validateMessage?: string; + // Captured here rather than left in props, so it is excluded from the props diff below. + httpMessages?: any; actions?: { onChange: Function; onBlur: Function; @@ -373,10 +375,10 @@ export class AngularPConnectService { delete incomingProps.pageMessages; } - if (incomingProps.httpMessages) { - inComp.angularPConnectData.httpMessages = incomingProps.httpMessages; - incomingProps.httpMessages = undefined; - } + // Captured here, and cleared when absent, so a stale error cannot leak into the next + // container item. Excluded from the props diff either way. + inComp.angularPConnectData.httpMessages = incomingProps.httpMessages; + incomingProps.httpMessages = undefined; const incomingPropsAsStr: string = JSON.stringify(incomingProps); diff --git a/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts b/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts index 6361cb92..7a06393e 100644 --- a/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts +++ b/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts @@ -22,6 +22,7 @@ import { AutoCompleteComponent } from '../../_components/field/auto-complete/aut import { CancelAlertComponent } from '../../_components/field/cancel-alert/cancel-alert.component'; import { CheckBoxComponent } from '../../_components/field/check-box/check-box.component'; import { CurrencyComponent } from '../../_components/field/currency/currency.component'; +import { DataViewActionButtonsComponent } from '../../_components/field/data-view-action-buttons/data-view-action-buttons.component'; import { DateComponent } from '../../_components/field/date/date.component'; import { DateTimeComponent } from '../../_components/field/date-time/date-time.component'; import { DecimalComponent } from '../../_components/field/decimal/decimal.component'; @@ -161,6 +162,7 @@ const pegaSdkComponentMap = { Currency: CurrencyComponent, DashboardFilter: DashboardFilterComponent, DataReference: DataReferenceComponent, + DataViewActionButtons: DataViewActionButtonsComponent, Date: DateComponent, DateTime: DateTimeComponent, Decimal: DecimalComponent, diff --git a/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.html b/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.html new file mode 100644 index 00000000..292b115c --- /dev/null +++ b/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.html @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.spec.ts b/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.spec.ts new file mode 100644 index 00000000..138206dd --- /dev/null +++ b/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.spec.ts @@ -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; + 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(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'); + }); +}); diff --git a/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.ts b/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.ts new file mode 100644 index 00000000..7dbb0168 --- /dev/null +++ b/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.ts @@ -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; + 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 { + return this.dataRecordKeys$ ? JSON.parse(this.dataRecordKeys$) : {}; + } +} diff --git a/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.html b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.html index bef990cb..ab426b3c 100644 --- a/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.html +++ b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.html @@ -12,6 +12,19 @@

{{ title$ }}

[outputEvents]="{ closeActionsDialog: closeActionsDialog }" > +
+ +
diff --git a/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.spec.ts b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.spec.ts index e29d72f8..8d8f53cc 100644 --- a/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.spec.ts +++ b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.spec.ts @@ -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; - - 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(); + }); }); diff --git a/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts index fb91a15b..5e216d5b 100644 --- a/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts @@ -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, @@ -118,6 +124,10 @@ export class ModalViewContainerComponent implements OnInit, OnDestroy { // right now onlu get one updated when initial diaplay. So, once modal is up // let fall through and do a check with "compareCaseInfoIsDifferent" until fixed // this.updateSelf(); + + // httpMessages are excluded from the bridge's props diff, so a rejected save does not + // flag an update; refresh banners here so the error still reaches the open modal. + this.refreshBanners(); } } @@ -228,6 +238,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$ = isDataObject || this.isMultiRecord ? this.getModalHeading(dataObjectAction) @@ -267,6 +283,7 @@ export class ModalViewContainerComponent implements OnInit, OnDestroy { // for when non modal this.modalVisibleChange.emit(this.bShowModal$); + this.bIsDataObjectRecord$ = false; this.oCaseInfo = {}; this.cdRef.markForCheck(); } @@ -377,7 +394,18 @@ export class ModalViewContainerComponent implements OnInit, OnDestroy { } getBanners() { - return getBanners({ target: this.itemKey$, ...this.stateProps$ }); + // The bridge captures httpMessages onto angularPConnectData instead of leaving them in + // state props, so they must be merged in explicitly for server errors to render. + return getBanners({ target: this.itemKey$, ...this.stateProps$, httpMessages: this.angularPConnectData.httpMessages }); + } + + refreshBanners() { + this.stateProps$ = this.pConn$.getStateProps(); + const refreshedBanners = this.getBanners(); + if (!isEqual(refreshedBanners, this.banners)) { + this.banners = refreshedBanners; + this.cdRef.markForCheck(); + } } getModalHeading(dataObjectAction) { diff --git a/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.html b/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.html index 7c15d7d0..541ef7d9 100644 --- a/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.html +++ b/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.html @@ -17,7 +17,9 @@

{{ heading$ }}

- + + + + + + +
diff --git a/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.spec.ts b/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.spec.ts index a04e2deb..bd39469d 100644 --- a/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.spec.ts +++ b/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.spec.ts @@ -1,24 +1,133 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectorRef } from '@angular/core'; import { CaseViewComponent } from './case-view.component'; describe('CaseViewComponent', () => { let component: CaseViewComponent; - let fixture: ComponentFixture; + let actionsApi: { openDataObjectAction: jasmine.Spy; createWork: jasmine.Spy }; + let dataInfoActions: any; + let dataRecord: any; + let caseInfo: any; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [CaseViewComponent] - }).compileComponents(); - })); + const tabsChild = { + getPConnect: () => ({ + getRawMetadata: () => ({ type: 'region', name: 'Tabs' }), + getChildren: () => [] + }) + }; beforeEach(() => { - fixture = TestBed.createComponent(CaseViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + (globalThis as any).PCore = { + getLocaleUtils: () => ({ getLocaleValue: (value: string) => value }) + }; + + actionsApi = { + openDataObjectAction: jasmine.createSpy('openDataObjectAction'), + createWork: jasmine.createSpy('createWork') + }; + dataInfoActions = undefined; + dataRecord = { PlanID: 'P-1', CustomerID: 'C-9' }; + caseInfo = { ID: 'C-1', availableActions: [], availableProcesses: [] }; + + const cdRef = { detectChanges: () => {}, markForCheck: () => {} } as unknown as ChangeDetectorRef; + const utils = { getImageSrc: () => '', getSDKStaticContentUrl: () => '' } as any; + + component = new CaseViewComponent(cdRef, {} as any, utils); + component.pConn$ = { + resolveConfigProps: (props: any) => props, + getConfigProps: () => ({ icon: 'case', header: 'Header', subheader: 'ID-1' }), + getCaseInfo: () => ({ getClassName: () => 'My-Class', getName: () => 'My Case' }), + getChildren: () => [tabsChild], + getLocalizationService: () => ({ getLocalizedText: (value: string) => value }), + getDataObject: () => ({ caseInfo }), + getValue: (prop: string, context?: string) => { + if (prop === '.actions' && context === 'dataInfo') return dataInfoActions; + if (prop === '.content' && context === 'dataInfo') return dataRecord; + if (prop === '.classID' && context === 'dataInfo.content') return 'My-Data-Class'; + if (context === '') return dataRecord[prop.slice(1)]; + return undefined; + }, + getActionsApi: () => actionsApi + } as any; + component.localizedVal = (value: string) => value; }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('lists data object actions published under dataInfo', () => { + dataInfoActions = { + availableActions: [{ ID: 'Edit', name: 'Edit' }], + availableCreateCaseActions: [{ ID: 'My-Work-Class', name: 'Request Plan Change' }] + }; + + component.fullUpdate(); + + expect(component.arDataObjectActions$.length).toBe(1); + expect(component.arCreateCaseActions$.length).toBe(1); + }); + + it('falls back to empty arrays when the dataInfo context is absent', () => { + dataInfoActions = undefined; + + component.fullUpdate(); + + expect(component.arDataObjectActions$).toEqual([]); + expect(component.arCreateCaseActions$).toEqual([]); + }); + + it('disables the actions menu when no action of any kind is available', () => { + component.fullUpdate(); + + expect(component.bActionsMenuDisabled$).toBeTrue(); + }); + + it('enables the actions menu when only data object actions are available', () => { + dataInfoActions = { availableActions: [{ ID: 'Edit', name: 'Edit' }] }; + + component.fullUpdate(); + + expect(component.bActionsMenuDisabled$).toBeFalse(); + }); + + it('picks up case actions that arrive after the first update, without a case ID change', () => { + component.fullUpdate(); + expect(component.bActionsMenuDisabled$).toBeTrue(); + expect(component.editAction).toBeUndefined(); + + caseInfo.availableActions = [{ ID: 'pyUpdateCaseDetails', name: 'Edit details' }]; + component.updateCaseActions(); + + expect(component.arAvailableActions$.length).toBe(1); + expect(component.editAction).toBeTruthy(); + expect(component.bActionsMenuDisabled$).toBeFalse(); + }); + + it('opens a data object action with the record class and content', () => { + component._menuDataObjectActionClick({ ID: 'updateCVV', name: 'Update CVV' }); + + expect(actionsApi.openDataObjectAction).toHaveBeenCalledWith('My-Data-Class', dataRecord, 'updateCVV', 'Update CVV'); + }); + + it('starts a new case with starting fields nested under the target reference field', () => { + component._menuCreateCaseActionClick({ + ID: 'My-Work-Class', + name: 'Request Plan Change', + targetDataReferenceField: { + field: 'PlanRef', + inputs: [{ linkedField: 'PlanID' }, { linkedField: 'CustomerID' }] + } + }); + + expect(actionsApi.createWork).toHaveBeenCalledWith('My-Work-Class', { + startingFields: { PlanRef: { PlanID: 'P-1', CustomerID: 'C-9' } } + }); + }); + + it('starts a new case with no starting fields when the action declares no inputs', () => { + component._menuCreateCaseActionClick({ ID: 'My-Work-Class', name: 'Request Plan Change' }); + + expect(actionsApi.createWork).toHaveBeenCalledWith('My-Work-Class', { startingFields: {} }); + }); }); diff --git a/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.ts b/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.ts index 92fbfa87..cd069c90 100644 --- a/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.ts @@ -8,6 +8,7 @@ import { interval } from 'rxjs'; import { AngularPConnectData, AngularPConnectService } from '../../../_bridge/angular-pconnect'; import { Utils } from '../../../_helpers/utils'; import { ComponentMapperComponent } from '../../../_bridge/component-mapper/component-mapper.component'; +import { CreateCaseAction, DataObjectAction, DataObjectActions } from '../../../_types/DataObjectAction.interface'; interface CaseViewProps { // If any, enter additional props that only exist on this component @@ -45,6 +46,9 @@ export class CaseViewComponent implements OnInit, OnDestroy { arAvailableActions$: any[] = []; arAvailabeProcesses$: any[] = []; + arDataObjectActions$: DataObjectAction[] = []; + arCreateCaseActions$: CreateCaseAction[] = []; + bActionsMenuDisabled$ = true; caseSummaryPConn$: any; currentCaseID = ''; @@ -98,10 +102,31 @@ export class CaseViewComponent implements OnInit, OnDestroy { sessionStorage.setItem('okToInitFlowContainer', 'true'); } else { this.updateHeaderAndSummary(); + this.updateCaseActions(); } } } + // React re-reads every action source on each render. Here fullUpdate is gated on a case ID + // change and so runs once, while actions (and a data object's `dataInfo.actions`) arrive + // later, so they are all refreshed on every store update instead. + updateCaseActions() { + const caseInfo = this.pConn$.getDataObject()?.caseInfo ?? {}; + this.arAvailableActions$ = caseInfo.availableActions ?? []; + this.arAvailabeProcesses$ = caseInfo.availableProcesses ?? []; + this.editAction = this.arAvailableActions$.find(action => action.ID === 'pyUpdateCaseDetails'); + + const dataInfoActions: DataObjectActions = this.pConn$.getValue('.actions', 'dataInfo') ?? {}; + this.arDataObjectActions$ = dataInfoActions.availableActions ?? []; + this.arCreateCaseActions$ = dataInfoActions.availableCreateCaseActions ?? []; + + this.bActionsMenuDisabled$ = + this.arAvailableActions$.length === 0 && + this.arAvailabeProcesses$.length === 0 && + this.arDataObjectActions$.length === 0 && + this.arCreateCaseActions$.length === 0; + } + hasCaseIDChanged(): boolean { if (this.currentCaseID !== this.pConn$.getDataObject().caseInfo.ID) { this.currentCaseID = this.pConn$.getDataObject().caseInfo.ID; @@ -144,9 +169,8 @@ export class CaseViewComponent implements OnInit, OnDestroy { const caseInfo = this.pConn$.getDataObject().caseInfo; this.currentCaseID = caseInfo.ID; - this.arAvailableActions$ = caseInfo?.availableActions ? caseInfo.availableActions : []; - this.editAction = this.arAvailableActions$.find(action => action.ID === 'pyUpdateCaseDetails'); - this.arAvailabeProcesses$ = caseInfo?.availableProcesses ? caseInfo.availableProcesses : []; + + this.updateCaseActions(); this.svgCase$ = this.utils.getImageSrc(this.configProps$.icon, this.utils.getSDKStaticContentUrl()); @@ -218,4 +242,29 @@ export class CaseViewComponent implements OnInit, OnDestroy { openProcessAction(data.ID, { ...data }); } + + _menuDataObjectActionClick(action: DataObjectAction) { + const actionsAPI = this.pConn$.getActionsApi(); + const openDataObjectAction = actionsAPI.openDataObjectAction.bind(actionsAPI); + const classID = this.pConn$.getValue('.classID', 'dataInfo.content'); + const dataRecord = this.pConn$.getValue('.content', 'dataInfo'); + + openDataObjectAction(classID, dataRecord, action.ID, this.localizedVal(action.name, '', this.localeKey)); + } + + _menuCreateCaseActionClick(action: CreateCaseAction) { + const actionsAPI = this.pConn$.getActionsApi(); + const createWork = actionsAPI.createWork.bind(actionsAPI); + const { field, inputs } = action.targetDataReferenceField ?? {}; + const startingFields: Record = {}; + + // The engine expects the linked values nested under the target reference field. + if (field) { + inputs?.forEach(input => { + startingFields[field] = { ...startingFields[field], [input.linkedField]: this.pConn$.getValue(`.${input.linkedField}`, '') }; + }); + } + + createWork(action.ID, { startingFields }); + } } diff --git a/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.html b/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.html index ffcf57dd..a779eb40 100644 --- a/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.html +++ b/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.html @@ -2,7 +2,9 @@
{{ this.heading$ }}
- + + + + + + +
diff --git a/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.spec.ts b/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.spec.ts index 5afb9f13..6b443472 100644 --- a/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.spec.ts +++ b/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.spec.ts @@ -1,24 +1,109 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; - import { SelfServiceCaseViewComponent } from './self-service-case-view.component'; describe('SelfServiceCaseViewComponent', () => { let component: SelfServiceCaseViewComponent; - let fixture: ComponentFixture; + let actionsApi: { openDataObjectAction: jasmine.Spy; createWork: jasmine.Spy }; + let dataInfoActions: any; + let configProps: any; + let dataRecord: any; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [SelfServiceCaseViewComponent] - }).compileComponents(); - })); + const utilityChild = { + getPConnect: () => ({ getRawMetadata: () => ({ type: 'region', name: 'Utilities', children: [] }) }) + }; beforeEach(() => { - fixture = TestBed.createComponent(SelfServiceCaseViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + (globalThis as any).PCore = { + getLocaleUtils: () => ({ getLocaleValue: (value: string) => value }), + getCaseUtils: () => ({ isObjectCaseType: () => false }) + }; + + actionsApi = { + openDataObjectAction: jasmine.createSpy('openDataObjectAction'), + createWork: jasmine.createSpy('createWork') + }; + dataInfoActions = undefined; + dataRecord = { PlanID: 'P-1' }; + // Summary region is switched off so the test does not depend on case summary metadata. + configProps = { icon: 'case', header: 'Header', subheader: 'ID-1', showSummaryRegion: false, showCaseActions: true }; + + const utils = { + getImageSrc: () => '', + getSDKStaticContentUrl: () => '', + getBooleanValue: (value: any) => value === true || value === 'true' + } as any; + + component = new SelfServiceCaseViewComponent({} as any, utils); + component.pConn$ = { + resolveConfigProps: () => configProps, + getConfigProps: () => configProps, + getCaseLocaleReference: () => 'MY-CLASS!CASE!MY-CASE', + getChildren: () => [utilityChild, utilityChild, utilityChild, utilityChild, utilityChild], + getLocalizationService: () => ({ getLocalizedText: (value: string) => value }), + getDataObject: () => ({ caseInfo: { ID: 'C-1', availableActions: [], availableProcesses: [] } }), + getValue: (prop: string, context?: string) => { + if (prop === '.actions' && context === 'dataInfo') return dataInfoActions; + if (prop === '.content' && context === 'dataInfo') return dataRecord; + if (prop === '.classID' && context === 'dataInfo.content') return 'My-Data-Class'; + if (context === '') return dataRecord[prop.slice(1)]; + return undefined; + }, + getActionsApi: () => actionsApi + } as any; + component.localizedVal = (value: string) => value; }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('lists data object actions published under dataInfo', () => { + dataInfoActions = { + availableActions: [{ ID: 'Edit', name: 'Edit' }], + availableCreateCaseActions: [{ ID: 'My-Work-Class', name: 'Request Plan Change' }] + }; + + component.fullUpdate(); + + expect(component.arDataObjectActions$.length).toBe(1); + expect(component.arCreateCaseActions$.length).toBe(1); + expect(component.bActionsMenuDisabled$).toBeFalse(); + }); + + it('falls back to empty arrays and disables the menu when nothing is available', () => { + component.fullUpdate(); + + expect(component.arDataObjectActions$).toEqual([]); + expect(component.arCreateCaseActions$).toEqual([]); + expect(component.bActionsMenuDisabled$).toBeTrue(); + }); + + it('keeps honouring the showCaseActions configuration flag', () => { + configProps = { ...configProps, showCaseActions: false }; + + component.fullUpdate(); + + expect(component.showCaseActions).toBeFalse(); + }); + + it('preserves the case locale reference used for action labels', () => { + component.fullUpdate(); + + expect(component.localeKey).toBe('MY-CLASS!CASE!MY-CASE'); + }); + + it('opens a data object action with the record class and content', () => { + component._menuDataObjectActionClick({ ID: 'updateCVV', name: 'Update CVV' }); + + expect(actionsApi.openDataObjectAction).toHaveBeenCalledWith('My-Data-Class', dataRecord, 'updateCVV', 'Update CVV'); + }); + + it('starts a new case with starting fields nested under the target reference field', () => { + component._menuCreateCaseActionClick({ + ID: 'My-Work-Class', + name: 'Request Plan Change', + targetDataReferenceField: { field: 'PlanRef', inputs: [{ linkedField: 'PlanID' }] } + }); + + expect(actionsApi.createWork).toHaveBeenCalledWith('My-Work-Class', { startingFields: { PlanRef: { PlanID: 'P-1' } } }); + }); }); diff --git a/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.ts b/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.ts index f789d80a..637f16d5 100644 --- a/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.ts @@ -9,6 +9,7 @@ import { AngularPConnectData, AngularPConnectService } from '../../../_bridge/an import { Utils } from '../../../_helpers/utils'; import { ComponentMapperComponent } from '../../../_bridge/component-mapper/component-mapper.component'; import { prepareCaseSummaryData } from '../utils'; +import { CreateCaseAction, DataObjectAction, DataObjectActions } from '../../../_types/DataObjectAction.interface'; interface SelfServiceCaseViewProps { // If any, enter additional props that only exist on this component @@ -45,6 +46,9 @@ export class SelfServiceCaseViewComponent implements OnInit, OnDestroy { arAvailableActions$: any[] = []; arAvailabeProcesses$: any[] = []; + arDataObjectActions$: DataObjectAction[] = []; + arCreateCaseActions$: CreateCaseAction[] = []; + bActionsMenuDisabled$ = true; caseSummaryPConn$: any; currentCaseID = ''; @@ -102,10 +106,31 @@ export class SelfServiceCaseViewComponent implements OnInit, OnDestroy { sessionStorage.setItem('okToInitFlowContainer', 'true'); } else { this.updateHeaderAndSummary(); + this.updateCaseActions(); } } } + // React re-reads every action source on each render. Here fullUpdate is gated on a case ID + // change and so runs once, while actions (and a data object's `dataInfo.actions`) arrive + // later, so they are all refreshed on every store update instead. + updateCaseActions() { + const caseInfo = this.pConn$.getDataObject()?.caseInfo ?? {}; + this.arAvailableActions$ = caseInfo.availableActions ?? []; + this.arAvailabeProcesses$ = caseInfo.availableProcesses ?? []; + this.editAction = this.arAvailableActions$.find(action => action.ID === 'pyUpdateCaseDetails'); + + const dataInfoActions: DataObjectActions = this.pConn$.getValue('.actions', 'dataInfo') ?? {}; + this.arDataObjectActions$ = dataInfoActions.availableActions ?? []; + this.arCreateCaseActions$ = dataInfoActions.availableCreateCaseActions ?? []; + + this.bActionsMenuDisabled$ = + this.arAvailableActions$.length === 0 && + this.arAvailabeProcesses$.length === 0 && + this.arDataObjectActions$.length === 0 && + this.arCreateCaseActions$.length === 0; + } + hasCaseIDChanged(): boolean { if (this.currentCaseID !== this.pConn$.getDataObject().caseInfo.ID) { this.currentCaseID = this.pConn$.getDataObject().caseInfo.ID; @@ -144,9 +169,8 @@ export class SelfServiceCaseViewComponent implements OnInit, OnDestroy { const caseInfo = this.pConn$.getDataObject().caseInfo; this.currentCaseID = caseInfo.ID; - this.arAvailableActions$ = caseInfo?.availableActions ? caseInfo.availableActions : []; - this.editAction = this.arAvailableActions$.find(action => action.ID === 'pyUpdateCaseDetails'); - this.arAvailabeProcesses$ = caseInfo?.availableProcesses ? caseInfo.availableProcesses : []; + + this.updateCaseActions(); const { showCaseLifecycle = true, showSummaryRegion = true, showUtilitiesRegion = true, showCaseActions = true, caseClass } = this.configProps$; this.showCaseLifecycle = this.utils.getBooleanValue(showCaseLifecycle); @@ -204,4 +228,29 @@ export class SelfServiceCaseViewComponent implements OnInit, OnDestroy { openProcessAction(data.ID, { ...data }); } + + _menuDataObjectActionClick(action: DataObjectAction) { + const actionsAPI = this.pConn$.getActionsApi(); + const openDataObjectAction = actionsAPI.openDataObjectAction.bind(actionsAPI); + const classID = this.pConn$.getValue('.classID', 'dataInfo.content'); + const dataRecord = this.pConn$.getValue('.content', 'dataInfo'); + + openDataObjectAction(classID, dataRecord, action.ID, this.localizedVal(action.name, '', this.localeKey)); + } + + _menuCreateCaseActionClick(action: CreateCaseAction) { + const actionsAPI = this.pConn$.getActionsApi(); + const createWork = actionsAPI.createWork.bind(actionsAPI); + const { field, inputs } = action.targetDataReferenceField ?? {}; + const startingFields: Record = {}; + + // The engine expects the linked values nested under the target reference field. + if (field) { + inputs?.forEach(input => { + startingFields[field] = { ...startingFields[field], [input.linkedField]: this.pConn$.getValue(`.${input.linkedField}`, '') }; + }); + } + + createWork(action.ID, { startingFields }); + } } diff --git a/packages/angular-sdk-components/src/lib/_types/DataObjectAction.interface.ts b/packages/angular-sdk-components/src/lib/_types/DataObjectAction.interface.ts new file mode 100644 index 00000000..968b0b5d --- /dev/null +++ b/packages/angular-sdk-components/src/lib/_types/DataObjectAction.interface.ts @@ -0,0 +1,26 @@ +// Actions published by a data object record under the `dataInfo` context. Work cases +// publish their actions under `caseInfo` instead; the case view renders both together. + +export interface DataObjectAction { + ID: string; + name: string; +} + +export interface CreateCaseActionInput { + linkedField: string; +} + +export interface CreateCaseAction { + ID: string; + name: string; + targetDataReferenceField?: { + // Property on the new case that the record is linked into; inputs are nested under it. + field?: string; + inputs?: CreateCaseActionInput[]; + }; +} + +export interface DataObjectActions { + availableActions?: DataObjectAction[]; + availableCreateCaseActions?: CreateCaseAction[]; +} diff --git a/packages/angular-sdk-components/src/public-api.ts b/packages/angular-sdk-components/src/public-api.ts index eb5a04fc..c09fe396 100644 --- a/packages/angular-sdk-components/src/public-api.ts +++ b/packages/angular-sdk-components/src/public-api.ts @@ -14,6 +14,7 @@ export * from './lib/_components/field/auto-complete/auto-complete.component'; export * from './lib/_components/field/cancel-alert/cancel-alert.component'; export * from './lib/_components/field/check-box/check-box.component'; export * from './lib/_components/field/currency/currency.component'; +export * from './lib/_components/field/data-view-action-buttons/data-view-action-buttons.component'; export * from './lib/_components/field/date-time/date-time.component'; export * from './lib/_components/field/date/date.component'; export * from './lib/_components/field/decimal/decimal.component'; @@ -168,4 +169,5 @@ export * from './lib/_messages/error-messages.service'; export * from './lib/_messages/progress-spinner.service'; export * from './lib/_messages/update-worklist.service'; +export * from './lib/_types/DataObjectAction.interface'; export * from './lib/_types/PConnProps.interface'; diff --git a/packages/angular-sdk-components/tsconfig.spec.json b/packages/angular-sdk-components/tsconfig.spec.json index 4b02ff17..ab8f1603 100644 --- a/packages/angular-sdk-components/tsconfig.spec.json +++ b/packages/angular-sdk-components/tsconfig.spec.json @@ -3,7 +3,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec", - "types": ["jasmine"] + // "types" replaces (not merges with) the root config, so pcore-pconnect-typedefs + // must be repeated here or the global PCore declaration is lost in test builds. + "types": ["jasmine", "pcore-pconnect-typedefs"] }, "include": ["**/*.spec.ts", "**/*.d.ts"] } diff --git a/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/plan.md b/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/plan.md new file mode 100644 index 00000000..1d7e909a --- /dev/null +++ b/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/plan.md @@ -0,0 +1,152 @@ +# Implementation Plan: Data Object Actions and Modal Submission + +**Branch**: `ActionButton` | **Date**: 2026-09-18 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/spec.md` + +## Summary + +Data object records currently render an empty **Actions...** menu and an edit modal with no +footer controls, because the case view templates source menu entries only from `caseInfo` +while data objects publish theirs under `dataInfo`, and no data-object submit component +exists. The work is additive in three parts: source and render `availableActions` plus +`availableCreateCaseActions` from `dataInfo` in both case views (disabling the trigger when +the combined count is zero), add two click handlers that call `openDataObjectAction` and +`createWork`, and add a `DataViewActionButtons` footer component wired into the modal +container behind an `isDataObject && !isMultiRecordData` flag. + +Research established two corrections to the original assumptions that materially change +scope: the `httpMessages` → banner path is **not** already wired in the modal container and +must be completed for in-modal save errors to appear, and the two case view components are +**not** identical, so the logic must be adapted per component rather than copied. See +[research.md](./research.md) R4 and R6. + +## Technical Context + +**Language/Version**: TypeScript with Angular ^21.x + +**Primary Dependencies**: `@pega/constellationjs` (PCore/PConnect engine, owns the Redux +store), Angular Material ^21.x, `@pega/pcore-pconnect-typedefs` (version-locked API +definitions) + +**Storage**: N/A — all state is owned by the engine and reached through PConnect + +**Testing**: Karma + Jasmine unit tests (`ng test angular-sdk-components`); Playwright E2E +(`npm run test`) against a live Infinity server + +**Target Platform**: Browser; both portal and embedded SDK modes + +**Project Type**: Angular component library (`@pega/angular-sdk-components`) plus a test +application + +**Performance Goals**: No new rendering cost on the work-case path; action sourcing happens +within the existing full-update pass, adding no extra render cycles + +**Constraints**: Additive and backward compatible only; `modal-view-container` is an +infrastructure container requiring commented, vigilant change; no direct REST calls; no +hard-coded user-facing strings; `$`-suffix and `b`-prefix template property conventions + +**Scale/Scope**: 2 new component files plus styles and a spec, 6 modified files, 0 removals + +## Constitution Check + +*GATE: evaluated before Phase 0 and re-evaluated after Phase 1 design.* + +| Principle | Status | Evidence | +|-----------|--------|----------| +| I. Platform Boundary | PASS | Every operation goes through `pConn$.getActionsApi()` and `pConn$.getValue()`. No HTTP calls, no new store. | +| II. Component Contracts | PASS | New component declares typed inputs; children render via ``; registered in the component map **and** exported from `public-api.ts`. Not a data field, so `FieldBase`/`PConnFieldProps` do not apply — matching the `ListViewActionButtons` precedent. | +| III. Backward Compatibility | PASS | Purely additive. No prop or export is removed or renamed. Work-case and embedded-data paths are untouched; `getBanners()` output is byte-equivalent when `httpMessages` is absent. | +| IV. Infrastructure Protection | PASS (vigilance required) | `modal-view-container` changes are additive, commented, and gated by a new flag; container/Redux logic is unchanged. Both portal and embedded modes must be E2E validated. | +| V. Security | PASS | No credentials, tokens, or URLs introduced; no auth logic touched. | +| VI. Testing Standards | PASS (planned) | Unit tests for both label modes, in-flight disabling, and the rejection path; E2E in both modes; lint at zero warnings. | +| VII. Spec and Plan Separation | PASS | [spec.md](./spec.md) names no file, framework, or API; all technical detail lives here and in `contracts/`. | +| VIII. Minimal Change and Code Health | PASS | Smallest effective change; a shared case-view helper was deliberately deferred (research R6) to avoid enlarging the diff. | +| IX. UX Consistency | PASS | Angular Material buttons only; all labels resolve through the engine's localization API under the existing `Data Object` category. | + +**Post-Phase 1 re-evaluation**: no gate changed status. The one judgment call — completing +the `httpMessages` banner path inside an infrastructure container — is required by FR-008, +is additive, and is confined to a single method whose output is unchanged when no HTTP error +is present. + +## Project Structure + +### Documentation (this feature) + +```text +specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/ +├── plan.md # This file (/speckit-plan command output) +├── spec.md # Feature specification +├── research.md # Phase 0 output (/speckit-plan command) +├── data-model.md # Phase 1 output (/speckit-plan command) +├── quickstart.md # Phase 1 output (/speckit-plan command) +├── contracts/ # Phase 1 output (/speckit-plan command) +│ ├── case-view-data-object-actions.md +│ ├── data-view-action-buttons.md +│ └── modal-view-container.md +├── checklists/ +│ └── requirements.md +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) +``` + +### Source Code (repository root) + +```text +packages/angular-sdk-components/src/ +├── public-api.ts # MODIFY — export new component +└── lib/ + ├── _bridge/helpers/ + │ └── sdk-pega-component-map.ts # MODIFY — register DataViewActionButtons + └── _components/ + ├── field/ + │ ├── list-view-action-buttons/ # REFERENCE ONLY — unchanged + │ └── data-view-action-buttons/ # NEW + │ ├── data-view-action-buttons.component.ts + │ ├── data-view-action-buttons.component.html + │ ├── data-view-action-buttons.component.scss + │ └── data-view-action-buttons.component.spec.ts + ├── infra/Containers/modal-view-container/ + │ ├── modal-view-container.component.ts # MODIFY — expose flags, merge httpMessages + │ └── modal-view-container.component.html # MODIFY — render new footer + └── template/ + ├── case-view/ + │ ├── case-view.component.ts # MODIFY — source dataInfo actions + handlers + │ └── case-view.component.html # MODIFY — render entries, disable trigger + └── self-service-case-view/ + ├── self-service-case-view.component.ts # MODIFY — same, adapted + └── self-service-case-view.component.html # MODIFY — same, adapted +``` + +**Structure Decision**: Standard library layout. The new component is placed under +`_components/field/` alongside `list-view-action-buttons`, the component it is modeled on, +so the two modal footers sit together. No new top-level directory is introduced. + +### Implementation sequence + +1. **Case views** (independently testable — delivers User Story 1 on its own) + → [contracts/case-view-data-object-actions.md](./contracts/case-view-data-object-actions.md) +2. **New footer component** + registration in both required places + → [contracts/data-view-action-buttons.md](./contracts/data-view-action-buttons.md) +3. **Modal container wiring** and the `httpMessages` banner fix + → [contracts/modal-view-container.md](./contracts/modal-view-container.md) + +Step 1 stands alone. Steps 2 and 3 together deliver User Stories 2 and 3; the footer cannot +render until the container passes the flag, so they land together. + +### Primary risk + +Per research R5, `httpMessages` is deliberately excluded from the bridge's props diff, so +merging it into `getBanners()` may not by itself cause a re-render on a failed save. If the +banner does not appear, trigger change detection from the failing submit path in the new +component — do **not** modify the bridge's diffing behavior. + +## Complexity Tracking + +No constitution principle is relaxed by this plan, so no justification entries are required. + +The one item worth recording for reviewers is a deliberate **rejection** of added +abstraction: the near-duplicate action-sourcing block across the two case view components is +intentionally duplicated rather than extracted into a shared helper, because the components +differ in localization key derivation and action-visibility gating (research R6), and +extracting it would modify the internals of two working templates for marginal benefit — +contrary to Principle VIII. diff --git a/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/spec.md b/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/spec.md new file mode 100644 index 00000000..21141290 --- /dev/null +++ b/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/spec.md @@ -0,0 +1,110 @@ +# Feature Specification: Data Object Actions and Modal Submission + +**Feature Branch**: `ActionButton` + +**Created**: 2026-09-18 + +**Status**: Draft + +**Input**: User description: "Data Object actions and modal submit buttons in the Angular SDK. Enable available actions for data object records, disable empty action menus, and provide submit/cancel controls with save error handling for data object edit/create modals while preserving work case and embedded-data behavior." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Use actions on a data object record (Priority: P1) + +When a user views a data object record, they can open the Actions menu and choose any action available for that record, including actions that begin a new case. The selected action opens in a modal so the user can complete it without losing the record context. + +**Why this priority**: An empty action menu prevents users from accessing the record's primary operations and makes the existing action control appear broken. + +**Independent Test**: Open a data object record with at least one available action, select each displayed action, and verify that the corresponding modal opens. + +**Acceptance Scenarios**: + +1. **Given** a data object record has available record actions, **When** the user opens the Actions menu, **Then** the menu lists those actions, such as Edit or Request Plan Change. +2. **Given** a data object record has an action that starts a new case, **When** the user opens the Actions menu, **Then** that action is listed with the record actions. +3. **Given** a listed action is selected, **When** the selection is made, **Then** the selected action opens in a modal. +4. **Given** the same data object record is opened in the standard case view or self-service case view, **When** the user opens the Actions menu, **Then** the available entries and resulting behavior are equivalent. + +### User Story 2 - Edit or create a data object record (Priority: P1) + +When a user opens a data object edit or create action, the modal provides an explicit way to save or discard the changes. The primary action is labeled according to whether the user is updating an existing record or submitting a new record or record action. + +**Why this priority**: Without modal controls, users cannot complete or safely abandon data object edits. + +**Independent Test**: Open both an existing-record edit and a new-record or record-action modal, verify the controls and labels, then complete each flow. + +**Acceptance Scenarios**: + +1. **Given** an existing data object record is being edited, **When** the modal opens, **Then** it shows Cancel and a primary button labeled Update. +2. **Given** a new data object record is being created, **When** the modal opens, **Then** it shows Cancel and a primary button labeled Submit. +3. **Given** a record action requires submission, **When** the modal opens, **Then** it shows Cancel and a primary button labeled Submit. +4. **Given** the record data is valid, **When** the user selects the primary button, **Then** the record is saved and the modal closes. +5. **Given** the user has changed data in the modal, **When** the user selects Cancel, **Then** the changes are discarded and the modal closes. + +### User Story 3 - Recover from a failed save (Priority: P1) + +When a data object save is rejected, the user receives the error in the modal, the entered data remains available for correction, and the save cannot be submitted repeatedly while it is in progress. + +**Why this priority**: Users need a recoverable failure path rather than losing context or receiving no explanation when a save is rejected. + +**Independent Test**: Trigger a rejected save, verify the in-modal error and preserved modal state, and attempt repeated primary-button activation during the save. + +**Acceptance Scenarios**: + +1. **Given** a data object save is rejected, **When** the rejection is returned, **Then** an error banner is shown inside the modal and the modal remains open. +2. **Given** a save is in flight, **When** the user attempts to activate the primary or Cancel button, **Then** both buttons are disabled until the save completes or fails. +3. **Given** a save has failed and the error is visible, **When** the user corrects the data and submits again, **Then** the new save attempt is allowed and a successful save closes the modal. + +### Edge Cases + +- When a data object record has no available actions, the Actions control is visibly disabled and cannot open an empty menu. +- When only actions that start a new case are available, those actions remain visible and selectable. +- When the save error has no useful detail, the modal still shows a clear user-facing failure message. +- When the user cancels a modal after entering changes, no partial changes are persisted. +- Existing work case action menus and work case modals retain their current entries, labels, save behavior, and error handling. +- Existing multi-record embedded-data modals retain their own controls and behavior without receiving duplicate buttons. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST identify and display all available actions for a data object record in the record's Actions menu, including actions that start a new case. +- **FR-002**: The system MUST open the selected data object action in a modal while preserving the current record context. +- **FR-003**: The system MUST visibly disable the Actions control when a data object record has no available menu entries, and MUST prevent an empty menu from opening. +- **FR-004**: A data object edit or create modal MUST display a Cancel button and one primary confirmation button. +- **FR-005**: The primary confirmation button MUST be labeled Update when editing an existing data object record. +- **FR-006**: The primary confirmation button MUST be labeled Submit when creating a data object record or submitting a record action. +- **FR-007**: Selecting the primary confirmation button with valid data MUST save the data object record and close the modal after a successful save. +- **FR-008**: The system MUST display a rejected-save error as a banner inside the data object modal and MUST keep the modal open with the user's data intact. +- **FR-009**: The primary confirmation and Cancel buttons MUST be disabled while a data object save is in flight, preventing duplicate submissions or cancellation during that save. +- **FR-010**: Selecting Cancel before a save is in flight MUST discard the current edit and close the data object modal. +- **FR-011**: The behavior MUST be equivalent in the standard case view and self-service case view. +- **FR-012**: The behavior MUST NOT alter existing work case behavior or the existing controls and behavior of multi-record embedded-data modals. + +### Key Entities + +- **Data object record**: A non-work-case business record that can expose record actions and can be created or edited. +- **Record action**: An operation available for a data object record, including edits, changes, and operations that begin a new case. +- **Data object modal**: The modal interaction used to complete or cancel a data object action, including save status and error feedback. +- **Work case**: An existing case type whose action menu and modal behavior must remain unchanged. +- **Embedded-data modal**: The existing multi-record editing modal with its own controls and behavior. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance testing, 100% of available data object record actions and new-case actions appear in the Actions menu when the record exposes them. +- **SC-002**: In acceptance testing, 100% of data object records with no available actions show a disabled Actions control and never open an empty menu. +- **SC-003**: At least 95% of valid data object edit, create, and record-action submissions close the modal and persist the record on the first primary-button attempt. +- **SC-004**: 100% of rejected data object saves display an in-modal error and leave the modal open with entered data available for correction. +- **SC-005**: 100% of tested save-in-flight states prevent a second save attempt and keep both modal controls disabled until the operation resolves. +- **SC-006**: Existing work case and embedded-data modal regression tests show no change in action availability, button labels, or completion behavior. +- **SC-007**: Users can complete or cancel a data object edit in both case-view variants without needing to leave the current record context. + +## Assumptions + +- Available actions and whether an action starts a new case are supplied by the existing record and platform configuration. +- The existing localization and design-system conventions provide the user-facing labels, button styles, and error-banner presentation. +- Save validation and authorization remain governed by the existing platform behavior; this feature only ensures that outcomes are represented correctly in the modal. +- The feature applies to single data object records and does not replace the controls already owned by multi-record embedded-data modals. +- Standard and self-service case views are expected to expose the same data object action and modal capabilities. \ No newline at end of file diff --git a/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/tasks.md b/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/tasks.md new file mode 100644 index 00000000..320379b0 --- /dev/null +++ b/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/tasks.md @@ -0,0 +1,248 @@ +--- +description: "Task list for Data Object Actions and Modal Submission" +--- + +# Tasks: Data Object Actions and Modal Submission + +**Input**: Design documents from `/specs/ENHANCEMENT-14695 - Actions on DataObjects not showing up/` + +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), [data-model.md](./data-model.md), [contracts/](./contracts/) + +**Tests**: Test tasks **are included and are not optional** for this feature. Constitution +Principle VI requires unit tests for every behavior change and E2E validation for changes +affecting case flow or form behavior, and the Constitution Check in [plan.md](./plan.md) +commits to them. + +**Organization**: Tasks are grouped by user story. All three stories are P1; they are +ordered so that User Story 1 alone is a shippable increment. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) +- Exact file paths are included in every task + +## Path Conventions + +All source paths are relative to the repository root. Library source lives under +`packages/angular-sdk-components/src/`. There is no separate tests tree — Karma specs sit +beside the components they cover, per existing repository convention. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Establish an attributable baseline and confirm the environment can exercise the feature + +- [X] T001 Record a green baseline by running `npm run lint` and `npx ng test angular-sdk-components --watch=false` from the repository root, so any later failure is attributable to this feature +- [ ] T002 [P] Confirm the Infinity application referenced by `sdk-config.json` exposes a data object with at least one record action and one create-case action, a data object with **no** actions, and a work case type for regression comparison, as required by the scenarios in [quickstart.md](./quickstart.md) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared typed contracts consumed by all three user stories + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T003 Create the shared action metadata interfaces (`DataObjectAction`, `CreateCaseAction`, `CreateCaseInput`, `DataRecordContext`) in `packages/angular-sdk-components/src/lib/_types/data-object-action.types.ts` per [data-model.md](./data-model.md), mirroring the export convention already used by `packages/angular-sdk-components/src/lib/_types/PConnProps.interface.ts` + +**Checkpoint**: Shared types available — user story implementation can begin + +--- + +## Phase 3: User Story 1 - Use actions on a data object record (Priority: P1) 🎯 MVP + +**Goal**: A data object record's Actions menu lists its record actions and create-case actions, each opening in a modal; the trigger is disabled when there are no entries at all. + +**Independent Test**: Open a data object record with actions and confirm the menu is populated and each entry opens a modal; open a record with no actions and confirm the Actions button is disabled. Work case menus are unchanged. Requires no modal footer work. + +### Tests for User Story 1 + +- [X] T004 [P] [US1] Extend `packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.spec.ts` to cover `dataInfo` action sourcing, the `[]` fallback when the `dataInfo` context is absent, and `bActionsMenuDisabled$` being true only when all four action arrays are empty +- [X] T005 [P] [US1] Extend `packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.spec.ts` with the same coverage, plus an assertion that the existing `showCaseActions` configuration gate still controls visibility + +### Implementation for User Story 1 + +- [X] T006 [US1] In `packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.ts`, read `pConn$.getValue('.actions', 'dataInfo')` inside the existing `fullUpdate()` pass and populate new `arDataObjectActions$`, `arCreateCaseActions$`, and `bActionsMenuDisabled$` properties, leaving the existing `caseInfo` reads and `editAction` logic untouched +- [X] T007 [US1] In `packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.ts`, add `_menuDataObjectActionClick` and `_menuCreateCaseActionClick` handlers following the existing `_menuActionClick` / `_menuProcessClick` style, per [contracts/case-view-data-object-actions.md](./contracts/case-view-data-object-actions.md) (depends on T006) +- [X] T008 [US1] In `packages/angular-sdk-components/src/lib/_components/template/case-view/case-view.component.html`, add two `ng-container` loops emitting `mat-menu-item` buttons for the new arrays after the existing two loops, and bind `[disabled]="bActionsMenuDisabled$"` on the Actions trigger button (depends on T007) +- [X] T009 [P] [US1] Apply the equivalent sourcing and handlers to `packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.ts`, preserving its `getCaseLocaleReference()`-derived `localeKey` rather than copying the case-view localization approach (see [research.md](./research.md) R6) +- [X] T010 [US1] Apply the equivalent rendering to `packages/angular-sdk-components/src/lib/_components/template/self-service-case-view/self-service-case-view.component.html`, keeping the surrounding `showCaseActions` gate intact (depends on T009) + +**Checkpoint**: Data object action menus work on both views and are disabled when empty. Shippable on its own. + +--- + +## Phase 4: User Story 2 - Edit or create a data object record (Priority: P1) + +**Goal**: A single-record data object modal shows a Cancel button and one correctly labeled primary button that saves the record and closes the modal. + +**Independent Test**: Open an edit modal and confirm the primary button reads "Update"; open a create modal or record action and confirm it reads "Submit". Confirm a valid save closes the modal and Cancel discards the change. Confirm the multi-record modal is unaffected. + +### Tests for User Story 2 + +- [X] T011 [P] [US2] Create `packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.spec.ts` covering the label mapping for all three `RESOURCE_STATUS` values and the correct actions-API branch per status. The spec must stub `globalThis.PCore` **before** component instantiation (the component reads `PCore.getLocaleUtils()` in a field initializer) and must register the standalone component via `imports:`, not `declarations:` + +### Implementation for User Story 2 + +- [X] T012 [P] [US2] Create `packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.ts` with the typed inputs and `closeActionsDialog` output defined in [contracts/data-view-action-buttons.md](./contracts/data-view-action-buttons.md), using the `$`-suffix and `b`-prefix conventions +- [X] T013 [P] [US2] Create `packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.html` with an Angular Material Cancel button and primary button, both labels resolved through `PCore.getLocaleUtils().getLocaleValue` under the `Data Object` category, and both bound to `[disabled]="bDisabled$"` +- [X] T014 [P] [US2] Create `packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.scss` mirroring `list-view-action-buttons.component.scss` +- [X] T015 [US2] Implement the primary-button branching in `data-view-action-buttons.component.ts` — `createDataObject(context$)`, `updateDataObject(context$, keys$)`, or `submitDataObjectAction(context$, keys$, actionID$)` — publishing `DATA_OBJECT_CREATED` or `DATA_OBJECT_UPDATED` on success and emitting `closeActionsDialog` (depends on T012) +- [X] T016 [US2] Implement `onCancel` in `data-view-action-buttons.component.ts` to emit `closeActionsDialog` then call `cancelDataObject(context$)`, guarding against its non-Promise return type documented in [research.md](./research.md) R2 (depends on T012) +- [X] T017 [US2] Register `DataViewActionButtons` in `packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts` by adding the import and map entry alongside the existing `ListViewActionButtons` entry — add only, remove nothing +- [X] T018 [P] [US2] Add `export * from './lib/_components/field/data-view-action-buttons/data-view-action-buttons.component';` to `packages/angular-sdk-components/src/public-api.ts` (required by AGENTS.md rule 3 and Principle II even though the `ListViewActionButtons` precedent omits it — see [research.md](./research.md) R7) +- [X] T019 [US2] In `packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts`, promote the existing `createView()` locals to the fields `dataObjectAction$`, `dataObjectActionID$`, `dataObjectKey$`, `dataObjectClassID$`, and add `bIsDataObjectRecord$ = isDataObject && !isMultiRecordData`, leaving `this.isMultiRecord` and the `title$` expression unchanged and adding a one-line comment explaining the flag (infrastructure container — additive only) +- [X] T020 [US2] In `packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.html`, render the new component through `` inside a block gated on `bIsDataObjectRecord$`, as a sibling of the existing `*ngIf="isMultiRecord"` block, reusing the existing `closeActionsDialog` handler (depends on T017, T019) + +**Checkpoint**: Data object modals save and cancel correctly; embedded-data modals unchanged. + +--- + +## Phase 5: User Story 3 - Recover from a failed save (Priority: P1) + +**Goal**: A rejected save surfaces an in-modal error banner, keeps the modal open with data intact, and cannot be double-submitted. + +**Independent Test**: Force a server rejection and confirm an error banner appears inside the modal, the modal stays open with entered data preserved, both buttons are disabled during the request, and a retry succeeds. + +### Tests for User Story 3 + +- [X] T021 [P] [US3] Extend `packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.spec.ts` to assert that a rejected save does **not** emit `closeActionsDialog`, that `bDisabled$` is true while the promise is pending, and that it resets to false after both resolution and rejection +- [X] T022 [P] [US3] Extend `packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.spec.ts` to assert `getBanners()` includes `httpMessages` sourced from `angularPConnectData`, and that its output is unchanged when `httpMessages` is absent + +### Implementation for User Story 3 + +- [X] T023 [US3] In `packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts`, merge `httpMessages: this.angularPConnectData.httpMessages` into the `getBanners()` call with a one-line comment noting the bridge stores it outside state props — do not modify `_helpers/case-utils.ts` or `_bridge/angular-pconnect.ts` (see [research.md](./research.md) R4) +- [X] T024 [US3] Ensure both the primary and Cancel buttons are disabled while a save is in flight in `data-view-action-buttons.component.ts` and its template — note the `ListViewActionButtons` precedent disables only its submit button, which does not satisfy FR-009 (depends on T015, T016) +- [ ] T025 [US3] Validate against a live rejection that the banner actually renders; if it does not, trigger change detection from the failure path in `data-view-action-buttons.component.ts` rather than altering the bridge's props diffing, per the risk recorded in [research.md](./research.md) R5 (depends on T023) + +**Checkpoint**: All three user stories are independently functional. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T026 [P] Run `npm run lint` and resolve every issue — the repository enforces `--max-warnings=0` +- [X] T027 Run `npx ng test angular-sdk-components --watch=false` and confirm coverage has not regressed against the baseline recorded in T001 +- [ ] T028 Execute quickstart scenarios 1–8 in **portal** mode against a live server per [quickstart.md](./quickstart.md) +- [ ] T029 Execute quickstart scenarios 1–8 in **embedded** mode — required for infrastructure container changes by Principle IV +- [X] T030 [P] Run `npm run build-overrides` and confirm it succeeds with the new component present in the generated `packages/angular-sdk-overrides/` output +- [ ] T031 Confirm the FR-012 regressions explicitly: work case action menus, work case modals, work case banners, and the multi-record embedded-data modal footer are all unchanged +- [X] T032 [P] Review the diff for convention compliance — no `any` on the new typed inputs, `$`-suffix and `b`-prefix template properties, no hard-coded user-facing strings, no unrelated changes or dead code + +--- + +## Implementation Notes + +### Outstanding tasks (require a live Pega Infinity server) + +T002, T025, T028, T029, and T031 remain unchecked because they can only be verified against +a running application and a configured Infinity environment. All code-level work is complete +and unit-tested; these are live-environment validation steps. + +### Blockers discovered and fixed during implementation + +1. **The unit test suite did not compile.** `packages/angular-sdk-components/tsconfig.spec.json` + overrode `types` with `["jasmine"]`, dropping `pcore-pconnect-typedefs` inherited from the + root config. Because `types` replaces rather than merges, every reference to the global + `PCore` failed with TS2304. Fixed by restoring the type entry; this blocked all mandated + test tasks. + +2. **The application is zoneless.** `provideZonelessChangeDetection()` is used at bootstrap and + `zone.js` is not a dependency. Async promise callbacks therefore do not trigger change + detection on their own, so the new component and the banner refresh both call + `markForCheck()` explicitly. The existing `ListViewActionButtons` pattern was intentionally + **not** copied, since its `.finally()` reset would not re-render. + +3. **`httpMessages` was never reaching the banner builder**, confirming research R4. In + addition, because the bridge excludes `httpMessages` from its props diff, a rejected save + does not flag an update at all — so `refreshBanners()` was added to the modal container's + no-update path (research R5 fallback) rather than altering the bridge. + +4. **`AngularPConnectData` did not declare `httpMessages`**, even though the bridge assigns it + at runtime through an untyped parameter. An optional property was added so the container + can read it type-safely. + +### Pre-existing test suite state (not caused by this feature) + +The library's Karma suite was already largely broken: 118 of 125 tests failed at baseline +because the legacy specs use `waitForAsync()` (requires zone.js, absent) and register +standalone components via `declarations:`. Repairing all of them is outside this feature's +scope. The four spec files touched here were rewritten to work in the zoneless environment. + +| | Total | Passing | Failing | +|---|---|---|---| +| Baseline | 125 | 7 | 118 | +| After this feature | 152 | 37 | 115 | + +All 27 new tests pass, 3 previously-failing tests were fixed, and no test regressed. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Phase 2 only. Independent of US2 and US3 +- **User Story 2 (Phase 4)**: Depends on Phase 2 only. Independent of US1 +- **User Story 3 (Phase 5)**: Depends on Phase 2; T024 depends on US2 implementation (T015, T016). T023 and T022 are independent of US2 and may proceed in parallel with it +- **Polish (Phase 6)**: Depends on all targeted stories being complete + +### User Story Dependencies + +- **US1 (P1)**: Fully independent — touches only the two case view components +- **US2 (P1)**: Fully independent of US1 — touches the new component, registration, and the modal container +- **US3 (P1)**: Shares files with US2. The banner fix (T023) is independent, but the in-flight disabling (T024) extends the component built in US2 + +### Within Each User Story + +- Tests are written alongside implementation and must pass before the story is considered complete +- Component TypeScript before its template, since the template binds properties defined in the class +- Component creation before registration; registration before the container renders it + +### Parallel Opportunities + +- T002 runs parallel to T001 +- **US1 and US2 can be built simultaneously by two people** — they share no files +- Within US1: the case-view group (T006→T007→T008) and the self-service group (T009→T010) touch different components and run in parallel +- Within US2: T012, T013, T014, and T018 are different files and can start together +- Test tasks T004/T005, T011, and T021/T022 are each in separate files and parallelizable +- In Polish: T026, T030, and T032 are independent + +--- + +## Parallel Example: User Story 1 + +```text +Developer A: T006 → T007 → T008 (case-view component) +Developer B: T009 → T010 (self-service-case-view component) +Both: T004 and T005 in parallel (separate spec files) +``` + +--- + +## Implementation Strategy + +### MVP scope + +**User Story 1 alone is the MVP.** It resolves the most visible defect — the Actions menu +that opens completely empty — and requires no changes to the modal container or any new +component. It can be merged and shipped before US2 and US3 exist. + +### Incremental delivery + +1. Complete Phase 1 and Phase 2 (3 tasks) → shared types in place +2. Complete Phase 3 → **MVP shippable**: action menus populated and correctly disabled +3. Complete Phase 4 → data object modals become usable end to end +4. Complete Phase 5 → failure handling hardened +5. Complete Phase 6 → validated in both modes and ready for review + +### Risk sequencing + +The highest-uncertainty task is **T025** (banner rendering after a rejected save), because +`httpMessages` is deliberately excluded from the bridge's props diff. Tackle T023 early in +Phase 5 so any need for the change-detection fallback is discovered before the phase closes, +and keep the remedy inside the new component so the shared bridge stays untouched. From b2022bf4eaa07368834e6ee3548c35e8ec38e168 Mon Sep 17 00:00:00 2001 From: sambhu Date: Tue, 22 Sep 2026 14:50:44 +0530 Subject: [PATCH 2/3] refactor: move data view action buttons under the modal view container --- .../src/lib/_bridge/helpers/sdk-pega-component-map.ts | 2 +- .../data-view-action-buttons.component.html | 0 .../data-view-action-buttons.component.spec.ts | 0 .../data-view-action-buttons.component.ts | 0 packages/angular-sdk-components/src/public-api.ts | 2 +- 5 files changed, 2 insertions(+), 2 deletions(-) rename packages/angular-sdk-components/src/lib/_components/{field => infra/Containers/modal-view-container}/data-view-action-buttons/data-view-action-buttons.component.html (100%) rename packages/angular-sdk-components/src/lib/_components/{field => infra/Containers/modal-view-container}/data-view-action-buttons/data-view-action-buttons.component.spec.ts (100%) rename packages/angular-sdk-components/src/lib/_components/{field => infra/Containers/modal-view-container}/data-view-action-buttons/data-view-action-buttons.component.ts (100%) diff --git a/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts b/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts index 7a06393e..a10a7fbf 100644 --- a/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts +++ b/packages/angular-sdk-components/src/lib/_bridge/helpers/sdk-pega-component-map.ts @@ -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'; @@ -22,7 +23,6 @@ import { AutoCompleteComponent } from '../../_components/field/auto-complete/aut import { CancelAlertComponent } from '../../_components/field/cancel-alert/cancel-alert.component'; import { CheckBoxComponent } from '../../_components/field/check-box/check-box.component'; import { CurrencyComponent } from '../../_components/field/currency/currency.component'; -import { DataViewActionButtonsComponent } from '../../_components/field/data-view-action-buttons/data-view-action-buttons.component'; import { DateComponent } from '../../_components/field/date/date.component'; import { DateTimeComponent } from '../../_components/field/date-time/date-time.component'; import { DecimalComponent } from '../../_components/field/decimal/decimal.component'; diff --git a/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.html b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/data-view-action-buttons/data-view-action-buttons.component.html similarity index 100% rename from packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.html rename to packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/data-view-action-buttons/data-view-action-buttons.component.html diff --git a/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.spec.ts b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/data-view-action-buttons/data-view-action-buttons.component.spec.ts similarity index 100% rename from packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.spec.ts rename to packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/data-view-action-buttons/data-view-action-buttons.component.spec.ts diff --git a/packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.ts b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/data-view-action-buttons/data-view-action-buttons.component.ts similarity index 100% rename from packages/angular-sdk-components/src/lib/_components/field/data-view-action-buttons/data-view-action-buttons.component.ts rename to packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/data-view-action-buttons/data-view-action-buttons.component.ts diff --git a/packages/angular-sdk-components/src/public-api.ts b/packages/angular-sdk-components/src/public-api.ts index c09fe396..fc594a2a 100644 --- a/packages/angular-sdk-components/src/public-api.ts +++ b/packages/angular-sdk-components/src/public-api.ts @@ -14,7 +14,6 @@ export * from './lib/_components/field/auto-complete/auto-complete.component'; export * from './lib/_components/field/cancel-alert/cancel-alert.component'; export * from './lib/_components/field/check-box/check-box.component'; export * from './lib/_components/field/currency/currency.component'; -export * from './lib/_components/field/data-view-action-buttons/data-view-action-buttons.component'; export * from './lib/_components/field/date-time/date-time.component'; export * from './lib/_components/field/date/date.component'; export * from './lib/_components/field/decimal/decimal.component'; @@ -42,6 +41,7 @@ export * from './lib/_components/infra/assignment-card/assignment-card.component export * from './lib/_components/infra/Containers/flow-container/flow-container.component'; export * from './lib/_components/infra/Containers/flow-container/helpers'; export * from './lib/_components/infra/Containers/hybrid-view-container/hybrid-view-container.component'; +export * from './lib/_components/infra/Containers/modal-view-container/data-view-action-buttons/data-view-action-buttons.component'; export * from './lib/_components/infra/Containers/modal-view-container/modal-view-container.component'; export * from './lib/_components/infra/Containers/preview-view-container/preview-view-container.component'; export * from './lib/_components/infra/Containers/view-container/view-container.component'; From 457dda8875aa2342be909726242cb9e612519c13 Mon Sep 17 00:00:00 2001 From: sambhu Date: Tue, 22 Sep 2026 18:06:00 +0530 Subject: [PATCH 3/3] fix(assignment): scope validation banners to their own container item and drop unused http message plumbing from the modal view container --- .../src/lib/_bridge/angular-pconnect.ts | 10 ++++------ .../modal-view-container.component.ts | 17 +--------------- .../assignment/assignment.component.html | 2 +- .../infra/assignment/assignment.component.ts | 4 +++- .../src/lib/_services/banner.service.ts | 20 +++++++++++++++---- 5 files changed, 25 insertions(+), 28 deletions(-) diff --git a/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts b/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts index 8751c75a..da8d80a0 100644 --- a/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts +++ b/packages/angular-sdk-components/src/lib/_bridge/angular-pconnect.ts @@ -9,8 +9,6 @@ export interface AngularPConnectData { compID?: string; unsubscribeFn?: Function; validateMessage?: string; - // Captured here rather than left in props, so it is excluded from the props diff below. - httpMessages?: any; actions?: { onChange: Function; onBlur: Function; @@ -375,10 +373,10 @@ export class AngularPConnectService { delete incomingProps.pageMessages; } - // Captured here, and cleared when absent, so a stale error cannot leak into the next - // container item. Excluded from the props diff either way. - inComp.angularPConnectData.httpMessages = incomingProps.httpMessages; - incomingProps.httpMessages = undefined; + if (incomingProps.httpMessages) { + inComp.angularPConnectData.httpMessages = incomingProps.httpMessages; + incomingProps.httpMessages = undefined; + } const incomingPropsAsStr: string = JSON.stringify(incomingProps); diff --git a/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts index fc5ae330..dfa28bcf 100755 --- a/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/infra/Containers/modal-view-container/modal-view-container.component.ts @@ -124,10 +124,6 @@ export class ModalViewContainerComponent implements OnInit, OnDestroy { // right now onlu get one updated when initial diaplay. So, once modal is up // let fall through and do a check with "compareCaseInfoIsDifferent" until fixed // this.updateSelf(); - - // httpMessages are excluded from the bridge's props diff, so a rejected save does not - // flag an update; refresh banners here so the error still reaches the open modal. - this.refreshBanners(); } } @@ -399,18 +395,7 @@ export class ModalViewContainerComponent implements OnInit, OnDestroy { } getBanners() { - // The bridge captures httpMessages onto angularPConnectData instead of leaving them in - // state props, so they must be merged in explicitly for server errors to render. - return getBanners({ target: this.itemKey$, ...this.stateProps$, httpMessages: this.angularPConnectData.httpMessages }); - } - - refreshBanners() { - this.stateProps$ = this.pConn$.getStateProps(); - const refreshedBanners = this.getBanners(); - if (!isEqual(refreshedBanners, this.banners)) { - this.banners = refreshedBanners; - this.cdRef.markForCheck(); - } + return getBanners({ target: this.itemKey$, ...this.stateProps$ }); } getModalHeading(dataObjectAction, actionName) { diff --git a/packages/angular-sdk-components/src/lib/_components/infra/assignment/assignment.component.html b/packages/angular-sdk-components/src/lib/_components/infra/assignment/assignment.component.html index 1ead4f74..28330701 100644 --- a/packages/angular-sdk-components/src/lib/_components/infra/assignment/assignment.component.html +++ b/packages/angular-sdk-components/src/lib/_components/infra/assignment/assignment.component.html @@ -1,5 +1,5 @@
-
+
= {}; - clearBanners() { - this.banners = []; + private static readonly noBanners: any[] = []; + + getBanners(itemKey: string): any[] { + return this.bannersByItemKey[itemKey] ?? BannerService.noBanners; + } + + clearBanners(itemKey: string) { + delete this.bannersByItemKey[itemKey]; } updateBanners(itemKey) { @@ -27,6 +35,10 @@ export class BannerService { return localizedValue(message, 'Messages'); }); - this.banners = formattedErrors.length ? [{ messages: formattedErrors, variant: 'urgent' }] : []; + if (formattedErrors.length) { + this.bannersByItemKey[itemKey] = [{ messages: formattedErrors, variant: 'urgent' }]; + } else { + this.clearBanners(itemKey); + } } }