From bd7f393db952517defb12470a7e6b22f59e23362 Mon Sep 17 00:00:00 2001 From: Amedeo Lepore Date: Fri, 26 Jun 2026 19:10:22 +0200 Subject: [PATCH] AAE-47634 Fix block task claim checking status on Runtime Bundle (#12009) * AAE-47634 Add taskDetailsSource input to read task from * AAE-47634 Fall back to Query strategy when taskDetailsSource is unsupported * AAE-47634 Reset loading state when task details request fails * AAE-47634 Reload task on taskDetailsSource change --- .../task/services/task-cloud.service.spec.ts | 45 ++++++++ .../lib/task/services/task-cloud.service.ts | 26 +++++ ...query-task-details-source.strategy.spec.ts | 61 ++++++++++ .../query-task-details-source.strategy.ts | 43 +++++++ ...undle-task-details-source.strategy.spec.ts | 107 +++++++++++++++++ ...ime-bundle-task-details-source.strategy.ts | 55 +++++++++ .../task-details-source.strategy.ts | 34 ++++++ .../user-task-cloud.component.spec.ts | 108 ++++++++++++++++++ .../user-task-cloud.component.ts | 36 +++++- 9 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/query-task-details-source.strategy.spec.ts create mode 100644 lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/query-task-details-source.strategy.ts create mode 100644 lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/runtime-bundle-task-details-source.strategy.spec.ts create mode 100644 lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/runtime-bundle-task-details-source.strategy.ts create mode 100644 lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/task-details-source.strategy.ts diff --git a/lib/process-services-cloud/src/lib/task/services/task-cloud.service.spec.ts b/lib/process-services-cloud/src/lib/task/services/task-cloud.service.spec.ts index 425cd2b695..444907140b 100644 --- a/lib/process-services-cloud/src/lib/task/services/task-cloud.service.spec.ts +++ b/lib/process-services-cloud/src/lib/task/services/task-cloud.service.spec.ts @@ -578,4 +578,49 @@ describe('Task Cloud Service', () => { expect(result).toBe(false); }); }); + + describe('canClaimTaskByState', () => { + it('should return true for a created, non-standalone task without relying on permissions', () => { + const task = { ...createdTaskDetailsCloudMock, status: TASK_CREATED_STATE, standalone: false, permissions: undefined }; + expect(service.canClaimTaskByState(task)).toBe(true); + }); + + it('should return false when the task is not in the created state', () => { + const task = { ...createdTaskDetailsCloudMock, status: TASK_ASSIGNED_STATE, standalone: false }; + expect(service.canClaimTaskByState(task)).toBe(false); + }); + + it('should return false when the task is standalone', () => { + const task = { ...createdTaskDetailsCloudMock, status: TASK_CREATED_STATE, standalone: true }; + expect(service.canClaimTaskByState(task)).toBe(false); + }); + }); + + describe('canUnclaimTaskByState', () => { + it('should return true for an assigned, non-standalone task assigned to the current user without relying on permissions', () => { + const task = { + ...assignedTaskDetailsCloudMock, + status: TASK_ASSIGNED_STATE, + assignee: 'AssignedTaskUser', + standalone: false, + permissions: undefined + }; + expect(service.canUnclaimTaskByState(task)).toBe(true); + }); + + it('should return false when the task is assigned to a different user', () => { + const task = { ...assignedTaskDetailsCloudMock, status: TASK_ASSIGNED_STATE, assignee: 'DifferentUser', standalone: false }; + expect(service.canUnclaimTaskByState(task)).toBe(false); + }); + + it('should return false when the task is not in the assigned state', () => { + const task = { ...assignedTaskDetailsCloudMock, status: TASK_CREATED_STATE, assignee: 'AssignedTaskUser', standalone: false }; + expect(service.canUnclaimTaskByState(task)).toBe(false); + }); + + it('should return false when the task is standalone', () => { + const task = { ...assignedTaskDetailsCloudMock, status: TASK_ASSIGNED_STATE, assignee: 'AssignedTaskUser', standalone: true }; + expect(service.canUnclaimTaskByState(task)).toBe(false); + }); + }); }); diff --git a/lib/process-services-cloud/src/lib/task/services/task-cloud.service.ts b/lib/process-services-cloud/src/lib/task/services/task-cloud.service.ts index b7ee44e57a..b9d0330e3e 100644 --- a/lib/process-services-cloud/src/lib/task/services/task-cloud.service.ts +++ b/lib/process-services-cloud/src/lib/task/services/task-cloud.service.ts @@ -130,6 +130,32 @@ export class TaskCloudService extends BaseCloudService { ); } + /** + * Validate if a task can be claimed based solely on its state, ignoring candidate + * `permissions`. Intended for callers reading the task from the Runtime Bundle, whose + * responses do not include the `permissions` evaluated by the (eventually consistent) + * Query Service. + * + * @param taskDetails task details object + * @returns Boolean value if the task can be claimed + */ + canClaimTaskByState(taskDetails: TaskDetailsCloudModel): boolean { + return taskDetails?.status === TASK_CREATED_STATE && !taskDetails?.standalone; + } + + /** + * Validate if a task can be unclaimed based solely on its state and assignee, ignoring + * candidate `permissions`. Intended for callers reading the task from the Runtime Bundle, + * whose responses do not include the `permissions` evaluated by the (eventually consistent) + * Query Service. + * + * @param taskDetails task details object + * @returns Boolean value if the task can be unclaimed + */ + canUnclaimTaskByState(taskDetails: TaskDetailsCloudModel): boolean { + return taskDetails?.status === TASK_ASSIGNED_STATE && this.isAssignedToMe(taskDetails?.assignee) && !taskDetails?.standalone; + } + /** * Returns the next recommended task to process. * diff --git a/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/query-task-details-source.strategy.spec.ts b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/query-task-details-source.strategy.spec.ts new file mode 100644 index 0000000000..a18defa795 --- /dev/null +++ b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/query-task-details-source.strategy.spec.ts @@ -0,0 +1,61 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { TaskCloudService } from '../../../../services/task-cloud.service'; +import { TaskDetailsCloudModel } from '../../../../models/task-details-cloud.model'; +import { QueryTaskDetailsSourceStrategy } from './query-task-details-source.strategy'; + +describe('QueryTaskDetailsSourceStrategy', () => { + let strategy: QueryTaskDetailsSourceStrategy; + let taskCloudService: jasmine.SpyObj; + + const taskDetails = { id: 'task-1' } as TaskDetailsCloudModel; + + beforeEach(() => { + taskCloudService = jasmine.createSpyObj('TaskCloudService', ['getTaskById', 'canClaimTask', 'canUnclaimTask']); + + TestBed.configureTestingModule({ + providers: [QueryTaskDetailsSourceStrategy, { provide: TaskCloudService, useValue: taskCloudService }] + }); + + strategy = TestBed.inject(QueryTaskDetailsSourceStrategy); + }); + + it('should fetch the task details from the Query Service', () => { + taskCloudService.getTaskById.and.returnValue(of(taskDetails)); + + strategy.getTaskDetails$('app', 'task-1').subscribe(); + + expect(taskCloudService.getTaskById).toHaveBeenCalledWith('app', 'task-1'); + }); + + it('should delegate claim eligibility to canClaimTask', () => { + taskCloudService.canClaimTask.and.returnValue(true); + + expect(strategy.canClaim(taskDetails)).toBe(true); + expect(taskCloudService.canClaimTask).toHaveBeenCalledWith(taskDetails); + }); + + it('should delegate unclaim eligibility to canUnclaimTask', () => { + taskCloudService.canUnclaimTask.and.returnValue(true); + + expect(strategy.canUnclaim(taskDetails)).toBe(true); + expect(taskCloudService.canUnclaimTask).toHaveBeenCalledWith(taskDetails); + }); +}); diff --git a/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/query-task-details-source.strategy.ts b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/query-task-details-source.strategy.ts new file mode 100644 index 0000000000..a8f46ac42b --- /dev/null +++ b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/query-task-details-source.strategy.ts @@ -0,0 +1,43 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { TaskCloudService } from '../../../../services/task-cloud.service'; +import { TaskDetailsCloudModel } from '../../../../models/task-details-cloud.model'; +import { TaskDetailsSourceStrategy } from './task-details-source.strategy'; + +/** + * Reads task details from the Query Service and evaluates claim/unclaim eligibility from + * the candidate `permissions` it returns. + */ +@Injectable({ providedIn: 'root' }) +export class QueryTaskDetailsSourceStrategy implements TaskDetailsSourceStrategy { + private readonly taskCloudService = inject(TaskCloudService); + + getTaskDetails$(appName: string, taskId: string): Observable { + return this.taskCloudService.getTaskById(appName, taskId); + } + + canClaim(taskDetails: TaskDetailsCloudModel): boolean { + return this.taskCloudService.canClaimTask(taskDetails); + } + + canUnclaim(taskDetails: TaskDetailsCloudModel): boolean { + return this.taskCloudService.canUnclaimTask(taskDetails); + } +} diff --git a/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/runtime-bundle-task-details-source.strategy.spec.ts b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/runtime-bundle-task-details-source.strategy.spec.ts new file mode 100644 index 0000000000..715895e5ee --- /dev/null +++ b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/runtime-bundle-task-details-source.strategy.spec.ts @@ -0,0 +1,107 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { TaskCloudService } from '../../../../services/task-cloud.service'; +import { TaskDetailsCloudModel } from '../../../../models/task-details-cloud.model'; +import { RuntimeBundleTaskDetailsSourceStrategy } from './runtime-bundle-task-details-source.strategy'; + +describe('RuntimeBundleTaskDetailsSourceStrategy', () => { + let strategy: RuntimeBundleTaskDetailsSourceStrategy; + let taskCloudService: jasmine.SpyObj; + + const taskDetails = { id: 'task-1' } as TaskDetailsCloudModel; + + beforeEach(() => { + taskCloudService = jasmine.createSpyObj('TaskCloudService', [ + 'getTaskById', + 'canClaimTask', + 'canClaimTaskByState', + 'canUnclaimTask', + 'canUnclaimTaskByState' + ]); + + TestBed.configureTestingModule({ + providers: [RuntimeBundleTaskDetailsSourceStrategy, { provide: TaskCloudService, useValue: taskCloudService }] + }); + + strategy = TestBed.inject(RuntimeBundleTaskDetailsSourceStrategy); + }); + + it('should fetch the task details from the Runtime Bundle', () => { + taskCloudService.getTaskById.and.returnValue(of(taskDetails)); + + strategy.getTaskDetails$('app', 'task-1').subscribe(); + + expect(taskCloudService.getTaskById).toHaveBeenCalledWith('app', 'task-1', 'rb'); + }); + + it('should fall back to the Query Service when the Runtime Bundle returns 404 for a terminal task', () => { + taskCloudService.getTaskById.withArgs('app', 'task-1', 'rb').and.returnValue(throwError(() => ({ status: 404 }))); + taskCloudService.getTaskById.withArgs('app', 'task-1', 'query').and.returnValue(of(taskDetails)); + + let result: TaskDetailsCloudModel; + strategy.getTaskDetails$('app', 'task-1').subscribe((task) => (result = task)); + + expect(taskCloudService.getTaskById).toHaveBeenCalledWith('app', 'task-1', 'query'); + expect(result).toEqual(taskDetails); + }); + + it('should propagate non-404 errors from the Runtime Bundle without falling back', () => { + const error = { status: 500 }; + taskCloudService.getTaskById.withArgs('app', 'task-1', 'rb').and.returnValue(throwError(() => error)); + + let caught: unknown; + strategy.getTaskDetails$('app', 'task-1').subscribe({ error: (err) => (caught = err) }); + + expect(caught).toBe(error); + expect(taskCloudService.getTaskById).not.toHaveBeenCalledWith('app', 'task-1', 'query'); + }); + + it('should delegate claim eligibility to the state-based check when permissions are absent', () => { + taskCloudService.canClaimTaskByState.and.returnValue(true); + + expect(strategy.canClaim({ ...taskDetails, permissions: undefined })).toBe(true); + expect(taskCloudService.canClaimTaskByState).toHaveBeenCalled(); + expect(taskCloudService.canClaimTask).not.toHaveBeenCalled(); + }); + + it('should delegate claim eligibility to the permission-based check when permissions are present', () => { + taskCloudService.canClaimTask.and.returnValue(true); + + expect(strategy.canClaim({ ...taskDetails, permissions: ['CLAIM'] })).toBe(true); + expect(taskCloudService.canClaimTask).toHaveBeenCalled(); + expect(taskCloudService.canClaimTaskByState).not.toHaveBeenCalled(); + }); + + it('should delegate unclaim eligibility to the state-based check when permissions are absent', () => { + taskCloudService.canUnclaimTaskByState.and.returnValue(true); + + expect(strategy.canUnclaim({ ...taskDetails, permissions: undefined })).toBe(true); + expect(taskCloudService.canUnclaimTaskByState).toHaveBeenCalled(); + expect(taskCloudService.canUnclaimTask).not.toHaveBeenCalled(); + }); + + it('should delegate unclaim eligibility to the permission-based check when permissions are present', () => { + taskCloudService.canUnclaimTask.and.returnValue(true); + + expect(strategy.canUnclaim({ ...taskDetails, permissions: ['RELEASE'] })).toBe(true); + expect(taskCloudService.canUnclaimTask).toHaveBeenCalled(); + expect(taskCloudService.canUnclaimTaskByState).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/runtime-bundle-task-details-source.strategy.ts b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/runtime-bundle-task-details-source.strategy.ts new file mode 100644 index 0000000000..e641e2f45c --- /dev/null +++ b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/runtime-bundle-task-details-source.strategy.ts @@ -0,0 +1,55 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { inject, Injectable } from '@angular/core'; +import { catchError, Observable, throwError } from 'rxjs'; +import { TaskCloudService } from '../../../../services/task-cloud.service'; +import { TaskDetailsCloudModel } from '../../../../models/task-details-cloud.model'; +import { TaskDetailsSourceStrategy } from './task-details-source.strategy'; + +/** + * Reads task details from the Runtime Bundle (always up to date, unlike the eventually + * consistent Query Service). Terminal tasks are no longer served by the Runtime Bundle, so a + * `404` transparently falls back to the Query Service. + * + * Claim/unclaim eligibility prefers the candidate `permissions` when the Runtime Bundle + * provides them, and falls back to a task-state evaluation when it does not (its responses + * historically omit `permissions`). This way the strategy keeps working as-is if the Runtime + * Bundle starts returning `permissions` in the future. + */ +@Injectable({ providedIn: 'root' }) +export class RuntimeBundleTaskDetailsSourceStrategy implements TaskDetailsSourceStrategy { + private readonly taskCloudService = inject(TaskCloudService); + + getTaskDetails$(appName: string, taskId: string): Observable { + return this.taskCloudService + .getTaskById(appName, taskId, 'rb') + .pipe( + catchError((error) => (error?.status === 404 ? this.taskCloudService.getTaskById(appName, taskId, 'query') : throwError(() => error))) + ); + } + + canClaim(taskDetails: TaskDetailsCloudModel): boolean { + return taskDetails?.permissions ? this.taskCloudService.canClaimTask(taskDetails) : this.taskCloudService.canClaimTaskByState(taskDetails); + } + + canUnclaim(taskDetails: TaskDetailsCloudModel): boolean { + return taskDetails?.permissions + ? this.taskCloudService.canUnclaimTask(taskDetails) + : this.taskCloudService.canUnclaimTaskByState(taskDetails); + } +} diff --git a/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/task-details-source.strategy.ts b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/task-details-source.strategy.ts new file mode 100644 index 0000000000..ca7e6379ee --- /dev/null +++ b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/task-details-source/task-details-source.strategy.ts @@ -0,0 +1,34 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable } from 'rxjs'; +import { TaskDetailsCloudModel } from '../../../../models/task-details-cloud.model'; + +export type TaskDetailsSource = 'query' | 'rb'; + +/** + * Encapsulates how a task's details are read and how its claim/unclaim eligibility is + * evaluated for a given backend source, so the component can stay agnostic of the source. + */ +export interface TaskDetailsSourceStrategy { + /** Fetch the task details for the given app and task. */ + getTaskDetails$(appName: string, taskId: string): Observable; + /** Whether the task can be claimed. */ + canClaim(taskDetails: TaskDetailsCloudModel): boolean; + /** Whether the task can be unclaimed. */ + canUnclaim(taskDetails: TaskDetailsCloudModel): boolean; +} diff --git a/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/user-task-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/user-task-cloud.component.spec.ts index cd81be2f48..9880c22e8a 100644 --- a/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/user-task-cloud.component.spec.ts +++ b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/user-task-cloud.component.spec.ts @@ -153,6 +153,105 @@ describe('UserTaskCloudComponent', () => { }); }); + describe('taskDetailsSource', () => { + beforeEach(() => { + fixture.componentRef.setInput('appName', 'app1'); + }); + + it('should fetch the task from the Query Service by default', () => { + fixture.componentRef.setInput('taskId', 'task1'); + fixture.detectChanges(); + + expect(getTaskSpy).toHaveBeenCalledWith('app1', 'task1'); + }); + + it('should fetch the task from the Runtime Bundle when taskDetailsSource is rb', () => { + fixture.componentRef.setInput('taskDetailsSource', 'rb'); + fixture.componentRef.setInput('taskId', 'task1'); + fixture.detectChanges(); + + expect(getTaskSpy).toHaveBeenCalledWith('app1', 'task1', 'rb'); + }); + + it('should fall back to the Query Service when the Runtime Bundle returns 404 for a terminal task', () => { + getTaskSpy.withArgs('app1', 'task1', 'rb').and.returnValue(throwError(() => ({ status: 404 }))); + getTaskSpy.withArgs('app1', 'task1', 'query').and.returnValue(of(taskDetails)); + fixture.componentRef.setInput('taskDetailsSource', 'rb'); + fixture.componentRef.setInput('taskId', 'task1'); + fixture.detectChanges(); + + expect(getTaskSpy).toHaveBeenCalledWith('app1', 'task1', 'rb'); + expect(getTaskSpy).toHaveBeenCalledWith('app1', 'task1', 'query'); + expect(component.taskDetails).toEqual(taskDetails); + expect(errorEmitSpy).not.toHaveBeenCalled(); + }); + + it('should fall back to the Query Service when taskDetailsSource is an unsupported value', () => { + fixture.componentRef.setInput('taskDetailsSource', 'unsupported'); + fixture.componentRef.setInput('taskId', 'task1'); + fixture.detectChanges(); + + expect(getTaskSpy).toHaveBeenCalledWith('app1', 'task1'); + expect(component.taskDetails).toEqual(taskDetails); + expect(errorEmitSpy).not.toHaveBeenCalled(); + }); + + it('should reload the task from the new source when taskDetailsSource changes after init', () => { + fixture.componentRef.setInput('taskId', 'task1'); + fixture.detectChanges(); + expect(getTaskSpy).toHaveBeenCalledWith('app1', 'task1'); + + getTaskSpy.calls.reset(); + fixture.componentRef.setInput('taskDetailsSource', 'rb'); + fixture.detectChanges(); + + expect(getTaskSpy).toHaveBeenCalledWith('app1', 'task1', 'rb'); + }); + + it('should emit error when the Runtime Bundle fails with a non-404 error', () => { + const error = { status: 500 }; + getTaskSpy.withArgs('app1', 'task1', 'rb').and.returnValue(throwError(() => error)); + fixture.componentRef.setInput('taskDetailsSource', 'rb'); + fixture.componentRef.setInput('taskId', 'task1'); + fixture.detectChanges(); + + expect(errorEmitSpy).toHaveBeenCalledWith(error); + }); + + describe('claim/unclaim checks in runtime bundle mode (no permissions)', () => { + beforeEach(() => { + fixture.componentRef.setInput('taskDetailsSource', 'rb'); + spyOn(component, 'hasCandidateUsersOrGroups').and.returnValue(true); + }); + + it('should allow claiming a created, non-standalone task even without permissions', () => { + component.taskDetails = { status: TASK_CREATED_STATE, standalone: false } as TaskDetailsCloudModel; + + expect(component.canClaimTask()).toBe(true); + }); + + it('should not allow claiming a task that is not in the created state', () => { + component.taskDetails = { status: TASK_ASSIGNED_STATE, standalone: false } as TaskDetailsCloudModel; + + expect(component.canClaimTask()).toBe(false); + }); + + it('should allow releasing a task assigned to the current user even without permissions', () => { + getCurrentUserSpy.and.returnValue({ username: 'admin.adf' }); + component.taskDetails = { status: TASK_ASSIGNED_STATE, assignee: 'admin.adf', standalone: false } as TaskDetailsCloudModel; + + expect(component.canUnclaimTask()).toBe(true); + }); + + it('should not allow releasing a task assigned to a different user', () => { + getCurrentUserSpy.and.returnValue({ username: 'admin.adf' }); + component.taskDetails = { status: TASK_ASSIGNED_STATE, assignee: 'another.user', standalone: false } as TaskDetailsCloudModel; + + expect(component.canUnclaimTask()).toBe(false); + }); + }); + }); + describe('Claim/Unclaim buttons', () => { beforeEach(() => { spyOn(component, 'hasCandidateUsers').and.returnValue(true); @@ -339,6 +438,15 @@ describe('UserTaskCloudComponent', () => { expect(errorEmitSpy).toHaveBeenCalledWith('getTaskyById error'); }); + + it('should reset the loading state when getTaskById fails', async () => { + getTaskSpy.and.returnValue(throwError(() => 'getTaskyById error')); + component.taskId = 'task1'; + component.ngOnChanges({ appName: new SimpleChange(null, 'app1', false) }); + await fixture.whenStable(); + + expect(component.loading).toBe(false); + }); }); describe('Events', () => { diff --git a/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/user-task-cloud.component.ts b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/user-task-cloud.component.ts index 7dec2eebd0..1343d18f0d 100644 --- a/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/user-task-cloud.component.ts +++ b/lib/process-services-cloud/src/lib/task/task-form/components/user-task-cloud/user-task-cloud.component.ts @@ -33,6 +33,9 @@ import { CompleteTaskDirective } from './complete-task/complete-task.directive'; import { catchError, EMPTY, forkJoin } from 'rxjs'; import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox'; import { UserTaskContentType, TaskTypeResolverService, UserTaskType } from '../../../../services/task-type-resolver/task-type-resolver.service'; +import { TaskDetailsSource, TaskDetailsSourceStrategy } from './task-details-source/task-details-source.strategy'; +import { QueryTaskDetailsSourceStrategy } from './task-details-source/query-task-details-source.strategy'; +import { RuntimeBundleTaskDetailsSourceStrategy } from './task-details-source/runtime-bundle-task-details-source.strategy'; @Component({ selector: 'adf-cloud-user-task', @@ -131,6 +134,17 @@ export class UserTaskCloudComponent implements OnInit, OnChanges { @Input() taskId: string; + /** + * Backend service used to fetch the task details. + * + * Defaults to `'query'` (Query Service). Set to `'rb'` to read the task from + * the Runtime Bundle, which is always up to date (the Query Service is + * eventually consistent). In `'rb'` mode, terminal tasks that are no longer + * served by the Runtime Bundle are transparently fetched from the Query Service. + */ + @Input() + taskDetailsSource: TaskDetailsSource = 'query'; + /** Emitted when the cancel button is clicked. */ @Output() cancelClick = new EventEmitter(); @@ -192,6 +206,11 @@ export class UserTaskCloudComponent implements OnInit, OnChanges { private readonly taskTypeResolverService = inject(TaskTypeResolverService); private readonly destroyRef = inject(DestroyRef); + private readonly taskDetailsSourceStrategies: Record = { + query: inject(QueryTaskDetailsSourceStrategy), + rb: inject(RuntimeBundleTaskDetailsSourceStrategy) + }; + ngOnChanges(changes: SimpleChanges) { const appName = changes['appName']; if (appName && appName.currentValue !== appName.previousValue && this.taskId) { @@ -204,6 +223,12 @@ export class UserTaskCloudComponent implements OnInit, OnChanges { this.loadTask(); return; } + + const taskDetailsSource = changes['taskDetailsSource']; + if (taskDetailsSource && !taskDetailsSource.firstChange && this.appName && this.taskId) { + this.loadTask(); + return; + } } ngOnInit() { @@ -213,7 +238,7 @@ export class UserTaskCloudComponent implements OnInit, OnChanges { } canClaimTask(): boolean { - return !this.readOnly && this.taskCloudService.canClaimTask(this.taskDetails) && this.hasCandidateUsersOrGroups(); + return !this.readOnly && this.taskDetailsStrategy.canClaim(this.taskDetails) && this.hasCandidateUsersOrGroups(); } canCompleteTask(): boolean { @@ -221,7 +246,7 @@ export class UserTaskCloudComponent implements OnInit, OnChanges { } canUnclaimTask(): boolean { - return !this.readOnly && this.taskCloudService.canUnclaimTask(this.taskDetails) && this.hasCandidateUsersOrGroups(); + return !this.readOnly && this.taskDetailsStrategy.canUnclaim(this.taskDetails) && this.hasCandidateUsersOrGroups(); } getTaskType(): void { @@ -299,7 +324,7 @@ export class UserTaskCloudComponent implements OnInit, OnChanges { private loadTask(): void { this.loading = true; - const tasks$ = this.taskCloudService.getTaskById(this.appName, this.taskId); + const tasks$ = this.taskDetailsStrategy.getTaskDetails$(this.appName, this.taskId); const candidateUsers$ = this.taskCloudService.getCandidateUsers(this.appName, this.taskId); const candidateGroups$ = this.taskCloudService.getCandidateGroups(this.appName, this.taskId); @@ -311,6 +336,7 @@ export class UserTaskCloudComponent implements OnInit, OnChanges { .pipe( takeUntilDestroyed(this.destroyRef), catchError((error) => { + this.loading = false; this.onError(error); return EMPTY; }) @@ -325,6 +351,10 @@ export class UserTaskCloudComponent implements OnInit, OnChanges { }); } + private get taskDetailsStrategy(): TaskDetailsSourceStrategy { + return this.taskDetailsSourceStrategies[this.taskDetailsSource] ?? this.taskDetailsSourceStrategies.query; + } + public switchToDisplayMode(newDisplayMode?: string): void { if (this.adfCloudTaskForm) { this.adfCloudTaskForm.switchToDisplayMode(newDisplayMode);