diff --git a/.commit b/.commit index f85d1bc5f..5b11d2744 100644 --- a/.commit +++ b/.commit @@ -1 +1 @@ -8c6dad65a72631732d54c5a497f3d7529c0ec564 +800253d0db65542d7805bc592b40cf4fd7b0b785 diff --git a/.sync-history b/.sync-history index 9aa7c8ba4..e69de29bb 100644 --- a/.sync-history +++ b/.sync-history @@ -1 +0,0 @@ -9900fc181 2026-07-08 Merged PR 94405: #724499 - TSB: Fix parallel cart creation when requesting multiple group memberships \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0984aa0..d63982dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### October 6, 2026 +- 725999 - Web Portal: Search approvals after loading. +- 736614 - Angular read-only view-property honors DateFormat metadata. +- 709042 - Web Portal: Do not resubmit unchanged Valid From on single approval. + ### July 30, 2026 - 724499 - Fixing parallel cart creation when requesting multiple group memberships. diff --git a/imxweb/projects/att/src/lib/decision/attestation-cases.service.ts b/imxweb/projects/att/src/lib/decision/attestation-cases.service.ts index 2586127e7..ddd93698e 100644 --- a/imxweb/projects/att/src/lib/decision/attestation-cases.service.ts +++ b/imxweb/projects/att/src/lib/decision/attestation-cases.service.ts @@ -94,6 +94,7 @@ export class AttestationCasesService { const collection = await this.attClient.typedClient.PortalAttestationApprove.Get(attDecisionParameters, { signal }); if (!collection) return { totalCount: 0, Data: [] }; return { + ...collection, tableName: collection.tableName, totalCount: collection.totalCount, Data: collection.Data.map((item: PortalAttestationApprove, index: number) => { diff --git a/imxweb/projects/qbm/src/lib/cdr/view-property/view-property.component.spec.ts b/imxweb/projects/qbm/src/lib/cdr/view-property/view-property.component.spec.ts new file mode 100644 index 000000000..a9393adff --- /dev/null +++ b/imxweb/projects/qbm/src/lib/cdr/view-property/view-property.component.spec.ts @@ -0,0 +1,171 @@ +/* + * ONE IDENTITY LLC. PROPRIETARY INFORMATION + * + * This software is confidential. One Identity, LLC. or one of its affiliates or + * subsidiaries, has supplied this software to you under terms of a + * license agreement, nondisclosure agreement or both. + * + * You may not copy, disclose, or use this software except in accordance with + * those terms. + * + * + * Copyright 2026 One Identity LLC. + * ALL RIGHTS RESERVED. + * + * ONE IDENTITY LLC. MAKES NO REPRESENTATIONS OR + * WARRANTIES ABOUT THE SUITABILITY OF THE SOFTWARE, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, OR + * NON-INFRINGEMENT. ONE IDENTITY LLC. SHALL NOT BE + * LIABLE FOR ANY DAMAGES SUFFERED BY LICENSEE + * AS A RESULT OF USING, MODIFYING OR DISTRIBUTING + * THIS SOFTWARE OR ITS DERIVATIVES. + * + */ + +import { DateFormat, ValType } from '@imx-modules/imx-qbm-dbts'; +import { EntityColumnContainer } from '../entity-column-container'; +import { ImxTranslationProviderService } from '../../translation/imx-translation-provider.service'; +import { ViewPropertyComponent } from './view-property.component'; + +describe('ViewPropertyComponent', () => { + const translation = { CultureFormat: 'en-US' } as unknown as ImxTranslationProviderService; + + function build(container: Partial): ViewPropertyComponent { + const component = new ViewPropertyComponent(translation); + component.columnContainer = container as EntityColumnContainer; + component.defaultValue = '#LDS#Not set'; + return component; + } + + function dateContainer(opts: { + value: unknown; + displayValue?: string; + dateFormat?: DateFormat; + }): Partial { + return { + type: ValType.Date, + value: opts.value as any, + displayValue: opts.displayValue, + metaData: { + GetDateFormat: () => opts.dateFormat, + } as any, + }; + } + + it('prefers the server-supplied DisplayValue for a Date-only column (CR-108653)', () => { + const c = build( + dateContainer({ + value: '2000-02-20T00:00:00Z', + displayValue: '20-2-2000', + dateFormat: DateFormat.Date, + }), + ); + + expect(c.displayedValue).toBe('20-2-2000'); + expect(c.displayedValue).not.toMatch(/[0-9]:[0-9]/); + }); + + it('prefers the server-supplied DisplayValue for a DateTime column', () => { + const c = build( + dateContainer({ + value: '2000-02-20T00:00:00Z', + displayValue: '2/20/2000, 1:00:00 AM', + dateFormat: DateFormat.DateTime, + }), + ); + + expect(c.displayedValue).toBe('2/20/2000, 1:00:00 AM'); + }); + + it('falls back to toLocaleDateString for a Date-only column when displayValue is absent', () => { + const c = build( + dateContainer({ + value: '2000-02-20T00:00:00Z', + displayValue: undefined, + dateFormat: DateFormat.Date, + }), + ); + + expect(c.displayedValue).toBe(new Date('2000-02-20T00:00:00Z').toLocaleDateString('en-US')); + expect(c.displayedValue).not.toMatch(/[0-9]:[0-9]/); + }); + + it('falls back to toLocaleString for a DateTime column when displayValue is absent', () => { + const c = build( + dateContainer({ + value: '2000-02-20T00:00:00Z', + displayValue: undefined, + dateFormat: DateFormat.DateTime, + }), + ); + + expect(c.displayedValue).toBe(new Date('2000-02-20T00:00:00Z').toLocaleString('en-US')); + }); + + it('falls back to toUTCString for a UtcDateTime column when displayValue is absent', () => { + const c = build( + dateContainer({ + value: '2000-02-20T00:00:00Z', + displayValue: undefined, + dateFormat: DateFormat.UtcDateTime, + }), + ); + + expect(c.displayedValue).toBe(new Date('2000-02-20T00:00:00Z').toUTCString()); + }); + + it('falls back to toLocaleString when DateFormat metadata is missing (unchanged legacy behavior)', () => { + const c = build( + dateContainer({ + value: '2000-02-20T00:00:00Z', + displayValue: undefined, + dateFormat: undefined, + }), + ); + + expect(c.displayedValue).toBe(new Date('2000-02-20T00:00:00Z').toLocaleString('en-US')); + }); + + it('returns defaultValue for a Date column with a null value and no displayValue', () => { + const c = build( + dateContainer({ + value: null, + displayValue: undefined, + dateFormat: DateFormat.Date, + }), + ); + + expect(c.displayedValue).toBe('#LDS#Not set'); + }); + + it('returns defaultValue for a Date column whose value cannot be parsed', () => { + const c = build( + dateContainer({ + value: 'not-a-date', + displayValue: undefined, + dateFormat: DateFormat.Date, + }), + ); + + expect(c.displayedValue).toBe('#LDS#Not set'); + }); + + it('uses displayValue for non-Date types (unchanged behavior)', () => { + const c = build({ + type: ValType.String, + value: 'raw-value' as any, + displayValue: 'Rendered', + }); + + expect(c.displayedValue).toBe('Rendered'); + }); + + it('returns defaultValue when column container is undefined', () => { + const c = new ViewPropertyComponent(translation); + c.defaultValue = '#LDS#Not set'; + + expect(c.displayedValue).toBe('#LDS#Not set'); + }); +}); diff --git a/imxweb/projects/qbm/src/lib/cdr/view-property/view-property.component.ts b/imxweb/projects/qbm/src/lib/cdr/view-property/view-property.component.ts index 9e40eaa56..b24edeba3 100644 --- a/imxweb/projects/qbm/src/lib/cdr/view-property/view-property.component.ts +++ b/imxweb/projects/qbm/src/lib/cdr/view-property/view-property.component.ts @@ -27,7 +27,7 @@ import { Component, Input } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { ValType } from '@imx-modules/imx-qbm-dbts'; +import { DateFormat, ValType } from '@imx-modules/imx-qbm-dbts'; import { EntityColumnContainer } from '../entity-column-container'; import { ImxTranslationProviderService } from '../../translation/imx-translation-provider.service'; @@ -44,11 +44,26 @@ export class ViewPropertyComponent { public get displayedValue(): string { if (this.columnContainer?.type === ValType.Date) { - if (this.columnContainer?.value == null) { + // Prefer the server-supplied DisplayValue: it already honors DateFormat (DateOnly vs DateTime) via the shared DisplayBuilder. + if (this.columnContainer.displayValue) { + return this.columnContainer.displayValue; + } + if (this.columnContainer.value == null) { return this.defaultValue; } const date: Date = new Date(this.columnContainer.value); - return date.getDate() ? date.toLocaleString(this.translationProviderService.CultureFormat) : this.defaultValue; + if (!date.getDate()) { + return this.defaultValue; + } + const cultureFormat = this.translationProviderService.CultureFormat; + switch (this.columnContainer.metaData?.GetDateFormat()) { + case DateFormat.Date: + return date.toLocaleDateString(cultureFormat); + case DateFormat.UtcDateTime: + return date.toUTCString(); + default: + return date.toLocaleString(cultureFormat); + } } return this.columnContainer?.displayValue || this.columnContainer?.value || this.defaultValue; } diff --git a/imxweb/projects/qer/src/lib/itshopapprove/workflow-action/workflow-action.service.spec.ts b/imxweb/projects/qer/src/lib/itshopapprove/workflow-action/workflow-action.service.spec.ts new file mode 100644 index 000000000..24b4a5076 --- /dev/null +++ b/imxweb/projects/qer/src/lib/itshopapprove/workflow-action/workflow-action.service.spec.ts @@ -0,0 +1,176 @@ +/* + * ONE IDENTITY LLC. PROPRIETARY INFORMATION + * + * This software is confidential. One Identity, LLC. or one of its affiliates or + * subsidiaries, has supplied this software to you under terms of a + * license agreement, nondisclosure agreement or both. + * + * You may not copy, disclose, or use this software except in accordance with + * those terms. + * + * + * Copyright 2026 One Identity LLC. + * ALL RIGHTS RESERVED. + * + * ONE IDENTITY LLC. MAKES NO REPRESENTATIONS OR + * WARRANTIES ABOUT THE SUITABILITY OF THE SOFTWARE, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + * TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE, OR + * NON-INFRINGEMENT. ONE IDENTITY LLC. SHALL NOT BE + * LIABLE FOR ANY DAMAGES SUFFERED BY LICENSEE + * AS A RESULT OF USING, MODIFYING OR DISTRIBUTING + * THIS SOFTWARE OR ITS DERIVATIVES. + * + */ + +import moment from 'moment'; +import { of } from 'rxjs'; + +import { WorkflowActionService } from './workflow-action.service'; + +interface ColumnWrite { + column: string; + value: any; +} + +function createRequest(validFrom: Date | null, validUntil: Date | null) { + const writes: ColumnWrite[] = []; + const createProperty = (name: string, initial: Date | null) => { + let current = initial; + return { + get value() { + return current; + }, + set value(value: any) { + current = value; + writes.push({ column: name, value }); + }, + Column: { + PutValue: async (value: any) => { + current = value; + writes.push({ column: name, value }); + }, + }, + }; + }; + + return { + writes, + ValidFrom: createProperty('ValidFrom', validFrom), + ValidUntil: createProperty('ValidUntil', validUntil), + ApproveReasonType: { value: 0 }, + IsApproveRequiresMfa: { value: false }, + UID_QERTermsOfUse: { value: '' }, + pwoData: undefined, + canSetValidFrom: () => true, + canSetValidUntil: () => true, + commit: jasmine.createSpy('commit').and.resolveTo(), + GetEntity: () => ({ + GetDisplay: () => 'Request', + GetKeys: () => ['uid'], + DiscardChanges: async () => {}, + }), + }; +} + +describe('WorkflowActionService.approve (validity dates)', () => { + let service: WorkflowActionService; + let formGroup: { controls: { [key: string]: { value: any } } }; + let makeDecision: jasmine.Spy; + + const pastDate = moment().startOf('day').subtract(3, 'days'); + + beforeEach(() => { + formGroup = { controls: {} }; + makeDecision = jasmine.createSpy('makeDecision').and.resolveTo(); + + const schema = { Columns: { ValidFrom: { ColumnName: 'ValidFrom' }, ValidUntil: { ColumnName: 'ValidUntil' } } }; + // The general (bulk) columns: only their existence matters here, apply() reads the form controls. + const localColumn = { GetValue: () => undefined }; + + service = new WorkflowActionService( + { typedClient: { PortalItshopApproveRequests: { GetSchema: () => schema } } } as any, + { open: () => ({ afterClosed: () => of(formGroup) }) } as any, + { instant: (key: string) => key } as any, + { createLocalEntityColumn: () => localColumn } as any, + { overlayRefs: [], show: () => {}, hide: () => {} } as any, + { open: () => {} } as any, + { debug: () => {} } as any, + {} as any, + { + getConfig: async () => ({ + ITShopConfig: { + VI_ITShop_ApproverCanSetValidFrom: true, + VI_ITShop_ApproverCanSetValidUntil: true, + StepUpAuthenticationProvider: 'NoAuth', + }, + }), + } as any, + { makeDecision } as any, + { createCdr: async () => undefined } as any, + {} as any, + { reloadPendingItems: async () => {} } as any, + { actionThreshold: 100 } as any, + ); + spyOn(service, 'checkTermsOfUse').and.resolveTo({ isChecked: true, isAuthenticated: true }); + }); + + it('does not resubmit an untouched past ValidFrom for a single request (738689)', async () => { + const request = createRequest(pastDate.toDate(), null); + formGroup.controls.ValidFrom = { value: pastDate.clone() }; + + await service.approve([request as any], 'uidUser', false); + + expect(request.writes.filter((w) => w.column === 'ValidFrom')).toEqual([]); + expect(request.ValidFrom.value).toEqual(pastDate.toDate()); + expect(request.commit).toHaveBeenCalled(); + expect(makeDecision).toHaveBeenCalled(); + }); + + it('writes null when ValidFrom was cleared for a single request (702471)', async () => { + const request = createRequest(pastDate.toDate(), null); + formGroup.controls.ValidFrom = { value: null }; + + await service.approve([request as any], 'uidUser', false); + + expect(request.writes.filter((w) => w.column === 'ValidFrom')).toEqual([{ column: 'ValidFrom', value: null }]); + }); + + it('does not overwrite a valid ValidUntil for a single request (709042)', async () => { + const validUntil = moment().startOf('day').add(10, 'days'); + const request = createRequest(null, validUntil.toDate()); + formGroup.controls.ValidUntil = { value: validUntil.clone() }; + + await service.approve([request as any], 'uidUser', false); + + expect(request.writes.filter((w) => w.column === 'ValidUntil')).toEqual([]); + expect(request.ValidUntil.value).toEqual(validUntil.toDate()); + }); + + it('applies the general ValidFrom to every request in a bulk approval', async () => { + const general = moment().startOf('day').add(2, 'days'); + const requests = [createRequest(null, null), createRequest(pastDate.toDate(), null)]; + formGroup.controls.ValidFrom = { value: general.clone() }; + + await service.approve(requests as any[], 'uidUser', false); + + for (const request of requests) { + const writes = request.writes.filter((w) => w.column === 'ValidFrom'); + expect(writes.length).toBe(1); + expect(moment(writes[0].value).isSame(general, 'day')).toBeTrue(); + } + }); + + it('keeps the individual ValidFrom values when the general ValidFrom is empty in a bulk approval', async () => { + const requests = [createRequest(null, null), createRequest(pastDate.toDate(), null)]; + formGroup.controls.ValidFrom = { value: null }; + + await service.approve(requests as any[], 'uidUser', false); + + for (const request of requests) { + expect(request.writes.filter((w) => w.column === 'ValidFrom')).toEqual([]); + } + expect(requests[1].ValidFrom.value).toEqual(pastDate.toDate()); + }); +}); diff --git a/imxweb/projects/qer/src/lib/itshopapprove/workflow-action/workflow-action.service.ts b/imxweb/projects/qer/src/lib/itshopapprove/workflow-action/workflow-action.service.ts index ab6cb4149..b679e0f5e 100644 --- a/imxweb/projects/qer/src/lib/itshopapprove/workflow-action/workflow-action.service.ts +++ b/imxweb/projects/qer/src/lib/itshopapprove/workflow-action/workflow-action.service.ts @@ -379,7 +379,11 @@ export class WorkflowActionService { if (request.canSetValidFrom() && formGroup.controls.ValidFrom) { const from = formGroup.controls.ValidFrom!.value; if (from) { - request.ValidFrom.value = addTimeNowToDate(from); + // For a single request the control is bound to the request's own column, so any change is already set. + // Writing it again would resubmit an unchanged (possibly past) date. Only apply the general bulk value. + if (requests.length > 1) { + request.ValidFrom.value = addTimeNowToDate(from); + } } else if (requests.length === 1) { // The value was removed, so set it to null in order to not send an invalid date to the backend await request.ValidFrom.Column.PutValue(null);