AAE-47634 Refactor: switch form service getTask to use runtime bundle API (#12020)

* refactor: switch form service getTask to use runtime bundle API instead of query service

* Update form-cloud.service.ts

* Add TaskDetailsCloudModelRuntimeBundle interface

* Update getTaskById to return TaskDetailsCloudModelRuntimeBundle

* Modify return type of getTaskById method

Updated return type of getTaskById to include TaskDetailsCloudModel.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Refactor TaskDetailsCloudModel by removing fields

Removed unused properties from TaskDetailsCloudModel.

* Update task-cloud.service.ts

* Update task-cloud.service.ts

* fix: add 404 fallback to query service in FormCloudService.getTask for completed tasks

* AAE-47634 getTaskById: call Runtime Bundle first, fall back to Query Service on 404

* AAE-47634 Gate Runtime Bundle task fallback behind ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED token and drop no longer used taskDetailsSource input

* refactor: update task fetching tests to use async/await and firstValueFrom

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Eugenio Romano <eromano@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: alep85 <amedeo.lepore@hyland.com>
This commit is contained in:
Copilot
2026-07-08 13:48:09 +00:00
committed by GitHub
co-authored by Copilot Autofix powered by AI Eugenio Romano alep85
parent 997303e6a7
commit 23f0392c12
14 changed files with 344 additions and 440 deletions
@@ -17,9 +17,11 @@
import { TestBed } from '@angular/core/testing';
import { FORM_CLOUD_SERVICE_FIELD_VALIDATORS_TOKEN, FormCloudService } from './form-cloud.service';
import { of } from 'rxjs';
import { BehaviorSubject, firstValueFrom, of } from 'rxjs';
import { AdfHttpClient } from '@alfresco/adf-core/api';
import { FORM_FIELD_VALIDATORS, FormFieldValidator, NoopAuthModule } from '@alfresco/adf-core';
import { HttpErrorResponse } from '@angular/common/http';
import { ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED } from '../../services/task-runtime-bundle-fallback.token';
const mockTaskResponseBody = {
entry: { id: 'id', name: 'name', formKey: 'form-key' }
@@ -37,14 +39,19 @@ describe('Form Cloud service', () => {
let service: FormCloudService;
let adfHttpClient: AdfHttpClient;
let requestSpy: jasmine.Spy;
let runtimeBundleFallback$: BehaviorSubject<boolean>;
const appName = 'app-name';
const taskId = 'task-id';
const processInstanceId = 'process-instance-id';
beforeEach(() => {
runtimeBundleFallback$ = new BehaviorSubject<boolean>(true);
TestBed.configureTestingModule({
imports: [NoopAuthModule],
providers: [{ provide: FORM_CLOUD_SERVICE_FIELD_VALIDATORS_TOKEN, useValue: [fakeValidator] }]
providers: [
{ provide: FORM_CLOUD_SERVICE_FIELD_VALIDATORS_TOKEN, useValue: [fakeValidator] },
{ provide: ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED, useValue: runtimeBundleFallback$ }
]
});
service = TestBed.inject(FormCloudService);
adfHttpClient = TestBed.inject(AdfHttpClient);
@@ -86,17 +93,45 @@ describe('Form Cloud service', () => {
});
describe('Task tests', () => {
it('should fetch and parse task', (done) => {
it('should fetch task from runtime bundle', async () => {
requestSpy.and.returnValue(Promise.resolve(mockTaskResponseBody));
service.getTask(appName, taskId).subscribe((result) => {
expect(result).toBeDefined();
expect(result.id).toBe('id');
expect(result.name).toBe('name');
expect(requestSpy.calls.mostRecent().args[0]).toContain(`${appName}/query/v1/tasks/${taskId}`);
expect(requestSpy.calls.mostRecent().args[1].httpMethod).toBe('GET');
done();
const result = await firstValueFrom(service.getTask(appName, taskId));
expect(result).toBeDefined();
expect(result.id).toBe('id');
expect(result.name).toBe('name');
expect(requestSpy.calls.first().args[0]).toContain(`${appName}/rb/v1/tasks/${taskId}`);
expect(requestSpy.calls.first().args[1].httpMethod).toBe('GET');
});
it('should fall back to query service when runtime bundle returns 404', async () => {
const notFoundError = new HttpErrorResponse({ status: 404 });
requestSpy.and.callFake((url: string) => {
if (url.includes('/rb/')) {
return Promise.reject(notFoundError);
}
return Promise.resolve(mockTaskResponseBody);
});
const result = await firstValueFrom(service.getTask(appName, taskId));
expect(result).toBeDefined();
expect(result.id).toBe('id');
expect(requestSpy.calls.first().args[0]).toContain(`${appName}/rb/v1/tasks/${taskId}`);
expect(requestSpy.calls.mostRecent().args[0]).toContain(`${appName}/query/v1/tasks/${taskId}`);
});
it('should use the query service only when the runtime bundle fallback is disabled', async () => {
runtimeBundleFallback$.next(false);
requestSpy.and.returnValue(Promise.resolve(mockTaskResponseBody));
const result = await firstValueFrom(service.getTask(appName, taskId));
expect(result).toBeDefined();
expect(result.id).toBe('id');
expect(requestSpy.calls.first().args[0]).toContain(`${appName}/query/v1/tasks/${taskId}`);
expect(requestSpy.calls.all().some((call) => call.args[0].includes('/rb/'))).toBe(false);
});
it('should fetch task variables', (done) => {
@@ -17,14 +17,15 @@
import { inject, Injectable, InjectionToken } from '@angular/core';
import { FormValues, FormModel, FormFieldOption, FormFieldValidator, FormService } from '@alfresco/adf-core';
import { Observable, from, EMPTY } from 'rxjs';
import { expand, map, reduce, switchMap } from 'rxjs/operators';
import { Observable, from, EMPTY, throwError } from 'rxjs';
import { catchError, expand, map, reduce, switchMap, take } from 'rxjs/operators';
import { TaskDetailsCloudModel } from '../../task/models/task-details-cloud.model';
import { CompleteFormRepresentation, LazyApi, UploadApi } from '@alfresco/js-api';
import { TaskVariableCloud } from '../models/task-variable-cloud.model';
import { BaseCloudService } from '../../services/base-cloud.service';
import { FormContent } from '../../services/form-fields.interfaces';
import { FormCloudServiceInterface } from './form-cloud.service.interface';
import { ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED, resolveTaskRuntimeBundleFallback$ } from '../../services/task-runtime-bundle-fallback.token';
export const FORM_CLOUD_SERVICE_FIELD_VALIDATORS_TOKEN = new InjectionToken<FormFieldValidator[]>('FORM_CLOUD_SERVICE_FIELD_VALIDATORS_TOKEN');
@@ -34,6 +35,9 @@ export const FORM_CLOUD_SERVICE_FIELD_VALIDATORS_TOKEN = new InjectionToken<Form
export class FormCloudService extends BaseCloudService implements FormCloudServiceInterface {
private readonly fieldValidators: FormFieldValidator[] = inject(FORM_CLOUD_SERVICE_FIELD_VALIDATORS_TOKEN, { optional: true }) ?? [];
private readonly formService = inject(FormService);
private readonly runtimeBundleFallbackEnabled$ = resolveTaskRuntimeBundleFallback$(
inject(ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED, { optional: true })
);
@LazyApi((self: FormCloudService) => new UploadApi(self.apiService.getInstance()))
declare readonly uploadApi: UploadApi;
@@ -132,16 +136,27 @@ export class FormCloudService extends BaseCloudService implements FormCloudServi
}
/**
* Gets details of a task
* Gets details of a task. Tries the Runtime Bundle first (always up to date for
* active tasks) and transparently falls back to the Query Service on 404 so that
* completed/archived tasks can still be loaded.
*
* @param appName Name of the app
* @param taskId ID of the target task
* @returns Details of the task
*/
getTask(appName: string, taskId: string): Observable<TaskDetailsCloudModel> {
const apiUrl = `${this.getBasePath(appName)}/query/v1/tasks/${taskId}`;
const rbUrl = `${this.getBasePath(appName)}/rb/v1/tasks/${taskId}`;
const queryUrl = `${this.getBasePath(appName)}/query/v1/tasks/${taskId}`;
return this.get(apiUrl).pipe(map((res: any) => res.entry));
return this.runtimeBundleFallbackEnabled$.pipe(
take(1),
switchMap((runtimeBundleFirst) =>
runtimeBundleFirst
? this.get(rbUrl).pipe(catchError((error) => (error?.status === 404 ? this.get(queryUrl) : throwError(() => error))))
: this.get(queryUrl)
),
map((res: any) => res.entry)
);
}
/**
@@ -22,6 +22,7 @@ export * from './local-preference-cloud.service';
export * from './notification-cloud.service';
export * from './preference-cloud.interface';
export * from './task-list-cloud.service.interface';
export * from './task-runtime-bundle-fallback.token';
export * from './user-preference-cloud.service';
export * from './variable-mapper.sevice';
export * from './web-socket.service';
@@ -0,0 +1,40 @@
/*!
* @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 { InjectionToken } from '@angular/core';
import { isObservable, Observable, of } from 'rxjs';
/**
* Controls whether task details are read from the Runtime Bundle first, falling back to the
* Query Service on a 404. The Runtime Bundle is always up to date, whereas the Query Service is
* eventually consistent.
*
* When it resolves to a falsy value (the default when not provided), task details are read from
* the Query Service only, preserving the historical behavior for every consumer that does not
* override it. Host applications can wire it to a feature flag, e.g. an `Observable<boolean>`.
*/
export const ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED = new InjectionToken<Observable<boolean> | boolean>('ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED');
/**
* Normalizes the injected {@link ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED} value to an
* `Observable<boolean>`, defaulting to `false` when it is not provided.
*
* @param token the injected token value (observable, boolean, or null when not provided)
* @returns an observable emitting whether the Runtime Bundle fallback is enabled
*/
export const resolveTaskRuntimeBundleFallback$ = (token: Observable<boolean> | boolean | null): Observable<boolean> =>
isObservable(token) ? token : of(token ?? false);
@@ -48,6 +48,24 @@ export interface TaskDetailsCloudModel {
processDefinitionDeploymentId?: string;
}
export interface TaskDetailsCloudModelRuntimeBundle {
id?: string;
name?: string;
appName?: string;
assignee?: string;
appVersion?: number;
createdDate?: Date;
claimedDate?: Date;
formKey?: any;
priority?: number;
processDefinitionId?: string;
processInstanceId?: string;
status?: TaskStatus;
standalone?: boolean;
candidateUsers?: string[];
candidateGroups?: string[];
}
export interface StartTaskCloudResponseModel {
entry: TaskDetailsCloudModel;
}
@@ -17,7 +17,9 @@
import { TestBed } from '@angular/core/testing';
import { AppConfigService, TranslationService, NoopTranslateModule, NoopAuthModule } from '@alfresco/adf-core';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { TaskCloudService } from './task-cloud.service';
import { ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED } from '../../services/task-runtime-bundle-fallback.token';
import { taskCompleteCloudMock } from '../task-header/mocks/fake-complete-task.mock';
import {
assignedTaskDetailsCloudMock,
@@ -26,7 +28,14 @@ import {
} from '../task-header/mocks/task-details-cloud.mock';
import { IdentityUserService } from '../../people/services/identity-user.service';
import { AdfHttpClient } from '@alfresco/adf-core/api';
import { TASK_COMPLETED_STATE, TASK_CREATED_STATE, TASK_ASSIGNED_STATE } from '../models/task-details-cloud.model';
import {
TASK_COMPLETED_STATE,
TASK_CREATED_STATE,
TASK_ASSIGNED_STATE,
TASK_CLAIM_PERMISSION,
TASK_RELEASE_PERMISSION,
TASK_VIEW_PERMISSION
} from '../models/task-details-cloud.model';
const fakeTaskDetailsCloud = {
entry: {
@@ -69,6 +78,7 @@ describe('Task Cloud Service', () => {
let translateService: TranslationService;
let appConfigService: AppConfigService;
let requestSpy: jasmine.Spy;
let runtimeBundleFallback$: BehaviorSubject<boolean>;
const returnFakeTaskCompleteResults = () => Promise.resolve(taskCompleteCloudMock);
@@ -81,8 +91,10 @@ describe('Task Cloud Service', () => {
const returnFakeCandidateGroupResults = () => Promise.resolve(['mockgroup1', 'mockgroup2', 'mockgroup3']);
beforeEach(() => {
runtimeBundleFallback$ = new BehaviorSubject<boolean>(false);
TestBed.configureTestingModule({
imports: [NoopTranslateModule, NoopAuthModule]
imports: [NoopTranslateModule, NoopAuthModule],
providers: [{ provide: ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED, useValue: runtimeBundleFallback$ }]
});
adfHttpClient = TestBed.inject(AdfHttpClient);
identityUserService = TestBed.inject(IdentityUserService);
@@ -579,6 +591,134 @@ describe('Task Cloud Service', () => {
});
});
describe('canClaimTask', () => {
describe('when the task exposes permissions', () => {
it('should allow claiming when the task is created and has the CLAIM permission', () => {
const task = {
...createdTaskDetailsCloudMock,
status: TASK_CREATED_STATE,
standalone: false,
permissions: [TASK_CLAIM_PERMISSION]
};
expect(service.canClaimTask(task)).toBe(true);
});
it('should not allow claiming when the task is standalone', () => {
const task = {
...createdTaskDetailsCloudMock,
status: TASK_CREATED_STATE,
standalone: true,
permissions: [TASK_CLAIM_PERMISSION]
};
expect(service.canClaimTask(task)).toBe(false);
});
it('should not allow claiming when the task is not created', () => {
const task = {
...createdTaskDetailsCloudMock,
status: TASK_ASSIGNED_STATE,
standalone: false,
permissions: [TASK_CLAIM_PERMISSION]
};
expect(service.canClaimTask(task)).toBe(false);
});
it('should not allow claiming when the CLAIM permission is missing', () => {
const task = {
...createdTaskDetailsCloudMock,
status: TASK_CREATED_STATE,
standalone: false,
permissions: [TASK_VIEW_PERMISSION]
};
expect(service.canClaimTask(task)).toBe(false);
});
});
describe('when the task does not expose permissions', () => {
it('should allow claiming a created, non-standalone task when permissions are missing', () => {
const task = { ...createdTaskDetailsCloudMock, status: TASK_CREATED_STATE, standalone: false, permissions: undefined };
expect(service.canClaimTask(task)).toBe(true);
});
it('should not allow claiming a standalone task when permissions are empty', () => {
const task = { ...createdTaskDetailsCloudMock, status: TASK_CREATED_STATE, standalone: true, permissions: [] };
expect(service.canClaimTask(task)).toBe(false);
});
});
});
describe('canUnclaimTask', () => {
describe('when the task exposes permissions', () => {
it('should allow releasing when the task is assigned to me and has the RELEASE permission', () => {
const task = {
...assignedTaskDetailsCloudMock,
status: TASK_ASSIGNED_STATE,
assignee: cloudMockUser.username,
standalone: false,
permissions: [TASK_RELEASE_PERMISSION]
};
expect(service.canUnclaimTask(task)).toBe(true);
});
it('should not allow releasing when the task is standalone', () => {
const task = {
...assignedTaskDetailsCloudMock,
status: TASK_ASSIGNED_STATE,
assignee: cloudMockUser.username,
standalone: true,
permissions: [TASK_RELEASE_PERMISSION]
};
expect(service.canUnclaimTask(task)).toBe(false);
});
it('should not allow releasing when the task is assigned to someone else', () => {
const task = {
...assignedTaskDetailsCloudMock,
status: TASK_ASSIGNED_STATE,
assignee: 'DifferentUser',
standalone: false,
permissions: [TASK_RELEASE_PERMISSION]
};
expect(service.canUnclaimTask(task)).toBe(false);
});
it('should not allow releasing when the RELEASE permission is missing', () => {
const task = {
...assignedTaskDetailsCloudMock,
status: TASK_ASSIGNED_STATE,
assignee: cloudMockUser.username,
standalone: false,
permissions: [TASK_VIEW_PERMISSION]
};
expect(service.canUnclaimTask(task)).toBe(false);
});
});
describe('when the task does not expose permissions', () => {
it('should allow releasing a task assigned to me when permissions are missing', () => {
const task = {
...assignedTaskDetailsCloudMock,
status: TASK_ASSIGNED_STATE,
assignee: cloudMockUser.username,
standalone: false,
permissions: undefined
};
expect(service.canUnclaimTask(task)).toBe(true);
});
it('should not allow releasing a task assigned to someone else when permissions are empty', () => {
const task = {
...assignedTaskDetailsCloudMock,
status: TASK_ASSIGNED_STATE,
assignee: 'DifferentUser',
standalone: false,
permissions: []
};
expect(service.canUnclaimTask(task)).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 };
@@ -623,4 +763,54 @@ describe('Task Cloud Service', () => {
expect(service.canUnclaimTaskByState(task)).toBe(false);
});
});
describe('getTaskById fallback', () => {
const appName = 'task-app';
const taskId = '12345678';
it('should use the Query Service only when the Runtime Bundle fallback is disabled', async () => {
runtimeBundleFallback$.next(false);
requestSpy.and.callFake(returnFakeTaskDetailsResults);
await firstValueFrom(service.getTaskById(appName, taskId));
const requestedUrls = requestSpy.calls.all().map((call) => call.args[0]);
expect(requestedUrls[0]).toContain(`/query/v1/tasks/${taskId}`);
expect(requestedUrls.some((url) => url.includes('/rb/'))).toBe(false);
});
it('should call the Runtime Bundle first when the fallback is enabled', async () => {
runtimeBundleFallback$.next(true);
requestSpy.and.callFake(returnFakeTaskDetailsResults);
await firstValueFrom(service.getTaskById(appName, taskId));
const [url] = requestSpy.calls.first().args;
expect(url).toContain(`/rb/v1/tasks/${taskId}`);
});
it('should use the Query Service when the Runtime Bundle returns 404', async () => {
runtimeBundleFallback$.next(true);
const notFoundError = Object.assign(new Error('Not Found'), { status: 404 });
requestSpy.and.callFake((url: string) => (url.includes('/rb/') ? Promise.reject(notFoundError) : Promise.resolve(fakeTaskDetailsCloud)));
const task: any = await firstValueFrom(service.getTaskById(appName, taskId));
const requestedUrls = requestSpy.calls.all().map((call) => call.args[0]);
expect(requestedUrls[0]).toContain(`/rb/v1/tasks/${taskId}`);
expect(requestedUrls[1]).toContain(`/query/v1/tasks/${taskId}`);
expect(task.id).toBe(fakeTaskDetailsCloud.entry.id);
});
it('should not use the Query Service for errors other than 404', async () => {
runtimeBundleFallback$.next(true);
const error = Object.assign(new Error('Server Error'), { status: 500 });
requestSpy.and.callFake((url: string) => (url.includes('/rb/') ? Promise.reject(error) : Promise.resolve(fakeTaskDetailsCloud)));
await expectAsync(firstValueFrom(service.getTaskById(appName, taskId))).toBeRejectedWith(error);
const requestedUrls = requestSpy.calls.all().map((call) => call.args[0]);
expect(requestedUrls.some((url) => url.includes('/query/'))).toBe(false);
});
});
});
@@ -18,7 +18,7 @@
import { inject, Injectable } from '@angular/core';
import { CardViewArrayItem, TranslationService } from '@alfresco/adf-core';
import { Observable, of, Subject, throwError } from 'rxjs';
import { map } from 'rxjs/operators';
import { catchError, map, switchMap, take } from 'rxjs/operators';
import {
StartTaskCloudResponseModel,
TASK_ASSIGNED_STATE,
@@ -33,6 +33,7 @@ import { StartTaskCloudRequestModel } from '../models/start-task-cloud-request.m
import { ProcessDefinitionCloud } from '../../models/process-definition-cloud.model';
import { DEFAULT_TASK_PRIORITIES, TaskPriorityOption } from '../models/task.model';
import { IdentityUserService } from '../../people/services/identity-user.service';
import { ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED, resolveTaskRuntimeBundleFallback$ } from '../../services/task-runtime-bundle-fallback.token';
@Injectable({
providedIn: 'root'
@@ -40,6 +41,9 @@ import { IdentityUserService } from '../../people/services/identity-user.service
export class TaskCloudService extends BaseCloudService {
private readonly translateService = inject(TranslationService);
private readonly identityUserService = inject(IdentityUserService);
private readonly runtimeBundleFallbackEnabled$ = resolveTaskRuntimeBundleFallback$(
inject(ADF_TASK_RUNTIME_BUNDLE_FALLBACK_ENABLED, { optional: true })
);
dataChangesDetected$ = new Subject();
@@ -111,6 +115,9 @@ export class TaskCloudService extends BaseCloudService {
* @returns Boolean value if the task can be completed
*/
canClaimTask(taskDetails: TaskDetailsCloudModel): boolean {
if (!taskDetails?.permissions || taskDetails?.permissions?.length === 0) {
return this.canClaimTaskByState(taskDetails);
}
return taskDetails?.status === TASK_CREATED_STATE && taskDetails?.permissions.includes(TASK_CLAIM_PERMISSION) && !taskDetails?.standalone;
}
@@ -122,6 +129,9 @@ export class TaskCloudService extends BaseCloudService {
*/
canUnclaimTask(taskDetails: TaskDetailsCloudModel): boolean {
const currentUser = this.identityUserService.getCurrentUserInfo().username;
if (!taskDetails?.permissions || taskDetails?.permissions?.length === 0) {
return this.canUnclaimTaskByState(taskDetails);
}
return (
taskDetails?.status === TASK_ASSIGNED_STATE &&
taskDetails?.assignee === currentUser &&
@@ -226,14 +236,22 @@ export class TaskCloudService extends BaseCloudService {
*
* @param appName Name of the app
* @param taskId ID of the task whose details you want
* @param service The service to call. Either Query Service or Runtime Bundle Service.
* @returns Task details
*/
getTaskById(appName: string, taskId: string, service: 'query' | 'rb' = 'query'): Observable<TaskDetailsCloudModel> {
getTaskById(appName: string, taskId: string): Observable<TaskDetailsCloudModel> {
if ((appName || appName === '') && taskId) {
const queryUrl = `${this.getBasePath(appName)}/${service}/v1/tasks/${taskId}`;
const queryUrl = `${this.getBasePath(appName)}/query/v1/tasks/${taskId}`;
const rbUrl = `${this.getBasePath(appName)}/rb/v1/tasks/${taskId}`;
return this.get(queryUrl).pipe(map((res: any) => res.entry));
return this.runtimeBundleFallbackEnabled$.pipe(
take(1),
switchMap((runtimeBundleFirst) =>
runtimeBundleFirst
? this.get(rbUrl).pipe(catchError((error) => (error?.status === 404 ? this.get(queryUrl) : throwError(() => error))))
: this.get(queryUrl)
),
map((res: any) => res.entry)
);
} else {
return throwError('AppName/TaskId not configured');
}
@@ -1,61 +0,0 @@
/*!
* @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<TaskCloudService>;
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);
});
});
@@ -1,43 +0,0 @@
/*!
* @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<TaskDetailsCloudModel> {
return this.taskCloudService.getTaskById(appName, taskId);
}
canClaim(taskDetails: TaskDetailsCloudModel): boolean {
return this.taskCloudService.canClaimTask(taskDetails);
}
canUnclaim(taskDetails: TaskDetailsCloudModel): boolean {
return this.taskCloudService.canUnclaimTask(taskDetails);
}
}
@@ -1,107 +0,0 @@
/*!
* @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<TaskCloudService>;
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();
});
});
@@ -1,55 +0,0 @@
/*!
* @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<TaskDetailsCloudModel> {
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);
}
}
@@ -1,34 +0,0 @@
/*!
* @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<TaskDetailsCloudModel>;
/** Whether the task can be claimed. */
canClaim(taskDetails: TaskDetailsCloudModel): boolean;
/** Whether the task can be unclaimed. */
canUnclaim(taskDetails: TaskDetailsCloudModel): boolean;
}
@@ -153,41 +153,12 @@ describe('UserTaskCloudComponent', () => {
});
});
describe('taskDetailsSource', () => {
describe('loadTask', () => {
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');
it('should fetch the task details by id', () => {
fixture.componentRef.setInput('taskId', 'task1');
fixture.detectChanges();
@@ -195,61 +166,6 @@ describe('UserTaskCloudComponent', () => {
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', () => {
@@ -33,9 +33,6 @@ 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',
@@ -134,17 +131,6 @@ 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<string>();
@@ -206,11 +192,6 @@ export class UserTaskCloudComponent implements OnInit, OnChanges {
private readonly taskTypeResolverService = inject(TaskTypeResolverService);
private readonly destroyRef = inject(DestroyRef);
private readonly taskDetailsSourceStrategies: Record<TaskDetailsSource, TaskDetailsSourceStrategy> = {
query: inject(QueryTaskDetailsSourceStrategy),
rb: inject(RuntimeBundleTaskDetailsSourceStrategy)
};
ngOnChanges(changes: SimpleChanges) {
const appName = changes['appName'];
if (appName && appName.currentValue !== appName.previousValue && this.taskId) {
@@ -223,12 +204,6 @@ 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() {
@@ -238,7 +213,7 @@ export class UserTaskCloudComponent implements OnInit, OnChanges {
}
canClaimTask(): boolean {
return !this.readOnly && this.taskDetailsStrategy.canClaim(this.taskDetails) && this.hasCandidateUsersOrGroups();
return !this.readOnly && this.taskCloudService.canClaimTask(this.taskDetails) && this.hasCandidateUsersOrGroups();
}
canCompleteTask(): boolean {
@@ -246,7 +221,7 @@ export class UserTaskCloudComponent implements OnInit, OnChanges {
}
canUnclaimTask(): boolean {
return !this.readOnly && this.taskDetailsStrategy.canUnclaim(this.taskDetails) && this.hasCandidateUsersOrGroups();
return !this.readOnly && this.taskCloudService.canUnclaimTask(this.taskDetails) && this.hasCandidateUsersOrGroups();
}
getTaskType(): void {
@@ -324,7 +299,7 @@ export class UserTaskCloudComponent implements OnInit, OnChanges {
private loadTask(): void {
this.loading = true;
const tasks$ = this.taskDetailsStrategy.getTaskDetails$(this.appName, this.taskId);
const tasks$ = this.taskCloudService.getTaskById(this.appName, this.taskId);
const candidateUsers$ = this.taskCloudService.getCandidateUsers(this.appName, this.taskId);
const candidateGroups$ = this.taskCloudService.getCandidateGroups(this.appName, this.taskId);
@@ -351,10 +326,6 @@ 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);