From 168292c33a36827cf5c1fa21c9161370b5f26f9f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:40:34 +0100 Subject: [PATCH 1/9] [OP-20344] Remove dead updateLinkedResources Deletes WorkPackageResource#updateLinkedResources. It has no callers and calls an undeclared wpCacheService, so it only compiled because of the resource's `any` index signature. https://community.openproject.org/wp/OP-20344 --- .../hal/resources/work-package-resource.ts | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/frontend/src/app/features/hal/resources/work-package-resource.ts b/frontend/src/app/features/hal/resources/work-package-resource.ts index 700c16f0b9d6..946d4c7af301 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -257,29 +257,6 @@ export class WorkPackageBaseResource extends HalResource { return otherWorkPackage.parent?.$links.self.$link.href === this.$links.self.$link.href; } - /** - * Invalidate a set of linked resources of this work package. - * And inform the cache service about the work package update. - * - * Return a promise that returns the linked resources as properties. - * Return a rejected promise, if the resource is not a property of the work package. - */ - public updateLinkedResources(...resourceNames:string[]):Promise { - const resources:Record> = {}; - - resourceNames.forEach((name) => { - const linked = this[name]; - resources[name] = linked ? linked.$update() : Promise.reject(undefined); - }); - - const promise = Promise.all(Object.values(resources)); - promise.then(() => { - this.wpCacheService.touch(this.id!); - }); - - return promise; - } - public $initialize(source:any) { super.$initialize(source); From 4664bd275d81bbaa4b721bb3a2ff4890820a02d8 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:44:34 +0100 Subject: [PATCH 2/9] [OP-20344] Type HAL links as callable links Makes HalLink#$fetch and #$callable generic and gives CallableHalLink its call signature and a HalLink-typed $link, so link functions no longer rely on the global `Function.$link?:any` augmentation, which is removed. HalResource#$links is now a map of CallableHalLink and #$embedded a map of unknown values. Resource-specific link interfaces declare their action links as CallableHalLink instead of `Promise` methods, and are intersected with the base maps on `$links`/`$embedded` so the resource interfaces they are merged into keep the `any` index signature until it is flipped separately. WorkPackageResourceEmbedded drops its `HalResource|any` unions and the link interface no longer inherits the embedded attributes. The unused QueryFilterResource embedded declaration is removed. Callers that relied on the untyped maps get boundary casts. https://community.openproject.org/wp/OP-20344 --- .../apiv3/cache/cachable-apiv3-resource.ts | 4 +- .../state/attachments/attachments.service.ts | 3 +- .../src/app/features/hal/hal-link/hal-link.ts | 28 +++---- .../resources/activity-comment-resource.ts | 13 +-- .../features/hal/resources/grid-resource.ts | 14 ++-- .../hal/resources/hal-resource.spec.ts | 16 ++-- .../features/hal/resources/hal-resource.ts | 10 ++- .../hal/resources/meeting-resource.ts | 7 +- .../features/hal/resources/post-resource.ts | 7 +- .../query-filter-instance-resource.ts | 3 +- .../query-filter-instance-schema-resource.ts | 10 +-- .../hal/resources/query-filter-resource.ts | 7 -- .../hal/resources/query-form-resource.ts | 9 ++- .../features/hal/resources/query-resource.ts | 4 +- .../hal/resources/query-sort-by-resource.ts | 4 +- .../hal/resources/relation-resource.ts | 9 ++- .../hal/resources/wiki-page-resource.ts | 7 +- .../resources/work-package-resource.spec.ts | 3 +- .../hal/resources/work-package-resource.ts | 80 ++++++++----------- .../work-package-timestamp-resource.ts | 14 +--- .../components/wp-new/wp-create.service.ts | 10 +-- .../work-package-single-view.base.ts | 2 +- .../fields/changeset/resource-changeset.ts | 2 - .../field-types/render-hierarchy-item.ts | 3 +- .../services/hal-resource-editing.service.ts | 2 +- .../wp-status-dropdown-menu.directive.ts | 3 +- .../storages/storage/storage.component.ts | 3 +- .../src/typings/open-project.typings.d.ts | 1 - .../module/hal/resources/budget-resource.ts | 7 +- .../module/hal/resources/document-resource.ts | 7 +- 30 files changed, 138 insertions(+), 154 deletions(-) diff --git a/frontend/src/app/core/apiv3/cache/cachable-apiv3-resource.ts b/frontend/src/app/core/apiv3/cache/cachable-apiv3-resource.ts index 2e5f6f2ddc17..df460f048c93 100644 --- a/frontend/src/app/core/apiv3/cache/cachable-apiv3-resource.ts +++ b/frontend/src/app/core/apiv3/cache/cachable-apiv3-resource.ts @@ -137,7 +137,7 @@ export abstract class ApiV3Resource switchMap((resource) => { if (resource.$links.schema) { return this.schemaCache - .requireAndStream(resource.$links.schema.href) + .requireAndStream(resource.$links.schema.href!) .pipe( take(1), mapTo(resource), @@ -145,7 +145,7 @@ export abstract class ApiV3Resource } return of(resource); }), - ) as any; // T does not extend HalResource for virtual endpoints such as board, thus we need to cast here + ) as unknown as Observable; // T does not extend HalResource for virtual endpoints such as board, thus we need to cast here } /** diff --git a/frontend/src/app/core/state/attachments/attachments.service.ts b/frontend/src/app/core/state/attachments/attachments.service.ts index 86516f85d6b8..efa26d2605bc 100644 --- a/frontend/src/app/core/state/attachments/attachments.service.ts +++ b/frontend/src/app/core/state/attachments/attachments.service.ts @@ -41,7 +41,6 @@ import { } from 'rxjs/operators'; import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { HalLink } from 'core-app/features/hal/hal-link/hal-link'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; import { AttachmentsStore } from 'core-app/core/state/attachments/attachments.store'; @@ -202,7 +201,7 @@ export class AttachmentsResourceService extends ResourceStoreService extends HalLinkInterface { + (data?:unknown, headers?:HTTPClientHeaders):Promise; + $link:HalLink; data?:Promise; } +export type HalLinkRequestMethod = (method:HTTPSupportedMethods, href:string, data?:unknown, headers?:HTTPClientHeaders) => Promise; + export class HalLink implements HalLinkInterface { - constructor(public requestMethod:(method:HTTPSupportedMethods, href:string, data:any, headers:any) => Promise, + constructor(public requestMethod:HalLinkRequestMethod, public href:string|null = null, public title = '', public method:HTTPSupportedMethods = 'get', public templated = false, - public payload?:any, + public payload?:unknown, public type = 'application/json', public identifier?:string, public displayId?:string) { @@ -72,7 +75,7 @@ export class HalLink implements HalLinkInterface { */ public static fromObject(halResourceService:HalResourceService, link:HalLinkInterface):HalLink { return new HalLink( - (method:HTTPSupportedMethods, href:string, data:object, headers:HTTPClientHeaders) => firstValueFrom(halResourceService.request(method, href, data, headers)), + (method, href, data, headers) => firstValueFrom(halResourceService.request(method, href, data, headers)), link.href, link.title, link.method, @@ -87,9 +90,8 @@ export class HalLink implements HalLinkInterface { /** * Fetch the resource. */ - public $fetch(...params:any[]):Promise { - const [data, headers] = params; - return this.requestMethod(this.method, this.href!, data, headers); + public $fetch(data?:unknown, headers?:HTTPClientHeaders):Promise { + return this.requestMethod(this.method, this.href!, data, headers) as Promise; } /** @@ -126,10 +128,10 @@ export class HalLink implements HalLinkInterface { * * @returns {CallableHalLink} */ - public $callable():CallableHalLink { - const linkFunc:any = (...params:any[]) => this.$fetch(...params); + public $callable():CallableHalLink { + const linkFunc = (data?:unknown, headers?:HTTPClientHeaders) => this.$fetch(data, headers); - Object.assign(linkFunc, { + return Object.assign(linkFunc, { $link: this, href: this.href, title: this.title, @@ -140,7 +142,5 @@ export class HalLink implements HalLinkInterface { identifier: this.identifier, displayId: this.displayId, }); - - return linkFunc; } } diff --git a/frontend/src/app/features/hal/resources/activity-comment-resource.ts b/frontend/src/app/features/hal/resources/activity-comment-resource.ts index 04b35cb99bda..c91e408d1b03 100644 --- a/frontend/src/app/features/hal/resources/activity-comment-resource.ts +++ b/frontend/src/app/features/hal/resources/activity-comment-resource.ts @@ -26,7 +26,8 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { HalResource, HalResourceEmbedded, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { Attachable } from 'core-app/features/hal/resources/mixins/attachable-mixin'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { AttachmentCollectionResource } from './attachment-collection-resource'; @@ -36,15 +37,15 @@ interface ActivityCommentResourceEmbedded { workPackage:WorkPackageResource; } -interface ActivityCommentResourceLinks extends ActivityCommentResourceEmbedded { - addAttachment(attachment:HalResource):Promise; +interface ActivityCommentResourceLinks { + addAttachment:CallableHalLink; } class ActivityCommentBaseResource extends HalResource { - public $embedded:ActivityCommentResourceEmbedded; - public $links:ActivityCommentResourceLinks; + public $embedded:ActivityCommentResourceEmbedded & HalResourceEmbedded; + public $links:ActivityCommentResourceLinks & HalResourceLinks; } export const ActivityCommentResource = Attachable(ActivityCommentBaseResource); -export interface ActivityCommentResource extends ActivityCommentBaseResource, ActivityCommentResourceLinks {} +export interface ActivityCommentResource extends ActivityCommentBaseResource, ActivityCommentResourceLinks, ActivityCommentResourceEmbedded {} diff --git a/frontend/src/app/features/hal/resources/grid-resource.ts b/frontend/src/app/features/hal/resources/grid-resource.ts index ed129b70363a..7b264509e840 100644 --- a/frontend/src/app/features/hal/resources/grid-resource.ts +++ b/frontend/src/app/features/hal/resources/grid-resource.ts @@ -26,17 +26,17 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { GridWidgetResource } from 'core-app/features/hal/resources/grid-widget-resource'; import { Attachable } from 'core-app/features/hal/resources/mixins/attachable-mixin'; -import { IHalResourceLink } from 'core-app/core/state/hal-resource'; import idFromLink from '../helpers/id-from-link'; export interface GridResourceLinks { - update(payload:unknown):Promise; - updateImmediately(payload:unknown):Promise; - delete():Promise; - project:IHalResourceLink; + update:CallableHalLink; + updateImmediately:CallableHalLink; + delete:CallableHalLink; + project:CallableHalLink; } export class GridBaseResource extends HalResource { @@ -48,7 +48,7 @@ export class GridBaseResource extends HalResource { public columnCount:number; - public $links:GridResourceLinks; + public $links:GridResourceLinks & HalResourceLinks; public get projectId():string|undefined { if (this.$links.project) { diff --git a/frontend/src/app/features/hal/resources/hal-resource.spec.ts b/frontend/src/app/features/hal/resources/hal-resource.spec.ts index 3efa51c88f84..b85737df34e1 100644 --- a/frontend/src/app/features/hal/resources/hal-resource.spec.ts +++ b/frontend/src/app/features/hal/resources/hal-resource.spec.ts @@ -34,7 +34,7 @@ import { States } from 'core-app/core/states/states.service'; import { of } from 'rxjs'; import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; import { OpenprojectHalModule } from 'core-app/features/hal/openproject-hal.module'; -import { HalLink, HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link'; +import { CallableHalLink, HalLink, HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient, withInterceptorsFromDi, withXhr } from '@angular/common/http'; import type { Mock } from 'vitest'; @@ -426,7 +426,7 @@ describe('HalResource', () => { }); it('should have an embedded resource, that is loaded', () => { - expect(resource.$embedded.resource.$loaded).toBeTruthy(); + expect((resource.$embedded.resource as HalResource).$loaded).toBeTruthy(); }); it('should have a property that is the resource', () => { @@ -466,8 +466,9 @@ describe('HalResource', () => { }, }; - first = resource.$embedded.resource.$embedded.first; - deep = resource.$embedded.resource.$embedded.first.$embedded.second; + const embedded = resource.$embedded.resource as HalResource; + first = embedded.$embedded.first; + deep = (embedded.$embedded.first as HalResource).$embedded.second; }); it('should create all nested resources recursively', () => { @@ -577,8 +578,9 @@ describe('HalResource', () => { }); it('should transform the list elements', () => { - expect(resource.$embedded.elements[0].$isHal).toBeTruthy(); - expect(resource.$embedded.elements[1].$isHal).toBeTruthy(); + const elements = resource.$embedded.elements as HalResource[]; + expect(elements[0].$isHal).toBeTruthy(); + expect(elements[1].$isHal).toBeTruthy(); }); }); @@ -629,7 +631,7 @@ describe('HalResource', () => { it('should not be possible to override a link', () => { try { - resource.$links.action = 'foo'; + resource.$links.action = 'foo' as unknown as CallableHalLink; } catch (ignore) { /**/ diff --git a/frontend/src/app/features/hal/resources/hal-resource.ts b/frontend/src/app/features/hal/resources/hal-resource.ts index 6294e91cbf75..eaa0c6176557 100644 --- a/frontend/src/app/features/hal/resources/hal-resource.ts +++ b/frontend/src/app/features/hal/resources/hal-resource.ts @@ -32,7 +32,7 @@ import { Injector } from '@angular/core'; import { States } from 'core-app/core/states/states.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; -import { HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link'; +import { CallableHalLink, HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link'; import { ICKEditorContext } from 'core-app/shared/components/editor/components/ckeditor/ckeditor.types'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; import { cloneDeep } from 'lodash-es'; @@ -47,6 +47,10 @@ export type HalResourceClass = new( _$halType:string, ) => T; +export type HalResourceLinks = Record; + +export type HalResourceEmbedded = Record; + export class HalResource { // TODO this is the source of many issues in the frontend // because it no longer properly type checks stuff @@ -101,9 +105,9 @@ export class HalResource { return { _links: { self } }; } - public $links:any = {}; + public $links:HalResourceLinks = {}; - public $embedded:any = {}; + public $embedded:HalResourceEmbedded = {}; public $self:Promise; diff --git a/frontend/src/app/features/hal/resources/meeting-resource.ts b/frontend/src/app/features/hal/resources/meeting-resource.ts index 4ee82f66b32e..7d573819c73b 100644 --- a/frontend/src/app/features/hal/resources/meeting-resource.ts +++ b/frontend/src/app/features/hal/resources/meeting-resource.ts @@ -26,17 +26,18 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { Attachable } from 'core-app/features/hal/resources/mixins/attachable-mixin'; interface MeetingResourceLinks { - addAttachment(attachment:HalResource):Promise; + addAttachment:CallableHalLink; } class MeetingBaseResource extends HalResource { title:string; project:HalResource; - public $links:MeetingResourceLinks; + public $links:MeetingResourceLinks & HalResourceLinks; } export const MeetingResource = Attachable(MeetingBaseResource); diff --git a/frontend/src/app/features/hal/resources/post-resource.ts b/frontend/src/app/features/hal/resources/post-resource.ts index 6a897e77ffe0..650876fb4add 100644 --- a/frontend/src/app/features/hal/resources/post-resource.ts +++ b/frontend/src/app/features/hal/resources/post-resource.ts @@ -26,15 +26,16 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { Attachable } from 'core-app/features/hal/resources/mixins/attachable-mixin'; export interface PostResourceLinks { - addAttachment(attachment:HalResource):Promise; + addAttachment:CallableHalLink; } class PostBaseResource extends HalResource { - public $links:PostResourceLinks; + public $links:PostResourceLinks & HalResourceLinks; private attachmentsBackend = false; } diff --git a/frontend/src/app/features/hal/resources/query-filter-instance-resource.ts b/frontend/src/app/features/hal/resources/query-filter-instance-resource.ts index fe3301b1f1f1..746ebd34414d 100644 --- a/frontend/src/app/features/hal/resources/query-filter-instance-resource.ts +++ b/frontend/src/app/features/hal/resources/query-filter-instance-resource.ts @@ -31,6 +31,7 @@ import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { QueryOperatorResource } from 'core-app/features/hal/resources/query-operator-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; import { QueryFilterInstanceSchemaResource } from 'core-app/features/hal/resources/query-filter-instance-schema-resource'; import { QueryFilterResource } from 'core-app/features/hal/resources/query-filter-resource'; @@ -53,7 +54,7 @@ export class QueryFilterInstanceResource extends HalResource { this.$links.schema = { href: `${this.pathHelper.api.v3.apiV3Base}/queries/filter_instance_schemas/${idFromLink(this.filter.href)}`, - }; + } as unknown as CallableHalLink; } public get id():string { diff --git a/frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.ts b/frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.ts index e77c433b0c47..59fe0a512e86 100644 --- a/frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.ts +++ b/frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.ts @@ -26,10 +26,10 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { merge } from 'lodash-es'; import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; -import { HalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { QueryOperatorResource } from 'core-app/features/hal/resources/query-operator-resource'; import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource'; import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; @@ -37,9 +37,9 @@ import { QueryFilterResource } from 'core-app/features/hal/resources/query-filte import { SchemaDependencyResource } from 'core-app/features/hal/resources/schema-dependency-resource'; import { SchemaAttributeObject } from 'core-app/features/hal/resources/schema-attribute-object'; -export interface QueryFilterInstanceSchemaResourceLinks { - self:HalLink; - filter:QueryFilterResource; +export interface QueryFilterInstanceSchemaResourceLinks extends HalResourceLinks { + self:CallableHalLink; + filter:CallableHalLink; } export class QueryFilterInstanceSchemaResource extends SchemaResource { diff --git a/frontend/src/app/features/hal/resources/query-filter-resource.ts b/frontend/src/app/features/hal/resources/query-filter-resource.ts index 97bf5ac83c24..b635a95fe3d9 100644 --- a/frontend/src/app/features/hal/resources/query-filter-resource.ts +++ b/frontend/src/app/features/hal/resources/query-filter-resource.ts @@ -27,16 +27,9 @@ //++ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; -import { QueryFilterInstanceSchemaResource } from 'core-app/features/hal/resources/query-filter-instance-schema-resource'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; -export interface QueryFilterResourceEmbedded { - schema:QueryFilterInstanceSchemaResource; -} - export class QueryFilterResource extends HalResource { - public $embedded:QueryFilterResourceEmbedded; - public values:any[]; public get id():string { diff --git a/frontend/src/app/features/hal/resources/query-form-resource.ts b/frontend/src/app/features/hal/resources/query-form-resource.ts index 6ddaf84a5474..90210741d102 100644 --- a/frontend/src/app/features/hal/resources/query-form-resource.ts +++ b/frontend/src/app/features/hal/resources/query-form-resource.ts @@ -28,12 +28,17 @@ import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; import { FormResource } from 'core-app/features/hal/resources/form-resource'; +import { HalResourceEmbedded } from 'core-app/features/hal/resources/hal-resource'; import { QueryFilterInstanceSchemaResource } from 'core-app/features/hal/resources/query-filter-instance-schema-resource'; import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; -export interface QueryFormResourceEmbedded { +export interface QueryFormSchemaResource extends SchemaResource { + $embedded:HalResourceEmbedded & { filtersSchemas:CollectionResource }; +} + +export interface QueryFormResourceEmbedded extends HalResourceEmbedded { filtersSchemas:CollectionResource; - schema:SchemaResource; + schema:QueryFormSchemaResource; } export class QueryFormResource extends FormResource { diff --git a/frontend/src/app/features/hal/resources/query-resource.ts b/frontend/src/app/features/hal/resources/query-resource.ts index bf679dfd3beb..ca3b70d95a9c 100644 --- a/frontend/src/app/features/hal/resources/query-resource.ts +++ b/frontend/src/app/features/hal/resources/query-resource.ts @@ -27,7 +27,7 @@ //++ import { QueryColumn } from 'core-app/features/work-packages/components/wp-query/query-column'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { HalResource, HalResourceEmbedded } from 'core-app/features/hal/resources/hal-resource'; import { HighlightingMode } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting-mode.const'; import { QueryOrder } from 'core-app/core/apiv3/endpoints/queries/apiv3-query-order'; import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; @@ -37,7 +37,7 @@ import { UserResource } from 'core-app/features/hal/resources/user-resource'; import { QuerySortByResource } from 'core-app/features/hal/resources/query-sort-by-resource'; import { QueryGroupByResource } from 'core-app/features/hal/resources/query-group-by-resource'; -export interface QueryResourceEmbedded { +export interface QueryResourceEmbedded extends HalResourceEmbedded { results:WorkPackageCollectionResource; columns:QueryColumn[]; groupBy:QueryGroupByResource|undefined; diff --git a/frontend/src/app/features/hal/resources/query-sort-by-resource.ts b/frontend/src/app/features/hal/resources/query-sort-by-resource.ts index bc294187f880..cedb799501f3 100644 --- a/frontend/src/app/features/hal/resources/query-sort-by-resource.ts +++ b/frontend/src/app/features/hal/resources/query-sort-by-resource.ts @@ -27,12 +27,12 @@ //++ import { QueryColumn } from 'core-app/features/work-packages/components/wp-query/query-column'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { HalResource, HalResourceEmbedded } from 'core-app/features/hal/resources/hal-resource'; export const QUERY_SORT_BY_ASC = 'urn:openproject-org:api:v3:queries:directions:asc'; export const QUERY_SORT_BY_DESC = 'urn:openproject-org:api:v3:queries:directions:desc'; -export interface QuerySortByResourceEmbedded { +export interface QuerySortByResourceEmbedded extends HalResourceEmbedded { column:QueryColumn; direction:QuerySortByDirection; } diff --git a/frontend/src/app/features/hal/resources/relation-resource.ts b/frontend/src/app/features/hal/resources/relation-resource.ts index 277ce128490e..416847074ed1 100644 --- a/frontend/src/app/features/hal/resources/relation-resource.ts +++ b/frontend/src/app/features/hal/resources/relation-resource.ts @@ -26,14 +26,15 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; export interface RelationResourceLinks { - delete():Promise; + delete:CallableHalLink; - updateImmediately(payload:any):Promise; + updateImmediately:CallableHalLink; } export class RelationResource extends HalResource { @@ -77,7 +78,7 @@ export class RelationResource extends HalResource { public reverseType:string; // Links - public $links:RelationResourceLinks; + public $links:RelationResourceLinks & HalResourceLinks; public to:WorkPackageResource; diff --git a/frontend/src/app/features/hal/resources/wiki-page-resource.ts b/frontend/src/app/features/hal/resources/wiki-page-resource.ts index f112f1e1525e..0578b7623d06 100644 --- a/frontend/src/app/features/hal/resources/wiki-page-resource.ts +++ b/frontend/src/app/features/hal/resources/wiki-page-resource.ts @@ -26,15 +26,16 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { Attachable } from 'core-app/features/hal/resources/mixins/attachable-mixin'; export interface WikiPageResourceLinks { - addAttachment(attachment:HalResource):Promise; + addAttachment:CallableHalLink; } class WikiPageBaseResource extends HalResource { - public $links:WikiPageResourceLinks; + public $links:WikiPageResourceLinks & HalResourceLinks; } export const WikiPageResource = Attachable(WikiPageBaseResource); diff --git a/frontend/src/app/features/hal/resources/work-package-resource.spec.ts b/frontend/src/app/features/hal/resources/work-package-resource.spec.ts index c6a12c55e5e4..ac348cb3de2c 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.spec.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.spec.ts @@ -44,6 +44,7 @@ import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { AttachmentCollectionResource } from 'core-app/features/hal/resources/attachment-collection-resource'; import { OpenprojectHalModule } from 'core-app/features/hal/openproject-hal.module'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { WeekdayService } from 'core-app/core/days/weekday.service'; import { of } from 'rxjs'; @@ -264,7 +265,7 @@ describe('WorkPackage', () => { }); it('when the work package has an `addAttachment` link', () => { - workPackage.$links.addAttachment = () => Promise.resolve(); + workPackage.$links.addAttachment = (() => Promise.resolve()) as unknown as CallableHalLink; expect(workPackage.canAddAttachments).toEqual(true); }); diff --git a/frontend/src/app/features/hal/resources/work-package-resource.ts b/frontend/src/app/features/hal/resources/work-package-resource.ts index 946d4c7af301..075a2fe471c1 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -41,7 +41,8 @@ import { import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { AttachmentCollectionResource } from 'core-app/features/hal/resources/attachment-collection-resource'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { HalResource, HalResourceEmbedded, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; import { TypeResource } from 'core-app/features/hal/resources/type-resource'; import { RelationResource } from 'core-app/features/hal/resources/relation-resource'; @@ -55,23 +56,23 @@ import { formatWorkPackageId } from 'core-app/shared/helpers/work-package-id-pat export interface WorkPackageResourceEmbedded { activities:CollectionResource; - assignee:HalResource|any; + assignee:HalResource|null; attachments:AttachmentCollectionResource; fileLinks?:CollectionResource; - author:HalResource|any; - availableWatchers:HalResource|any; - category:HalResource|any; + author:HalResource; + availableWatchers:HalResource; + category:HalResource|null; children:WorkPackageResource[]; parent:WorkPackageResource|null; - priority:HalResource|any; - project:HalResource|any; + priority:HalResource; + project:HalResource; relations:CollectionResource; - responsible:HalResource|any; - revisions:CollectionResource|any; - status:StatusResource|any; - timeEntries:HalResource[]|any[]; + responsible:HalResource|null; + revisions:CollectionResource; + status:StatusResource; + timeEntries:HalResource[]; type:TypeResource; - version:HalResource|any; + version:HalResource|null; watchers:CollectionResource; // For regular work packages startDate:string; @@ -82,46 +83,31 @@ export interface WorkPackageResourceEmbedded { scheduleManually:boolean; } -export interface WorkPackageResourceLinks extends WorkPackageResourceEmbedded { - addAttachment(attachment:HalResource):Promise; - - addChild(child:HalResource):Promise; - - addComment(comment:unknown, headers?:any):Promise; - - addRelation(relation:any):Promise|undefined; - - addWatcher(watcher:HalResource):Promise; - - changeParent(params:any):Promise; - - copy():Promise; - - delete():Promise; - - logTime():Promise; - - startTimer():Promise; - - move():Promise; - - removeWatcher():Promise; - - self():Promise; - - update(payload:any):Promise>; - - updateImmediately(payload:any):Promise; - - watch():Promise; +export interface WorkPackageResourceLinks { + addAttachment:CallableHalLink; + addChild:CallableHalLink; + addComment:CallableHalLink; + addRelation:CallableHalLink; + addWatcher:CallableHalLink; + changeParent:CallableHalLink; + copy:CallableHalLink; + delete:CallableHalLink; + logTime:CallableHalLink; + startTimer:CallableHalLink; + move:CallableHalLink; + removeWatcher:CallableHalLink; + update:CallableHalLink>; + updateImmediately:CallableHalLink; + watch:CallableHalLink; } -export interface WorkPackageLinksObject extends WorkPackageResourceLinks { - schema:HalResource; +export interface WorkPackageLinksObject extends HalResourceLinks, WorkPackageResourceLinks { + self:CallableHalLink; + schema:CallableHalLink; } export class WorkPackageBaseResource extends HalResource { - public $embedded:WorkPackageResourceEmbedded; + public $embedded:WorkPackageResourceEmbedded & HalResourceEmbedded; public $links:WorkPackageLinksObject; diff --git a/frontend/src/app/features/hal/resources/work-package-timestamp-resource.ts b/frontend/src/app/features/hal/resources/work-package-timestamp-resource.ts index 309c2c557786..63fdaf8de865 100644 --- a/frontend/src/app/features/hal/resources/work-package-timestamp-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-timestamp-resource.ts @@ -26,7 +26,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; export interface IWorkPackageTimestampMeta { exists:boolean; @@ -43,15 +43,5 @@ export class IWorkPackageTimestamp extends HalResource { _meta:IWorkPackageTimestampMeta; - $links:{ - schema?:HalResource; - self:HalResource; - status?:HalResource; - assignee?:HalResource; - accountable?:HalResource; - project?:HalResource; - type?:HalResource; - priority?:HalResource; - version?:HalResource; - }; + $links:HalResourceLinks; } diff --git a/frontend/src/app/features/work-packages/components/wp-new/wp-create.service.ts b/frontend/src/app/features/work-packages/components/wp-new/wp-create.service.ts index 9e045c99e3a6..309828e93702 100644 --- a/frontend/src/app/features/work-packages/components/wp-new/wp-create.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-new/wp-create.service.ts @@ -59,7 +59,7 @@ import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service'; -import { AttachmentCollectionResource } from 'core-app/features/hal/resources/attachment-collection-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { HalSource } from 'core-app/features/hal/interfaces'; export const newWorkPackageHref = '/api/v3/work_packages/new'; @@ -404,13 +404,13 @@ export class WorkPackageCreateService extends UntilDestroyedMixin { wp.__initialized_at = Date.now(); // Set update link to form - wp.update = wp.$links.update = form.$links.self; + wp.update = wp.$links.update = form.$links.self as CallableHalLink>; // Use POST /work_packages for saving link - wp.updateImmediately = (data:object) => firstValueFrom(this.apiV3Service.work_packages.post(data)); - wp.$links.updateImmediately = (data:object) => firstValueFrom(this.apiV3Service.work_packages.post(data)); + wp.updateImmediately = ((data:object) => firstValueFrom(this.apiV3Service.work_packages.post(data))) as CallableHalLink; + wp.$links.updateImmediately = ((data:object) => firstValueFrom(this.apiV3Service.work_packages.post(data))) as CallableHalLink; if (form.schema.$links.attachments) { - wp.$links.attachments = { elements: [] } as unknown as AttachmentCollectionResource; + wp.$links.attachments = { elements: [] } as unknown as CallableHalLink; } // We need to provide the schema to the cache so that it is available in the html form to e.g. determine diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts index a096622e0114..fd939fd5ee35 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts @@ -191,7 +191,7 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { // lazy load the work package's project, needed when initializing // the work package resource from split view. this.projectsResourceService - .requireEntity((this.workPackage.$links.project as HalResource).href!) + .requireEntity(this.workPackage.$links.project.href!) .subscribe( () => {}, (error:HttpErrorResponse) => { diff --git a/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts b/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts index 19f06f5614cc..0c386694a9df 100644 --- a/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts +++ b/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts @@ -167,12 +167,10 @@ export class ResourceChangeset { protected updateForm():Promise { const payload = this.buildPayloadFromChanges(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (!this.pristineResource.$links.update) { return Promise.reject(); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const promise = this.pristineResource .$links .update(payload) diff --git a/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts b/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts index 33508daf5a9f..ce54004b78fa 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts @@ -33,8 +33,7 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; export function renderHierarchyItem(item:HalResource, multiple = false):Observable { - const customFieldItemLinks = item.$links as { branch:() => HalResource[] }; - return from(customFieldItemLinks.branch()) + return from(item.$links.branch() as Promise) .pipe( map((ancestors:CollectionResource) => spansFromAncestors(ancestors)), map((spans) => { diff --git a/frontend/src/app/shared/components/fields/edit/services/hal-resource-editing.service.ts b/frontend/src/app/shared/components/fields/edit/services/hal-resource-editing.service.ts index 67e75b5ee726..b2b76ded6b11 100644 --- a/frontend/src/app/shared/components/fields/edit/services/hal-resource-editing.service.ts +++ b/frontend/src/app/shared/components/fields/edit/services/hal-resource-editing.service.ts @@ -106,7 +106,7 @@ export class HalResourceEditingService extends StateCacheService>(change:T):Promise> { // Form the payload we're going to save const payload = await change.buildRequestPayload(); - const savedResource = await change.pristineResource.$links.updateImmediately(payload); + const savedResource = await change.pristineResource.$links.updateImmediately(payload) as V; // Initialize any potentially new HAL values savedResource.retainFrom(change.pristineResource); diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts index f7ffb1373fbe..c195ce09c2b8 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts @@ -36,6 +36,7 @@ import { } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { StatusResource } from 'core-app/features/hal/resources/status-resource'; import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; import { Highlighting @@ -89,7 +90,7 @@ export class WorkPackageStatusDropdownDirective extends OpContextMenuTrigger { private updateStatus(status:HalResource) { const change = this.halEditing.changeFor(this.workPackage); - change.projectedResource.status = status; + change.projectedResource.status = status as StatusResource; if (!isNewResource(this.workPackage)) { this.halEditing diff --git a/frontend/src/app/shared/components/storages/storage/storage.component.ts b/frontend/src/app/shared/components/storages/storage/storage.component.ts index 9b03dafbc890..95ed04e38adb 100644 --- a/frontend/src/app/shared/components/storages/storage/storage.component.ts +++ b/frontend/src/app/shared/components/storages/storage/storage.component.ts @@ -62,7 +62,6 @@ import { OpModalService } from 'core-app/shared/components/modal/modal.service'; import { FilePickerModalComponent, } from 'core-app/shared/components/storages/file-picker-modal/file-picker-modal.component'; -import { IHalResourceLink } from 'core-app/core/state/hal-resource'; import { LocationPickerModalComponent, } from 'core-app/shared/components/storages/location-picker-modal/location-picker-modal.component'; @@ -191,7 +190,7 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD return this.pathHelperService.fileLinksPath(); } - return (this.resource.$links as { addFileLink:IHalResourceLink }).addFileLink.href; + return this.resource.$links.addFileLink.href!; } private onGlobalDragLeave:(_event:DragEvent) => void = (_event) => { diff --git a/frontend/src/typings/open-project.typings.d.ts b/frontend/src/typings/open-project.typings.d.ts index e5afbccd2d39..7eaf7b4b28d6 100644 --- a/frontend/src/typings/open-project.typings.d.ts +++ b/frontend/src/typings/open-project.typings.d.ts @@ -51,7 +51,6 @@ declare namespace api { */ interface Function { - $link?:any; name:string; _type:string; } diff --git a/modules/budgets/frontend/module/hal/resources/budget-resource.ts b/modules/budgets/frontend/module/hal/resources/budget-resource.ts index e0d49fcb961b..d43b1ab5b339 100644 --- a/modules/budgets/frontend/module/hal/resources/budget-resource.ts +++ b/modules/budgets/frontend/module/hal/resources/budget-resource.ts @@ -26,15 +26,16 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { HalResource } from "core-app/features/hal/resources/hal-resource"; +import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { Attachable } from "core-app/features/hal/resources/mixins/attachable-mixin"; export interface BudgetResourceLinks { - addAttachment(attachment:HalResource):Promise; + addAttachment:CallableHalLink; } class BudgetBaseResource extends HalResource { - public $links:BudgetResourceLinks; + public $links:BudgetResourceLinks & HalResourceLinks; } export const BudgetResource = Attachable(BudgetBaseResource); diff --git a/modules/documents/frontend/module/hal/resources/document-resource.ts b/modules/documents/frontend/module/hal/resources/document-resource.ts index e11be7f691f0..32ad6a5af9c1 100644 --- a/modules/documents/frontend/module/hal/resources/document-resource.ts +++ b/modules/documents/frontend/module/hal/resources/document-resource.ts @@ -28,15 +28,16 @@ // This resource exists solely for the purpose of uploading attachments via the // WYSIWYIG editor. -import { HalResource } from "core-app/features/hal/resources/hal-resource"; +import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { Attachable } from "core-app/features/hal/resources/mixins/attachable-mixin"; export interface DocumentResourceLinks { - addAttachment(attachment:HalResource):Promise; + addAttachment:CallableHalLink; } class DocumentBaseResource extends HalResource { - public $links:DocumentResourceLinks; + public $links:DocumentResourceLinks & HalResourceLinks; private attachmentsBackend = false; } From e53b0b0d6d16745b227a524cd643e97bf7e1f8a6 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:50:16 +0100 Subject: [PATCH 3/9] [OP-20344] Type HAL resource sources Types HalResource#$source as HalSource and accepts an unknown source in the constructor and $initialize, which already unwrap a HalResource passed in place of a raw source. The initializer callback takes a HalResource, $copy and $plain drop their `any`, and lazy() becomes generic over the value it defines. initializeHalProperties now reads the source through typed views of `_links` and `_embedded` and passes the target maps to setupProperty directly instead of looking them up by a computed `$`-name, removing the builder's `any` casts. Runtime behaviour is unchanged. Callers and specs that relied on the untyped source get boundary casts. https://community.openproject.org/wp/OP-20344 --- .../global-search-input.component.spec.ts | 3 +- .../hal/helpers/hal-resource-builder.ts | 83 +++++++++++-------- .../app/features/hal/helpers/lazy-accessor.ts | 14 ++-- .../hal/resources/hal-resource.spec.ts | 2 +- .../features/hal/resources/hal-resource.ts | 33 +++++--- .../hal/resources/query-filter-resource.ts | 2 +- .../hal/resources/query-operator-resource.ts | 2 +- .../hal/resources/work-package-resource.ts | 14 ++-- .../hal/services/hal-resource.service.ts | 4 +- .../work-package-filter-values.spec.ts | 2 +- .../components/wp-new/wp-create.service.ts | 2 +- 11 files changed, 89 insertions(+), 72 deletions(-) diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts b/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts index 4257977ec617..b03c9cefa117 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts @@ -27,6 +27,7 @@ //++ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { HalSource } from 'core-app/features/hal/interfaces'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { GlobalSearchInputComponent } from './global-search-input.component'; @@ -62,7 +63,7 @@ describe('GlobalSearchInputComponent#followItem', () => { // so followItem exercises the production displayId getter rather than a stub. function buildWorkPackage(source:{ id:number, displayId?:string }):WorkPackageResource { const item = Object.create(WorkPackageResource.prototype) as WorkPackageResource; - item.$source = source; + item.$source = source as unknown as HalSource; return item; } diff --git a/frontend/src/app/features/hal/helpers/hal-resource-builder.ts b/frontend/src/app/features/hal/helpers/hal-resource-builder.ts index 3544fe5740a0..99e9d3e8a097 100644 --- a/frontend/src/app/features/hal/helpers/hal-resource-builder.ts +++ b/frontend/src/app/features/hal/helpers/hal-resource-builder.ts @@ -28,10 +28,10 @@ import ObservableArray from 'observable-array'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; -import { HalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { CallableHalLink, HalLink, HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link'; import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; import { OpenprojectHalModuleHelpers } from 'core-app/features/hal/helpers/lazy-accessor'; -import { HalSource } from 'core-app/features/hal/interfaces'; +import { HalSource, HalSourceLink } from 'core-app/features/hal/interfaces'; export function cloneHalResourceCollection(values:T[]|undefined):T[] { if (values == null) { @@ -55,6 +55,14 @@ export function initializeHalProperties(halResourceServic setLinksAsProperties(); setEmbeddedAsProperties(); + function sourceLinks():Record { + return halResource.$source._links; + } + + function sourceEmbedded():Record|undefined { + return halResource.$source._embedded as Record|undefined; + } + function setSource() { if (!halResource.$source._links) { halResource.$source._links = {}; @@ -65,7 +73,7 @@ export function initializeHalProperties(halResourceServic } } - function asHalResource(value?:HalSource, loaded = true):HalResource|HalSource|undefined|null { + function asHalResource(value?:HalSource|null, loaded = true):HalResource|HalSource|undefined|null { if (value == null) { return value; } @@ -78,14 +86,14 @@ export function initializeHalProperties(halResourceServic } function proxyProperties() { - halResource.$embeddableKeys().forEach((property:any) => { + halResource.$embeddableKeys().forEach((property:string) => { Object.defineProperty(halResource, property, { get() { - const value = halResource.$source[property]; + const value = halResource.$source[property] as HalSource|undefined; return asHalResource(value, true); }, - set(value) { + set(value:unknown) { halResource.$source[property] = value; }, @@ -97,12 +105,13 @@ export function initializeHalProperties(halResourceServic function setLinksAsProperties() { halResource.$linkableKeys().forEach((linkName:string) => { - OpenprojectHalModuleHelpers.lazy(halResource, linkName, + OpenprojectHalModuleHelpers.lazy(halResource, linkName, () => { - const link:any = halResource.$links[linkName].$link || halResource.$links[linkName]; + const entry = halResource.$links[linkName] as CallableHalLink|CallableHalLink[]; + const link = (entry as CallableHalLink).$link || entry; if (Array.isArray(link)) { - const items = link.map((item) => halResourceService.createLinkedResource(halResource, + const items = link.map((item:CallableHalLink) => halResourceService.createLinkedResource(halResource, linkName, item.$link)); const property:HalResource[] = new ObservableArray(...items).on('change', () => { @@ -112,7 +121,7 @@ export function initializeHalProperties(halResourceServic } }); - halResource.$source._links[linkName] = property.map((item) => item.$link); + sourceLinks()[linkName] = property.map((item) => item.$link); }); return property; @@ -128,92 +137,94 @@ export function initializeHalProperties(halResourceServic return null; }, - (val:any) => setter(val, linkName)); + (val) => setter(val, linkName)); }); } function setEmbeddedAsProperties() { - if (!halResource.$source._embedded) { + const embedded = sourceEmbedded(); + + if (!embedded) { return; } - Object.keys(halResource.$source._embedded).forEach((name) => { - OpenprojectHalModuleHelpers.lazy(halResource, + Object.keys(embedded).forEach((name) => { + OpenprojectHalModuleHelpers.lazy(halResource, name, () => halResource.$embedded[name], - (val:any) => setter(val, name)); + (val) => setter(val, name)); }); } - function setupProperty(name:string, callback:(element:any) => any) { - const instanceName = `$${name}`; - const sourceName = `_${name}`; - const sourceObj:any = halResource.$source[sourceName]; + function setupProperty(sourceName:'_links'|'_embedded', target:object, callback:(element:unknown) => unknown) { + const sourceObj = halResource.$source[sourceName]; if (typeof sourceObj === 'object' && sourceObj !== null) { Object.keys(sourceObj).forEach((propName) => { - OpenprojectHalModuleHelpers.lazy((halResource)[instanceName], + OpenprojectHalModuleHelpers.lazy(target, propName, - () => callback((sourceObj as any)[propName])); + () => callback((sourceObj as Record)[propName])); }); } } function setupLinks() { - setupProperty('links', + setupProperty('_links', + halResource.$links, (link) => { if (Array.isArray(link)) { - return link.map((l) => HalLink.fromObject(halResourceService, l).$callable()); + return (link as HalLinkInterface[]).map((l) => HalLink.fromObject(halResourceService, l).$callable()); } - return HalLink.fromObject(halResourceService, link).$callable(); + return HalLink.fromObject(halResourceService, link as HalLinkInterface).$callable(); }); } function setupEmbedded() { - setupProperty('embedded', (element:any) => { + setupProperty('_embedded', halResource.$embedded, (element) => { if (Array.isArray(element)) { - return element.map((source) => asHalResource(source, true)); + return (element as HalSource[]).map((source) => asHalResource(source, true)); } if (typeof element === 'object' && element !== null) { Object.entries(element as Record).forEach(([name, child]) => { if (child && (child._embedded || child._links)) { - OpenprojectHalModuleHelpers.lazy(element as any, + OpenprojectHalModuleHelpers.lazy(element, name, () => asHalResource(child, true)); } }); } - return asHalResource(element, true); + return asHalResource(element as HalSource|undefined, true); }); } - function setter(val:HalResource[]|HalResource|{ href?:string }, linkName:string) { + function setter(val:unknown, linkName:string):unknown { const isArray = Array.isArray(val); if (!val) { - halResource.$source._links[linkName] = { href: null }; + sourceLinks()[linkName] = { href: null }; } else if (isArray) { - halResource.$source._links[linkName] = (val).map((el:any) => ({ href: el.href })); + sourceLinks()[linkName] = (val as HalResource[]).map((el) => ({ href: el.href })); } else if (Object.hasOwn(val, '$link')) { const link = (val as HalResource).$link; if (link.href) { - halResource.$source._links[linkName] = link; + sourceLinks()[linkName] = link; } - } else if ('href' in val) { - halResource.$source._links[linkName] = { href: val.href }; + } else if ('href' in (val as { href?:string })) { + sourceLinks()[linkName] = { href: (val as { href?:string }).href }; } if (halResource.$embedded?.[linkName]) { halResource.$embedded[linkName] = val; + const embedded = sourceEmbedded()!; if (isArray) { - halResource.$source._embedded[linkName] = (val).map((el) => el.$source); + embedded[linkName] = (val as HalResource[]).map((el) => el.$source); } else { const source:unknown = (val as HalResource | undefined)?.$source; - (halResource.$source as { _embedded:Record })._embedded[linkName] = source === undefined ? val : source; + embedded[linkName] = source === undefined ? val : source; } } diff --git a/frontend/src/app/features/hal/helpers/lazy-accessor.ts b/frontend/src/app/features/hal/helpers/lazy-accessor.ts index f997696aff37..97e7beb09800 100644 --- a/frontend/src/app/features/hal/helpers/lazy-accessor.ts +++ b/frontend/src/app/features/hal/helpers/lazy-accessor.ts @@ -26,17 +26,15 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; - export namespace OpenprojectHalModuleHelpers { - export function lazy(obj:HalResource, + export function lazy(obj:object, property:string, - getter:() => any, - setter?:(value:any) => void):void { + getter:() => V, + setter?:(value:V) => V):void { if (typeof obj === 'object' && obj !== null) { let done = false; - let value:any; - const config:any = { + let value:V; + const config:PropertyDescriptor = { get() { if (!done) { value = getter(); @@ -51,7 +49,7 @@ export namespace OpenprojectHalModuleHelpers { }; if (setter) { - config.set = (val:any) => { + config.set = (val:V) => { value = setter(val); done = true; }; diff --git a/frontend/src/app/features/hal/resources/hal-resource.spec.ts b/frontend/src/app/features/hal/resources/hal-resource.spec.ts index b85737df34e1..8d6be30a1d30 100644 --- a/frontend/src/app/features/hal/resources/hal-resource.spec.ts +++ b/frontend/src/app/features/hal/resources/hal-resource.spec.ts @@ -493,7 +493,7 @@ describe('HalResource', () => { }); it(`should ${update} the $source property`, () => { - expect(resource.$source._links.values.length).toEqual(length); + expect((resource.$source._links.values as unknown as unknown[]).length).toEqual(length); }); }; diff --git a/frontend/src/app/features/hal/resources/hal-resource.ts b/frontend/src/app/features/hal/resources/hal-resource.ts index eaa0c6176557..efba30d3fd47 100644 --- a/frontend/src/app/features/hal/resources/hal-resource.ts +++ b/frontend/src/app/features/hal/resources/hal-resource.ts @@ -37,13 +37,13 @@ import { ICKEditorContext } from 'core-app/shared/components/editor/components/c import idFromLink from 'core-app/features/hal/helpers/id-from-link'; import { cloneDeep } from 'lodash-es'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; +import { HalSource } from 'core-app/features/hal/interfaces'; export type HalResourceClass = new( _injector:Injector, - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - _source:any, + _source:unknown, _$loaded:boolean, - _halInitializer:(_:T) => void, + _halInitializer:(_:HalResource) => void, _$halType:string, ) => T; @@ -91,20 +91,22 @@ export class HalResource { */ public constructor( public injector:Injector, - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - public $source:any, + $source:unknown, public $loaded:boolean, - public halInitializer:(halResource:any) => void, + public halInitializer:(halResource:HalResource) => void, $halType:string, ) { + this.$source = $source as HalSource; this.$halType = $halType; this.$initialize($source); } - public static getEmptyResource(self:{ href:string|null } = { href: null }):any { + public static getEmptyResource(self:{ href:string|null } = { href: null }):HalSource { return { _links: { self } }; } + public $source:HalSource; + public $links:HalResourceLinks = {}; public $embedded:HalResourceEmbedded = {}; @@ -118,8 +120,13 @@ export class HalResource { return match?.[1] ?? null; } - public $initialize(source:any) { - this.$source = source.$source || source; + public $initialize(source:unknown) { + const wrapped = (source as { $source?:HalSource }).$source; + if (wrapped) { + this.$source = wrapped; + } else { + this.$source = source as HalSource; + } this.halInitializer(this); } @@ -142,7 +149,7 @@ export class HalResource { */ public get id():string|null { if (this.$source.id) { - return this.$source.id.toString(); + return (this.$source.id as string|number).toString(); } const id = idFromLink(this.href); @@ -175,12 +182,12 @@ export class HalResource { * @returns A HalResource with the identitical copied source of other. */ public $copy(source:object = {}):T { - const clone:HalResourceClass = this.constructor as any; + const clone = this.constructor as HalResourceClass; return new clone(this.injector, merge(this.$plain(), source), this.$loaded, this.halInitializer, this.$halType); } - public $plain():any { + public $plain():HalSource { // Use a deep clone (not structuredClone) because $source may contain // HalResource instances (e.g. filter values), which carry functions and // injector state that structuredClone cannot clone (DataCloneError). @@ -273,7 +280,7 @@ export class HalResource { // Reset and load this resource this.$loaded = false; - this.$self = this.$links.self({}).then((source:any) => { + this.$self = this.$links.self({}).then((source:HalResource) => { this.$loaded = true; this.$initialize(source.$source); return this; diff --git a/frontend/src/app/features/hal/resources/query-filter-resource.ts b/frontend/src/app/features/hal/resources/query-filter-resource.ts index b635a95fe3d9..1c2d934441fc 100644 --- a/frontend/src/app/features/hal/resources/query-filter-resource.ts +++ b/frontend/src/app/features/hal/resources/query-filter-resource.ts @@ -33,7 +33,7 @@ export class QueryFilterResource extends HalResource { public values:any[]; public get id():string { - return this.$source.id || idFromLink(this.href); + return (this.$source.id as string) || idFromLink(this.href); } public set id(newId:string) { diff --git a/frontend/src/app/features/hal/resources/query-operator-resource.ts b/frontend/src/app/features/hal/resources/query-operator-resource.ts index 990b2e80be24..56ebfac944d4 100644 --- a/frontend/src/app/features/hal/resources/query-operator-resource.ts +++ b/frontend/src/app/features/hal/resources/query-operator-resource.ts @@ -31,7 +31,7 @@ import idFromLink from 'core-app/features/hal/helpers/id-from-link'; export class QueryOperatorResource extends HalResource { public get id():string { - return this.$source.id || idFromLink(this.href); + return (this.$source.id as string) || idFromLink(this.href); } public set id(val:string) { diff --git a/frontend/src/app/features/hal/resources/work-package-resource.ts b/frontend/src/app/features/hal/resources/work-package-resource.ts index 075a2fe471c1..7f85b4445828 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -42,7 +42,7 @@ import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorato import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { AttachmentCollectionResource } from 'core-app/features/hal/resources/attachment-collection-resource'; import { HalResource, HalResourceEmbedded, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; -import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { CallableHalLink, HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link'; import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; import { TypeResource } from 'core-app/features/hal/resources/type-resource'; import { RelationResource } from 'core-app/features/hal/resources/relation-resource'; @@ -132,8 +132,8 @@ export class WorkPackageBaseResource extends HalResource { * bare hrefs). */ public get displayId():string { - return this.$source.displayId?.toString() - ?? this.$source._links?.self?.displayId?.toString() + return (this.$source.displayId as string|number|undefined)?.toString() + ?? (this.$source._links?.self as HalLinkInterface|undefined)?.displayId?.toString() ?? this.id?.toString() ?? ''; } @@ -243,10 +243,10 @@ export class WorkPackageBaseResource extends HalResource { return otherWorkPackage.parent?.$links.self.$link.href === this.$links.self.$link.href; } - public $initialize(source:any) { + public $initialize(source:unknown) { super.$initialize(source); - const attachments:any = this.attachments || { $source: {}, elements: [] }; + const attachments:unknown = this.attachments || { $source: {}, elements: [] }; this.attachments = new AttachmentCollectionResource( this.injector, // Attachments MAY be an array if we're building from a form @@ -268,7 +268,7 @@ export class WorkPackageBaseResource extends HalResource { * Return the associated state to this HAL resource, if any. */ public get state():InputState { - return this.states.workPackages.get(this.id!) as any; + return this.states.workPackages.get(this.id!) as unknown as InputState; } /** @@ -282,7 +282,7 @@ export class WorkPackageBaseResource extends HalResource { this.apiV3Service.work_packages.id(newValue.parent).refresh(); } - return this.apiV3Service.work_packages.cache.updateWorkPackage(newValue as any); + return this.apiV3Service.work_packages.cache.updateWorkPackage(newValue as unknown as WorkPackageResource); } } diff --git a/frontend/src/app/features/hal/services/hal-resource.service.ts b/frontend/src/app/features/hal/services/hal-resource.service.ts index abc6f772f698..2898d397224e 100644 --- a/frontend/src/app/features/hal/services/hal-resource.service.ts +++ b/frontend/src/app/features/hal/services/hal-resource.service.ts @@ -224,7 +224,7 @@ export class HalResourceService { public createHalResourceOfType(type:string, source:any, loaded = false) { const resourceClass:HalResourceClass = this.getResourceClassOfType(type); - const initializer = (halResource:T) => initializeHalProperties(this, halResource); + const initializer = (halResource:HalResource) => initializeHalProperties(this, halResource); const resource = new resourceClass(this.injector, source, loaded, initializer, type); return resource; @@ -237,7 +237,7 @@ export class HalResourceService { * @param loaded */ public createHalResourceOfClass(resourceClass:HalResourceClass, source:any, loaded = false) { - const initializer = (halResource:T) => initializeHalProperties(this, halResource); + const initializer = (halResource:HalResource) => initializeHalProperties(this, halResource); const type = source._type || 'HalResource'; const resource = new resourceClass(this.injector, source, loaded, initializer, type); diff --git a/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts b/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts index ba285f772873..f7c0a9f30d76 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts @@ -99,7 +99,7 @@ describe('WorkPackageFilterValues', () => { injector = TestBed.inject(Injector); halResourceService = injector.get(HalResourceService); - resource = halResourceService.createHalResourceOfClass(WorkPackageResource, source, true); + resource = halResourceService.createHalResourceOfClass(WorkPackageResource, source, true) as unknown as WorkPackageResource; changeset = new WorkPackageChangeset(resource); const type1 = halResourceService.createHalResourceOfClass(TypeResource, { _type: 'Type', id: '1', _links: { self: { href: '/api/v3/types/1', name: 'Task' } } }); diff --git a/frontend/src/app/features/work-packages/components/wp-new/wp-create.service.ts b/frontend/src/app/features/work-packages/components/wp-new/wp-create.service.ts index 309828e93702..2795f8328f69 100644 --- a/frontend/src/app/features/work-packages/components/wp-new/wp-create.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-new/wp-create.service.ts @@ -358,7 +358,7 @@ export class WorkPackageCreateService extends UntilDestroyedMixin { 'Boolean', 'String', 'Text', - undefined].includes(schema.$source[attribute].type)) { + undefined].includes((schema.$source[attribute] as { type?:string }).type)) { links.push(attribute); } }); From 8d0b3263b34cf26d609795b52063ebf2806848f9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:55:13 +0100 Subject: [PATCH 4/9] [OP-20344] Type HAL schemas and field schemas Types ResourceChangeset#schema and WorkPackageChangeset#schema as ISchemaProxy, which is what both getters return. FormResource#schema stays the embedded form schema and gets its own FormSchemaResource type, a SchemaResource whose attributes are field schemas, and its commit link becomes a CallableHalLink. IFieldSchema is now derived from IOPFieldSchema instead of duplicating it with `any`-typed allowedValues and options. Fields that load allowed values through the link cast it at the boundary. SchemaProxy dispatches its proxied methods by name through proxyMethod, which keeps them bound to the handler and still honours subclass overrides, instead of wrapping unbound method references in a Function proxy. The payload helper checks own schema keys with Object.hasOwn. https://community.openproject.org/wp/OP-20344 --- .../features/hal/resources/form-resource.ts | 10 ++++--- .../hal/schemas/hal-payload.helper.ts | 5 ++-- .../app/features/hal/schemas/schema-proxy.ts | 26 +++++-------------- .../hal/schemas/work-package-schema-proxy.ts | 2 +- .../wp-edit/work-package-changeset.ts | 5 ++-- .../fields/changeset/resource-changeset.ts | 5 ++-- .../multi-select-edit-field.component.ts | 2 +- .../project-edit-field.component.ts | 4 +-- .../select-edit-field.component.ts | 7 +++-- .../work-package-edit-field.component.ts | 6 ++--- .../shared/components/fields/field.base.ts | 12 +++------ 11 files changed, 35 insertions(+), 49 deletions(-) diff --git a/frontend/src/app/features/hal/resources/form-resource.ts b/frontend/src/app/features/hal/resources/form-resource.ts index 0cf1a5ecbe15..4f01eba40804 100644 --- a/frontend/src/app/features/hal/resources/form-resource.ts +++ b/frontend/src/app/features/hal/resources/form-resource.ts @@ -29,18 +29,22 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { ErrorResource, v3ErrorIdentifierMultipleErrors } from 'core-app/features/hal/resources/error-resource'; import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { IOPFieldSchema } from 'core-app/features/hal/interfaces'; + +export type FormSchemaResource = SchemaResource & Partial>; export interface FormResourceLinks { - commit(payload:any):Promise; + commit:CallableHalLink; } export interface FormResourceEmbedded { - schema:SchemaResource; + schema:FormSchemaResource; validationErrors:Record; } export class FormResource extends HalResource { - public schema:SchemaResource; + public schema:FormSchemaResource; public validationErrors:Record; diff --git a/frontend/src/app/features/hal/schemas/hal-payload.helper.ts b/frontend/src/app/features/hal/schemas/hal-payload.helper.ts index 9d2ed16fa9f4..3ed967b3f308 100644 --- a/frontend/src/app/features/hal/schemas/hal-payload.helper.ts +++ b/frontend/src/app/features/hal/schemas/hal-payload.helper.ts @@ -28,6 +28,7 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; +import { IOPFieldSchema } from 'core-app/features/hal/interfaces'; export class HalPayloadHelper { /** @@ -63,13 +64,13 @@ export class HalPayloadHelper { const nonLinkProperties = []; for (const key in schema) { - if (schema.hasOwnProperty(key) && schema[key]?.writable) { + if (Object.hasOwn(schema, key) && (schema[key] as IOPFieldSchema|undefined)?.writable) { if (resource.$links[key]) { if (Array.isArray(resource[key])) { payload._links[key] = (resource[key] as HalResource[]).map((element) => ({ href: element.href })); } else { payload._links[key] = { - href: (resource[key]?.href), + href: (resource[key] as HalResource|undefined)?.href, }; } } else { diff --git a/frontend/src/app/features/hal/schemas/schema-proxy.ts b/frontend/src/app/features/hal/schemas/schema-proxy.ts index 03fbce11685f..08d1ea9cfda4 100644 --- a/frontend/src/app/features/hal/schemas/schema-proxy.ts +++ b/frontend/src/app/features/hal/schemas/schema-proxy.ts @@ -50,16 +50,12 @@ export class SchemaProxy implements ProxyHandler { ) as ISchemaProxy; } - get(schema:SchemaResource, property:PropertyKey, receiver:any):any { + get(schema:SchemaResource, property:PropertyKey, receiver:unknown):unknown { switch (property) { - case 'ofProperty': { - return this.proxyMethod(this.ofProperty); - } - case 'isAttributeEditable': { - return this.proxyMethod(this.isAttributeEditable); - } + case 'ofProperty': + case 'isAttributeEditable': case 'mappedName': { - return this.proxyMethod(this.mappedName); + return this.proxyMethod(property); } case 'isEditable': { return this.isEditable; @@ -81,7 +77,7 @@ export class SchemaProxy implements ProxyHandler { * @param property the schema part is desired for */ public ofProperty(property:string):IFieldSchema|null { - const propertySchema = this.schema[this.mappedName(property)]; + const propertySchema = this.schema[this.mappedName(property)] as IFieldSchema|undefined; if (propertySchema) { return { ...propertySchema, writable: this.isEditable && propertySchema?.writable }; @@ -117,15 +113,7 @@ export class SchemaProxy implements ProxyHandler { return property; } - private proxyMethod(method:Function) { - const self = this; - - // Returning a Proxy here so that the call is bound - // to the SchemaProxy instance. - return new Proxy(method, { - apply(_, __, argumentsList) { - return method.apply(self, [argumentsList[0]]); - }, - }); + private proxyMethod(name:'ofProperty'|'isAttributeEditable'|'mappedName') { + return (property:string) => this[name](property); } } diff --git a/frontend/src/app/features/hal/schemas/work-package-schema-proxy.ts b/frontend/src/app/features/hal/schemas/work-package-schema-proxy.ts index 34f5cfd3ccd9..69a7fecde0b9 100644 --- a/frontend/src/app/features/hal/schemas/work-package-schema-proxy.ts +++ b/frontend/src/app/features/hal/schemas/work-package-schema-proxy.ts @@ -31,7 +31,7 @@ import { SchemaResource } from 'core-app/features/hal/resources/schema-resource' import { StatusResource } from 'core-app/features/hal/resources/status-resource'; export class WorkPackageSchemaProxy extends SchemaProxy { - get(schema:SchemaResource, property:PropertyKey, receiver:any):any { + get(schema:SchemaResource, property:PropertyKey, receiver:unknown):unknown { switch (property) { case 'isMilestone': { return this.isMilestone; diff --git a/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts b/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts index dd948d7ad67f..34a4b1fc935d 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts @@ -28,7 +28,7 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; -import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; +import { ISchemaProxy } from 'core-app/features/hal/schemas/schema-proxy'; import { WorkPackageSchemaProxy } from 'core-app/features/hal/schemas/work-package-schema-proxy'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; @@ -57,7 +57,6 @@ export class WorkPackageChangeset extends ResourceChangeset // Explicitly not send the subject, if the subject was not editable. // In this case a generated template is rendered in the subject and // must not get submitted. - // eslint-disable-next-line @typescript-eslint/no-unsafe-call if (!this.schema.isAttributeEditable('subject')) { delete (payload as { subject?:string }).subject; } @@ -96,7 +95,7 @@ export class WorkPackageChangeset extends ResourceChangeset * If loaded, return the form schema, which provides better information on writable status * and contains available values. */ - public get schema():SchemaResource { + public get schema():ISchemaProxy { if (this.form$.hasValue()) { return WorkPackageSchemaProxy.create(super.schema, this.projectedResource); } diff --git a/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts b/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts index 0c386694a9df..ccc50af8bb17 100644 --- a/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts +++ b/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts @@ -32,14 +32,13 @@ import { } from '@openproject/reactivestates'; import { cloneDeep } from 'lodash-es'; -import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; import { FormResource } from 'core-app/features/hal/resources/form-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { ChangeMap, Changeset } from 'core-app/shared/components/fields/changeset/changeset'; import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; -import { SchemaProxy } from 'core-app/features/hal/schemas/schema-proxy'; +import { ISchemaProxy, SchemaProxy } from 'core-app/features/hal/schemas/schema-proxy'; import { IHalOptionalTitledLink } from 'core-app/core/state/hal-resource'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { firstValueFrom } from 'rxjs'; @@ -347,7 +346,7 @@ export class ResourceChangeset { * If loaded, return the form schema, which provides better information on writable status * and contains available values. */ - public get schema():SchemaResource { + public get schema():ISchemaProxy { if (this.form$.hasValue()) { return SchemaProxy.create(this.form$.value!.schema, this.projectedResource); } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts index 3a7f3514cf4e..64bb3e567caa 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts @@ -199,7 +199,7 @@ export class MultiSelectEditFieldComponent extends EditFieldComponent implements if (Array.isArray(allowedValues)) { this.setValues(allowedValues); } else if (this.schema.allowedValues) { - return (this.schema.allowedValues.$load() as Promise).then((values:CollectionResource) => { + return ((this.schema.allowedValues as HalResource).$load() as Promise).then((values:CollectionResource) => { // The select options of the project shall be sorted if (values.count > 0 && (values.elements[0] as any)._type === 'Project') { this.setValues(values.elements, true); diff --git a/frontend/src/app/shared/components/fields/edit/field-types/project-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/project-edit-field.component.ts index 2fc92e38dcfa..f579817dfb45 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/project-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/project-edit-field.component.ts @@ -38,6 +38,7 @@ import { HalResourceService } from 'core-app/features/hal/services/hal-resource. import { take, tap } from 'rxjs/operators'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { IAPIFilter } from 'core-app/shared/components/autocompleter/op-autocompleter/typings'; import { FilterOperator } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; @@ -102,7 +103,6 @@ export class ProjectEditFieldComponent extends EditFieldComponent implements OnI } private setUrl():void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.url = this.schema.allowedValues.$link.href as string; + this.url = (this.schema.allowedValues as CallableHalLink).$link.href!; } } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts index 34ef43e5c462..9e2e872f9875 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts @@ -41,7 +41,7 @@ import { CollectionResource } from 'core-app/features/hal/resources/collection-r import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; import { HalResourceSortingService } from 'core-app/features/hal/services/hal-resource-sorting.service'; import { EditFieldComponent } from '../../edit-field.component'; -import { HalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { EventHandler } from 'ng-dynamic-component'; export interface ValueOption { @@ -183,7 +183,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn protected loadAllowedValues(query?:string):Promise { // Cache the search without any params if (!query) { - const cacheKey = this.schema.allowedValues.$link.href; + const cacheKey = (this.schema.allowedValues as CallableHalLink).$link.href!; return this.change.cacheValue(cacheKey, this.fetchAllowedValueQuery.bind(this)); } @@ -191,8 +191,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn } protected fetchAllowedValueQuery(query?:string):Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const link = this.schema.allowedValues?.$link as HalLink|undefined; + const link = (this.schema.allowedValues as CallableHalLink|undefined)?.$link; // Race condition: Field was under edit but is no longer editable / values not loadable // which means the schema switched during the period it opened / updated after saved. diff --git a/frontend/src/app/shared/components/fields/edit/field-types/work-package-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/work-package-edit-field.component.ts index bd898c98d2db..0e6c9d7f4123 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/work-package-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/work-package-edit-field.component.ts @@ -28,6 +28,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { DebouncedRequestSwitchmap, errorNotificationHandler, @@ -72,11 +73,10 @@ export class WorkPackageEditFieldComponent extends SelectEditFieldComponent { protected fetchAllowedValueQuery(query?:string):Promise { if (this.name === 'parent') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - return this.schema.allowedValues.$link.$fetch({ + return (this.schema.allowedValues as CallableHalLink).$link.$fetch({ query, sortBy: '[["exactMatch","desc"],["updatedAt","desc"]]', - }) as Promise; + }); } return super.fetchAllowedValueQuery(query); diff --git a/frontend/src/app/shared/components/fields/field.base.ts b/frontend/src/app/shared/components/fields/field.base.ts index d71ee7a17132..37d197eee7d0 100644 --- a/frontend/src/app/shared/components/fields/field.base.ts +++ b/frontend/src/app/shared/components/fields/field.base.ts @@ -28,15 +28,11 @@ import { DisplayFieldContext } from 'core-app/shared/components/fields/display/display-field.service'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; +import { IOPFieldSchema } from 'core-app/features/hal/interfaces'; -export interface IFieldSchema { - type:string; - writable:boolean; - allowedValues?:any; - required?:boolean; - hasDefault:boolean; +export interface IFieldSchema extends Omit { name:string; - options?:any; + options?:IOPFieldSchema['options']; placeholder?:string; } @@ -75,7 +71,7 @@ export class Field extends UntilDestroyedMixin { return this.schema.hasDefault; } - public get options():boolean { + public get options():IFieldSchema['options'] { return this.schema.options; } From 7e5ec6f485103087cefc1792f5176c9657672c0c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:58:28 +0100 Subject: [PATCH 5/9] [OP-20344] Declare HAL resource attributes Declares attributes and link callables that callers already read through the `any` index signature: FormResource#payload, #commit and #configureForm, QueryResource#star, #unstar, #icalUrl and #updatedAt, TimeEntryResource#hours, WorkPackageCollectionResource#customFields and #createWorkPackage, and WorkPackageResource#configureForm and #bcfViewpoints. WorkPackageResource#description becomes Formattable and RelationResource#type a string. Class and interface declaration merges are replaced by member declarations on the class (with `implements` for the exported shape), which emit no code under useDefineForClassFields: false. Resource state getters cast through unknown instead of `any`. Callers whose values are now typed get boundary casts. https://community.openproject.org/wp/OP-20344 --- .../endpoints/queries/apiv3-queries-paths.ts | 4 +-- .../core/apiv3/virtual/apiv3-boards-paths.ts | 2 +- .../bcf-wp-attribute-group.component.ts | 22 ++++++++------ .../attachment-collection-resource.ts | 8 ++--- .../hal/resources/custom-action-resource.ts | 14 +++++---- .../features/hal/resources/form-resource.ts | 11 +++++-- .../hal/resources/membership-resource.ts | 24 +++++++++++---- .../resources/placeholder-user-resource.ts | 2 +- .../hal/resources/project-resource.ts | 4 +-- .../features/hal/resources/query-resource.ts | 29 +++++++++++++------ .../hal/resources/relation-resource.ts | 12 ++++---- .../features/hal/resources/schema-resource.ts | 2 +- .../features/hal/resources/share-resource.ts | 11 +++++-- .../features/hal/resources/status-resource.ts | 2 +- .../hal/resources/time-entry-resource.ts | 9 +++--- .../features/hal/resources/type-resource.ts | 2 +- .../features/hal/resources/user-resource.ts | 2 +- .../hal/resources/work-package-resource.ts | 6 +++- .../hal/resources/wp-collection-resource.ts | 10 ++++--- .../wp-edit/work-package-changeset.ts | 2 +- .../op-settings-dropdown-menu.directive.ts | 2 +- .../wp-create-settings-menu.directive.ts | 2 +- .../wp-context-menu/wp-single-context-menu.ts | 3 +- 23 files changed, 116 insertions(+), 69 deletions(-) diff --git a/frontend/src/app/core/apiv3/endpoints/queries/apiv3-queries-paths.ts b/frontend/src/app/core/apiv3/endpoints/queries/apiv3-queries-paths.ts index 138bcc194b2d..71080464e1c1 100644 --- a/frontend/src/app/core/apiv3/endpoints/queries/apiv3-queries-paths.ts +++ b/frontend/src/app/core/apiv3/endpoints/queries/apiv3-queries-paths.ts @@ -130,9 +130,9 @@ export class ApiV3QueriesPaths extends ApiV3ResourceCollection { if (query.starred) { - return query.unstar(); + return query.unstar!(); } - return query.star(); + return query.star!(); } /** diff --git a/frontend/src/app/core/apiv3/virtual/apiv3-boards-paths.ts b/frontend/src/app/core/apiv3/virtual/apiv3-boards-paths.ts index 8c63d64e2e66..05bc64cafde5 100644 --- a/frontend/src/app/core/apiv3/virtual/apiv3-boards-paths.ts +++ b/frontend/src/app/core/apiv3/virtual/apiv3-boards-paths.ts @@ -136,7 +136,7 @@ export class ApiV3BoardsPaths extends ApiV3Collection { switchMap((form) => this .apiRoot .grids - .post(form.payload.$source)), + .post(form.payload.$source as unknown as GridResource)), ); } } diff --git a/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts b/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts index d257d009183e..241968ad46ab 100644 --- a/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts +++ b/frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts @@ -171,17 +171,21 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements .id(this.workPackage) .requireAndStream() .pipe(this.untilDestroyed()) - .subscribe(async (wp) => { - this.workPackage = wp; + .subscribe((wp) => { + void this.onWorkPackageChange(wp); + }); + } - if (!this.projectId) { - await this.initialize(this.workPackage); - } + private async onWorkPackageChange(wp:WorkPackageResource) { + this.workPackage = wp; - if (wp.bcfViewpoints) { - this.refreshViewpoints(wp.bcfViewpoints); - } - }); + if (!this.projectId) { + await this.initialize(this.workPackage); + } + + if (wp.bcfViewpoints) { + this.refreshViewpoints(wp.bcfViewpoints as unknown as HalLink[]); + } } async initialize(workPackage:WorkPackageResource) { diff --git a/frontend/src/app/features/hal/resources/attachment-collection-resource.ts b/frontend/src/app/features/hal/resources/attachment-collection-resource.ts index 539ddd736fc7..e564b264531c 100644 --- a/frontend/src/app/features/hal/resources/attachment-collection-resource.ts +++ b/frontend/src/app/features/hal/resources/attachment-collection-resource.ts @@ -30,13 +30,11 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; export class AttachmentCollectionResource extends CollectionResource { - public $initialize(source:any) { + public elements:HalResource[]; + + public $initialize(source:unknown) { super.$initialize(source); this.elements = this.elements || []; } } - -export interface AttachmentCollectionResource { - elements:HalResource[]; -} diff --git a/frontend/src/app/features/hal/resources/custom-action-resource.ts b/frontend/src/app/features/hal/resources/custom-action-resource.ts index aa3db9504dee..0bae6b5046ca 100644 --- a/frontend/src/app/features/hal/resources/custom-action-resource.ts +++ b/frontend/src/app/features/hal/resources/custom-action-resource.ts @@ -28,17 +28,21 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; export interface CustomActionResourceLinks { - self():Promise; - executeImmediately(payload:any):Promise; + self:CallableHalLink; + executeImmediately:CallableHalLink; } export interface CustomActionResourceEmbedded { description:string; } -export class CustomActionResource extends HalResource { -} +export class CustomActionResource extends HalResource implements CustomActionResourceLinks, CustomActionResourceEmbedded { + public self:CallableHalLink; + + public executeImmediately:CallableHalLink; -export interface CustomActionResource extends CustomActionResourceLinks, CustomActionResourceEmbedded {} + public description:string; +} diff --git a/frontend/src/app/features/hal/resources/form-resource.ts b/frontend/src/app/features/hal/resources/form-resource.ts index 4f01eba40804..f76723b91cf7 100644 --- a/frontend/src/app/features/hal/resources/form-resource.ts +++ b/frontend/src/app/features/hal/resources/form-resource.ts @@ -41,13 +41,20 @@ export interface FormResourceLinks { export interface FormResourceEmbedded { schema:FormSchemaResource; validationErrors:Record; + payload:HalResource; } -export class FormResource extends HalResource { +export class FormResource extends HalResource implements FormResourceEmbedded, FormResourceLinks { public schema:FormSchemaResource; public validationErrors:Record; + public payload:HalResource; + + public commit:CallableHalLink; + + public configureForm?:HalResource; + public getErrors():ErrorResource|null { const errors = Object.values(this.validationErrors ?? {}); const count = errors.length; @@ -68,5 +75,3 @@ export class FormResource extends HalResource { return resource; } } - -export interface FormResource extends FormResourceEmbedded, FormResourceLinks {} diff --git a/frontend/src/app/features/hal/resources/membership-resource.ts b/frontend/src/app/features/hal/resources/membership-resource.ts index 1c7c5e98e798..b8fe2ed839c2 100644 --- a/frontend/src/app/features/hal/resources/membership-resource.ts +++ b/frontend/src/app/features/hal/resources/membership-resource.ts @@ -27,14 +27,15 @@ //++ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { RoleResource } from 'core-app/features/hal/resources/role-resource'; import { ProjectResource } from 'core-app/features/hal/resources/project-resource'; import Formattable = api.v3.Formattable; export interface MembershipResourceLinks { - update(payload:unknown):Promise; - updateImmediately(payload:unknown):Promise; - delete():Promise; + update:CallableHalLink; + updateImmediately:CallableHalLink; + delete:CallableHalLink; } export interface MembershipResourceEmbedded { @@ -44,7 +45,18 @@ export interface MembershipResourceEmbedded { notificationMessage:Formattable; } -export class MembershipResource extends HalResource { -} +export class MembershipResource extends HalResource implements MembershipResourceLinks, MembershipResourceEmbedded { + public update:CallableHalLink; + + public updateImmediately:CallableHalLink; + + public delete:CallableHalLink; + + public principal:HalResource; -export interface MembershipResource extends MembershipResourceLinks, MembershipResourceEmbedded {} + public roles:RoleResource[]; + + public project:ProjectResource; + + public notificationMessage:Formattable; +} diff --git a/frontend/src/app/features/hal/resources/placeholder-user-resource.ts b/frontend/src/app/features/hal/resources/placeholder-user-resource.ts index 89ba7e275e6e..2d940528ab9a 100644 --- a/frontend/src/app/features/hal/resources/placeholder-user-resource.ts +++ b/frontend/src/app/features/hal/resources/placeholder-user-resource.ts @@ -38,7 +38,7 @@ export class PlaceholderUserResource extends HalResource { public showUser:HalResource; public get state():InputState { - return this.states.placeholderUsers.get(this.href!) as any; + return this.states.placeholderUsers.get(this.href!) as unknown as InputState; } public get showUserPath() { diff --git a/frontend/src/app/features/hal/resources/project-resource.ts b/frontend/src/app/features/hal/resources/project-resource.ts index 6d3dec17be79..6a93edfda726 100644 --- a/frontend/src/app/features/hal/resources/project-resource.ts +++ b/frontend/src/app/features/hal/resources/project-resource.ts @@ -26,17 +26,17 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { InputState } from '@openproject/reactivestates'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { ICKEditorContext } from 'core-app/shared/components/editor/components/ckeditor/ckeditor.types'; export class ProjectResource extends HalResource { public get identifier():string { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return this.$source.identifier as string; } public get state() { - return this.states.projects.get(this.id!) as any; + return this.states.projects.get(this.id!) as unknown as InputState; } public getEditorContext(fieldName:string):ICKEditorContext { diff --git a/frontend/src/app/features/hal/resources/query-resource.ts b/frontend/src/app/features/hal/resources/query-resource.ts index ca3b70d95a9c..0e5891f2c295 100644 --- a/frontend/src/app/features/hal/resources/query-resource.ts +++ b/frontend/src/app/features/hal/resources/query-resource.ts @@ -28,6 +28,7 @@ import { QueryColumn } from 'core-app/features/work-packages/components/wp-query/query-column'; import { HalResource, HalResourceEmbedded } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { HighlightingMode } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting-mode.const'; import { QueryOrder } from 'core-app/core/apiv3/endpoints/queries/apiv3-query-order'; import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; @@ -55,7 +56,14 @@ export interface TimelineLabels { farRight:string|null; } -export class QueryResource extends HalResource { +export interface QueryResourceLinks { + updateImmediately?:CallableHalLink; + icalUrl:CallableHalLink<{ icalUrl:{ href:string } }>; + star?:CallableHalLink; + unstar?:CallableHalLink; +} + +export class QueryResource extends HalResource implements QueryResourceLinks { public $embedded:QueryResourceEmbedded; public results:WorkPackageCollectionResource; @@ -106,7 +114,17 @@ export class QueryResource extends HalResource { public ordered_work_packages:QueryOrder; - public $initialize(source:any) { + public updatedAt:string; + + public updateImmediately?:CallableHalLink; + + public icalUrl:CallableHalLink<{ icalUrl:{ href:string } }>; + + public star?:CallableHalLink; + + public unstar?:CallableHalLink; + + public $initialize(source:unknown) { super.$initialize(source); this.filters = this @@ -120,10 +138,3 @@ export class QueryResource extends HalResource { )); } } - -export interface QueryResourceLinks { - updateImmediately?(attributes:any):Promise; - icalUrl(payload:unknown):Promise<{ icalUrl:{ href:string } }>; -} - -export interface QueryResource extends QueryResourceLinks {} diff --git a/frontend/src/app/features/hal/resources/relation-resource.ts b/frontend/src/app/features/hal/resources/relation-resource.ts index 416847074ed1..c7cb8247769f 100644 --- a/frontend/src/app/features/hal/resources/relation-resource.ts +++ b/frontend/src/app/features/hal/resources/relation-resource.ts @@ -37,7 +37,7 @@ export interface RelationResourceLinks { updateImmediately:CallableHalLink; } -export class RelationResource extends HalResource { +export class RelationResource extends HalResource implements RelationResourceLinks { static RELATION_TYPES(includeParentChild = true):string[] { const types = [ 'relates', @@ -73,13 +73,17 @@ export class RelationResource extends HalResource { // Properties public description:string|null; - public type:any; + public type:string; public reverseType:string; // Links public $links:RelationResourceLinks & HalResourceLinks; + public delete:CallableHalLink; + + public updateImmediately:CallableHalLink; + public to:WorkPackageResource; public from:WorkPackageResource; @@ -128,13 +132,11 @@ export class RelationResource extends HalResource { return this.$links.updateImmediately({ description }); } - public updateType(type:any) { + public updateType(type:string) { return this.$links.updateImmediately({ type }); } } -export interface RelationResource extends RelationResourceLinks {} - export interface DenormalizedRelationData { target:WorkPackageResource; targetId:string; diff --git a/frontend/src/app/features/hal/resources/schema-resource.ts b/frontend/src/app/features/hal/resources/schema-resource.ts index 9066845f0609..86411d45294a 100644 --- a/frontend/src/app/features/hal/resources/schema-resource.ts +++ b/frontend/src/app/features/hal/resources/schema-resource.ts @@ -32,7 +32,7 @@ import { InputState } from '@openproject/reactivestates'; export class SchemaResource extends HalResource { public get state():InputState { - return this.states.schemas.get(this.href!) as any; + return this.states.schemas.get(this.href!) as unknown as InputState; } public get availableAttributes():string[] { diff --git a/frontend/src/app/features/hal/resources/share-resource.ts b/frontend/src/app/features/hal/resources/share-resource.ts index a787c1acfb0a..f1ba1a2d8fc2 100644 --- a/frontend/src/app/features/hal/resources/share-resource.ts +++ b/frontend/src/app/features/hal/resources/share-resource.ts @@ -37,7 +37,12 @@ export interface ShareResourceEmbedded { entity:HalResource; } -export class ShareResource extends HalResource { -} +export class ShareResource extends HalResource implements ShareResourceEmbedded { + public principal:HalResource; + + public roles:RoleResource[]; -export interface ShareResource extends ShareResourceEmbedded {} + public project:ProjectResource; + + public entity:HalResource; +} diff --git a/frontend/src/app/features/hal/resources/status-resource.ts b/frontend/src/app/features/hal/resources/status-resource.ts index 2c7152f81986..691ea8b81225 100644 --- a/frontend/src/app/features/hal/resources/status-resource.ts +++ b/frontend/src/app/features/hal/resources/status-resource.ts @@ -39,6 +39,6 @@ export class StatusResource extends HalResource { excludedFromTotals:boolean; public get state():InputState { - return this.states.statuses.get(this.href!) as any; + return this.states.statuses.get(this.href!) as unknown as InputState; } } diff --git a/frontend/src/app/features/hal/resources/time-entry-resource.ts b/frontend/src/app/features/hal/resources/time-entry-resource.ts index 9492744f1142..4c8bf804dbd1 100644 --- a/frontend/src/app/features/hal/resources/time-entry-resource.ts +++ b/frontend/src/app/features/hal/resources/time-entry-resource.ts @@ -27,6 +27,7 @@ //++ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { ProjectResource } from 'core-app/features/hal/resources/project-resource'; import { InputState } from '@openproject/reactivestates'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; @@ -48,6 +49,10 @@ export class TimeEntryResource extends HalResource { ongoing:boolean; + hours:string; + + delete:CallableHalLink; + public get state():InputState { return this.states.timeEntries.get(this.id!) as unknown as InputState; } @@ -60,10 +65,6 @@ export class TimeEntryResource extends HalResource { } } -export interface TimeEntryResource { - delete():Promise; -} - export function formatTimeEntryEntityName(entity:WorkPackageResource|MeetingResource):string { const displayId = entity.$link?.displayId; const formattedId = displayId ? formatWorkPackageId(displayId) : `#${idFromLink(entity.href)}`; diff --git a/frontend/src/app/features/hal/resources/type-resource.ts b/frontend/src/app/features/hal/resources/type-resource.ts index fddd3cdcbe18..4a7c728ef070 100644 --- a/frontend/src/app/features/hal/resources/type-resource.ts +++ b/frontend/src/app/features/hal/resources/type-resource.ts @@ -33,6 +33,6 @@ export class TypeResource extends HalResource { public color:string; public get state():InputState { - return this.states.types.get(this.href!) as any; + return this.states.types.get(this.href!) as unknown as InputState; } } diff --git a/frontend/src/app/features/hal/resources/user-resource.ts b/frontend/src/app/features/hal/resources/user-resource.ts index 5167a7e2abc7..8d7032a6a1e2 100644 --- a/frontend/src/app/features/hal/resources/user-resource.ts +++ b/frontend/src/app/features/hal/resources/user-resource.ts @@ -57,7 +57,7 @@ export class UserResource extends HalResource { } public get state():InputState { - return this.states.users.get(this.href!) as any; + return this.states.users.get(this.href!) as unknown as InputState; } public get showUserPath() { diff --git a/frontend/src/app/features/hal/resources/work-package-resource.ts b/frontend/src/app/features/hal/resources/work-package-resource.ts index 7f85b4445828..2b36a34051fb 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -153,7 +153,11 @@ export class WorkPackageBaseResource extends HalResource { public hasProjectAttributes:boolean; - public description:any; + public description:api.v3.Formattable; + + public configureForm?:HalResource; + + public bcfViewpoints?:HalResource[]; public activities:CollectionResource; diff --git a/frontend/src/app/features/hal/resources/wp-collection-resource.ts b/frontend/src/app/features/hal/resources/wp-collection-resource.ts index a6df82c9a15b..eab07b4dccea 100644 --- a/frontend/src/app/features/hal/resources/wp-collection-resource.ts +++ b/frontend/src/app/features/hal/resources/wp-collection-resource.ts @@ -30,16 +30,20 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-packag import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; +import { FormResource } from 'core-app/features/hal/resources/form-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; export interface WorkPackageCollectionResourceEmbedded { elements:WorkPackageResource[]; groups:GroupObject[]; } -export class WorkPackageCollectionResource extends CollectionResource { +export class WorkPackageCollectionResource extends CollectionResource implements WorkPackageCollectionResourceEmbedded { public schemas:CollectionResource; - public createWorkPackage:any; + public createWorkPackage?:CallableHalLink; + + public customFields?:HalResource; public elements:WorkPackageResource[]; @@ -52,8 +56,6 @@ export class WorkPackageCollectionResource extends CollectionResource // Special handling for taking over the description and // the subject to the pristine resource. if (key === 'description' && isNewResource(this.pristineResource)) { - this.pristineResource.description = val; + this.pristineResource.description = val as api.v3.Formattable; return; } diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts index a23d892f3a32..afb300cefb3d 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts @@ -350,7 +350,7 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger implements Aft { // Settings modal hidden: !this.query.results.customFields || this.hideTableOptions, - href: this.query.results.customFields?.href, + href: this.query.results.customFields?.href as string|undefined, linkText: this.query.results.customFields?.name, icon: 'icon-custom-fields', onClick: () => false, diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts index 17943fb47409..4ca70334471c 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/wp-create-settings-menu.directive.ts @@ -73,7 +73,7 @@ export class WorkPackageCreateSettingsMenuDirective extends OpContextMenuTrigger if (configureFormLink) { this.items.push({ - href: configureFormLink.href, + href: configureFormLink.href as string|undefined, icon: 'icon-settings3', linkText: configureFormLink.name, onClick: () => false, diff --git a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts index 4735c26d7e45..4217ab9ae5b9 100644 --- a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts +++ b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts @@ -211,7 +211,6 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger } protected buildItems(permittedActions:WorkPackageAction[]):OpContextMenuItem[] { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const configureFormLink = this.workPackage.configureForm; this.items = permittedActions.map((action:WorkPackageAction) => { @@ -243,7 +242,7 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger if (configureFormLink) { this.items.push( { - href: configureFormLink.href, + href: configureFormLink.href as string|undefined, icon: 'icon-settings3', linkText: I18n.t('js.button_configure-form'), onClick: () => false, From c79db15110600f76ae62aa7609353b585a52c16b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 23:04:10 +0100 Subject: [PATCH 6/9] [OP-20344] Type HAL services and error resources Types the request data, created sources and registered classes in HalResourceService, the HTTP client options and interceptor, and ErrorResource's errors and details. HalResourceNotificationService takes ErrorResource where it reads error attributes and treats raw responses as unknown. HalError#resource is nullable, so the two callers passing it to showError assert it at the boundary, as the service already assumed. HTTPClientParamMap keeps its `any` values: query and BCF callers pass untyped objects as request params. https://community.openproject.org/wp/OP-20344 --- .../app/features/hal/http/http.interfaces.ts | 6 +-- .../http/openproject-header-interceptor.ts | 6 +-- .../features/hal/resources/error-resource.ts | 14 +++---- .../hal-resource-notification.service.ts | 26 ++++++------- .../hal/services/hal-resource.service.ts | 37 +++++++++++-------- .../planner/team-planner.component.ts | 2 +- .../wp-status-dropdown-menu.directive.ts | 2 +- 7 files changed, 50 insertions(+), 43 deletions(-) diff --git a/frontend/src/app/features/hal/http/http.interfaces.ts b/frontend/src/app/features/hal/http/http.interfaces.ts index 075e1cff96d4..c0432974a924 100644 --- a/frontend/src/app/features/hal/http/http.interfaces.ts +++ b/frontend/src/app/features/hal/http/http.interfaces.ts @@ -31,13 +31,13 @@ import { HttpHeaders, HttpParams } from '@angular/common/http'; export type HTTPSupportedMethods = 'get'|'post'|'put'|'patch'|'delete'; export interface HTTPClientOptions { - body?:any; + body?:unknown; headers?:HTTPClientHeaders; - observe?:any; + observe?:'body'; params?:HTTPClientParams; reportProgress?:boolean; withCredentials?:boolean; - responseType:any; + responseType:'json'; } export type HTTPClientParamMap = Record; diff --git a/frontend/src/app/features/hal/http/openproject-header-interceptor.ts b/frontend/src/app/features/hal/http/openproject-header-interceptor.ts index 085e306346c0..f3c0510fdc51 100644 --- a/frontend/src/app/features/hal/http/openproject-header-interceptor.ts +++ b/frontend/src/app/features/hal/http/openproject-header-interceptor.ts @@ -37,7 +37,7 @@ export const EXTERNAL_REQUEST_HEADER = 'X-External-Request'; @Injectable() export class OpenProjectHeaderInterceptor implements HttpInterceptor { - intercept(req:HttpRequest, next:HttpHandler):Observable> { + intercept(req:HttpRequest, next:HttpHandler):Observable> { const withCredentials = req.headers.get(EXTERNAL_REQUEST_HEADER) !== 'true'; if (withCredentials) { @@ -47,7 +47,7 @@ export class OpenProjectHeaderInterceptor implements HttpInterceptor { } } - private handleExternalRequest(req:HttpRequest, next:HttpHandler):Observable> { + private handleExternalRequest(req:HttpRequest, next:HttpHandler):Observable> { // Clone the request to add the new header const clonedRequest = req.clone({ withCredentials: false, @@ -57,7 +57,7 @@ export class OpenProjectHeaderInterceptor implements HttpInterceptor { return next.handle(clonedRequest); } - private handleAuthenticatedRequest(req:HttpRequest, next:HttpHandler):Observable> { + private handleAuthenticatedRequest(req:HttpRequest, next:HttpHandler):Observable> { const csrfToken = getMetaContent('csrf-token'); let newHeaders = req.headers.set('X-Requested-With', 'XMLHttpRequest'); diff --git a/frontend/src/app/features/hal/resources/error-resource.ts b/frontend/src/app/features/hal/resources/error-resource.ts index a07bf150118f..75d7a7593d2d 100644 --- a/frontend/src/app/features/hal/resources/error-resource.ts +++ b/frontend/src/app/features/hal/resources/error-resource.ts @@ -60,11 +60,11 @@ export interface IHalMultipleError extends IHalErrorBase { } export class ErrorResource extends HalResource { - public errors:any[]; + public errors:ErrorResource[]; public message:string; - public details:any; + public details:{ attribute:string }; public errorIdentifier:string; @@ -97,21 +97,21 @@ export class ErrorResource extends HalResource { let columns:ErrorResource[] = []; if (this.details) { - columns = [{ details: this.details as { attribute:string } } as ErrorResource]; + columns = [{ details: this.details } as ErrorResource]; } else if (this.errors) { - columns = this.errors as ErrorResource[]; + columns = this.errors; } return columns.map((resource:ErrorResource):string => { if (resource.errorIdentifier === v3ErrorIdentifierMultipleErrors) { return this.extractMultiError(resource)[0]; } - return (resource.details as { attribute:string }).attribute; + return resource.details.attribute; }).flat(); } public getMessagesPerAttribute():Record { - const perAttribute:any = {}; + const perAttribute:Record = {}; if (this.details) { perAttribute[this.details.attribute] = [this.message]; @@ -119,7 +119,7 @@ export class ErrorResource extends HalResource { this.errors?.forEach((error:ErrorResource) => { if (error.errorIdentifier === v3ErrorIdentifierMultipleErrors) { const [attribute, messages] = this.extractMultiError(error); - const current = perAttribute[attribute] || []; + const current = perAttribute[attribute] ?? []; perAttribute[attribute] = current.concat(messages); } else if (perAttribute[error.details.attribute]) { perAttribute[error.details.attribute].push(error.message); diff --git a/frontend/src/app/features/hal/services/hal-resource-notification.service.ts b/frontend/src/app/features/hal/services/hal-resource-notification.service.ts index 2c82451913ba..130c22600725 100644 --- a/frontend/src/app/features/hal/services/hal-resource-notification.service.ts +++ b/frontend/src/app/features/hal/services/hal-resource-notification.service.ts @@ -29,7 +29,7 @@ import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; import { Injectable, Injector, inject } from '@angular/core'; import { LoadingIndicatorService } from 'core-app/core/loading-indicator/loading-indicator.service'; -import { ToastService } from 'core-app/shared/components/toaster/toast.service'; +import { IToast, ToastService } from 'core-app/shared/components/toaster/toast.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { HttpErrorResponse } from '@angular/common/http'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; @@ -52,9 +52,9 @@ export class HalResourceNotificationService { protected readonly schemaCache = inject(SchemaCacheService); public showSave(resource:HalResource, isCreate = false) { - const message:any = { + const message = { message: this.I18n.t(`js.notice_successful_${isCreate ? 'create' : 'update'}`), - }; + } as IToast; this.ToastService.addSuccess(message); } @@ -115,10 +115,10 @@ export class HalResourceNotificationService { return this.I18n.t('js.error.internal'); } - public retrieveError(response:unknown):ErrorResource|unknown { + public retrieveError(response:unknown):unknown { // we try to detect what we got, this may either be an HttpErrorResponse, // some older XHR response object or a string - let errorBody:any = response; + let errorBody:unknown = response; // Angular http response have an error body attribute if (response instanceof HttpErrorResponse) { @@ -127,19 +127,19 @@ export class HalResourceNotificationService { // Some older response may have a data attribute if ((response as { data?:{ _type?:string } } | null)?.data?._type === 'Error') { - errorBody = (response as any).data; + errorBody = (response as { data:unknown }).data; } - if (errorBody?._type === 'Error') { + if ((errorBody as { _type?:string }|null|undefined)?._type === 'Error') { return this.halResourceService.createHalResourceOfClass(ErrorResource, errorBody); } return errorBody; } - protected handleErrorResponse(errorResource:any, resource?:HalResource) { + protected handleErrorResponse(errorResource:unknown, resource?:HalResource) { if (errorResource instanceof HalError && resource) { - return this.showError(errorResource.resource, resource); + return this.showError(errorResource.resource!, resource); } if (!(errorResource instanceof ErrorResource)) { @@ -153,7 +153,7 @@ export class HalResourceNotificationService { return this.showApiErrorMessages(errorResource); } - public showError(errorResource:any, resource:HalResource) { + public showError(errorResource:ErrorResource, resource:HalResource) { this.showCustomError(errorResource, resource) || this.showApiErrorMessages(errorResource); } @@ -161,7 +161,7 @@ export class HalResourceNotificationService { let error = this.I18n.t('js.error.internal'); if (typeof (message) === 'string' || (message != null && Object.hasOwn(message, 'toString'))) { - error += ` ${(message as any).toString()}`; + error += ` ${(message as { toString():string }).toString()}`; } this.ToastService.addError(error); @@ -174,7 +174,7 @@ export class HalResourceNotificationService { )); } - protected showCustomError(errorResource:any, resource:HalResource) { + protected showCustomError(errorResource:ErrorResource, resource:HalResource) { if (errorResource.errorIdentifier === 'urn:openproject-org:api:v3:errors:PropertyFormatError') { const schema = this.schemaCache.of(resource).ofProperty(errorResource.details.attribute); const attributeName = schema.name; @@ -193,7 +193,7 @@ export class HalResourceNotificationService { return false; } - protected showApiErrorMessages(errorResource:any) { + protected showApiErrorMessages(errorResource:ErrorResource) { const messages = errorResource.errorMessages; if (messages.length > 1) { diff --git a/frontend/src/app/features/hal/services/hal-resource.service.ts b/frontend/src/app/features/hal/services/hal-resource.service.ts index 2898d397224e..c427d27b4ee4 100644 --- a/frontend/src/app/features/hal/services/hal-resource.service.ts +++ b/frontend/src/app/features/hal/services/hal-resource.service.ts @@ -49,7 +49,7 @@ import { HalError } from 'core-app/features/hal/services/hal-error'; import { getPaginatedCollections } from 'core-app/core/apiv3/helpers/get-paginated-results'; export interface HalResourceFactoryConfigInterface { - cls?:any; + cls?:HalResourceClass; attrTypes?:Record; } @@ -57,6 +57,10 @@ interface ErrorWithType { _type?:string; } +interface TypedHalSource { + _type:string; +} + @Injectable({ providedIn: 'root' }) export class HalResourceService { readonly injector = inject(Injector); @@ -147,7 +151,7 @@ export class HalResourceService { * @param headers * @returns {Promise} */ - public put(href:string, data?:any, headers?:HTTPClientHeaders):Observable { + public put(href:string, data?:unknown, headers?:HTTPClientHeaders):Observable { return this.request('put', href, data, headers); } @@ -159,7 +163,7 @@ export class HalResourceService { * @param headers * @returns {Promise} */ - public post(href:string, data?:any, headers?:HTTPClientHeaders):Observable { + public post(href:string, data?:unknown, headers?:HTTPClientHeaders):Observable { return this.request('post', href, data, headers); } @@ -171,7 +175,7 @@ export class HalResourceService { * @param headers * @returns {Promise} */ - public patch(href:string, data?:any, headers?:HTTPClientHeaders):Observable { + public patch(href:string, data?:unknown, headers?:HTTPClientHeaders):Observable { return this.request('patch', href, data, headers); } @@ -183,7 +187,7 @@ export class HalResourceService { * @param headers * @returns {Promise} */ - public delete(href:string, data?:any, headers?:HTTPClientHeaders):Observable { + public delete(href:string, data?:unknown, headers?:HTTPClientHeaders):Observable { return this.request('delete', href, data, headers); } @@ -215,14 +219,14 @@ export class HalResourceService { * @param source * @returns {HalResource} */ - public createHalResource(source:any, loaded = true):T { - source ??= HalResource.getEmptyResource(); + public createHalResource(source:unknown, loaded = true):T { + const halSource = (source ?? HalResource.getEmptyResource()) as TypedHalSource; - const type = source._type || 'HalResource'; - return this.createHalResourceOfType(type, source, loaded); + const type = halSource._type || 'HalResource'; + return this.createHalResourceOfType(type, halSource, loaded); } - public createHalResourceOfType(type:string, source:any, loaded = false) { + public createHalResourceOfType(type:string, source:unknown, loaded = false) { const resourceClass:HalResourceClass = this.getResourceClassOfType(type); const initializer = (halResource:HalResource) => initializeHalProperties(this, halResource); const resource = new resourceClass(this.injector, source, loaded, initializer, type); @@ -236,9 +240,9 @@ export class HalResourceService { * @param source * @param loaded */ - public createHalResourceOfClass(resourceClass:HalResourceClass, source:any, loaded = false) { + public createHalResourceOfClass(resourceClass:HalResourceClass, source:unknown, loaded = false) { const initializer = (halResource:HalResource) => initializeHalProperties(this, halResource); - const type = source._type || 'HalResource'; + const type = (source as TypedHalSource)._type || 'HalResource'; const resource = new resourceClass(this.injector, source, loaded, initializer, type); return resource; @@ -285,8 +289,11 @@ export class HalResourceService { * @returns {HalResource} */ protected getResourceClassOfType(type:string):HalResourceClass { - const config = this.config[type]; - return (config?.cls) ? config.cls : this.defaultClass as HalResourceClass; + const cls = this.config[type]?.cls; + if (cls) { + return cls as HalResourceClass; + } + return this.defaultClass as HalResourceClass; } /** @@ -313,7 +320,7 @@ export class HalResourceService { private createErrorObservable(error:HttpErrorResponse):Observable { let resource:ErrorResource|null = null; - const body = error.error as string|ErrorWithType|unknown; + const body:unknown = error.error; if (typeof body === 'object' && (body as ErrorWithType)?._type) { resource = this.createHalResource(error.error); } diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts index b475490883b3..615c30852e31 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts @@ -866,7 +866,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, await this.halEditing.save(changeset); } catch (e:unknown) { this.loading$?.complete(); - this.halNotification.showError((e as HalError).resource, changeset.projectedResource); + this.halNotification.showError((e as HalError).resource!, changeset.projectedResource); this.calendarDrag.handleDropError(changeset.projectedResource); info?.revert(); } diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts index c195ce09c2b8..fb4f4d2097b6 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts @@ -99,7 +99,7 @@ export class WorkPackageStatusDropdownDirective extends OpContextMenuTrigger { this.workPackageNotificationService.showSave(this.workPackage); }) .catch((e:unknown) => { - this.workPackageNotificationService.showError((e as HalError).resource, change.projectedResource); + this.workPackageNotificationService.showError((e as HalError).resource!, change.projectedResource); }); } } From d84ae681c62bde2554d51054a0ed14f2dad97148 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 23:04:10 +0100 Subject: [PATCH 7/9] [OP-20344] Type remaining HAL resource members Types the remaining `any` members of the query filter, filter schema, schema dependency and grid resources, lets attachable resources read the now typed addAttachment link directly, and casts the two schema and parent reads that go through the resource index signature. https://community.openproject.org/wp/OP-20344 --- frontend/src/app/features/hal/resources/grid-resource.ts | 5 ++++- .../features/hal/resources/mixins/attachable-mixin.ts | 2 +- .../hal/resources/query-filter-instance-resource.ts | 8 ++++---- .../resources/query-filter-instance-schema-resource.ts | 9 +++++---- .../app/features/hal/resources/query-filter-resource.ts | 2 +- .../features/hal/resources/schema-dependency-resource.ts | 4 ++-- .../src/app/features/hal/resources/schema-resource.ts | 6 +++--- .../app/features/hal/resources/work-package-resource.ts | 2 +- 8 files changed, 21 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/features/hal/resources/grid-resource.ts b/frontend/src/app/features/hal/resources/grid-resource.ts index 7b264509e840..37e585de07b9 100644 --- a/frontend/src/app/features/hal/resources/grid-resource.ts +++ b/frontend/src/app/features/hal/resources/grid-resource.ts @@ -29,6 +29,7 @@ import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; import { HalResource, HalResourceLinks } from 'core-app/features/hal/resources/hal-resource'; import { GridWidgetResource } from 'core-app/features/hal/resources/grid-widget-resource'; +import { AttachmentCollectionResource } from 'core-app/features/hal/resources/attachment-collection-resource'; import { Attachable } from 'core-app/features/hal/resources/mixins/attachable-mixin'; import idFromLink from '../helpers/id-from-link'; @@ -48,6 +49,8 @@ export class GridBaseResource extends HalResource { public columnCount:number; + public attachments:AttachmentCollectionResource; + public $links:GridResourceLinks & HalResourceLinks; public get projectId():string|undefined { @@ -58,7 +61,7 @@ export class GridBaseResource extends HalResource { return undefined; } - public $initialize(source:any) { + public $initialize(source:unknown) { super.$initialize(source); this.widgets = this diff --git a/frontend/src/app/features/hal/resources/mixins/attachable-mixin.ts b/frontend/src/app/features/hal/resources/mixins/attachable-mixin.ts index c4bc457f6d27..5a1c4be68022 100644 --- a/frontend/src/app/features/hal/resources/mixins/attachable-mixin.ts +++ b/frontend/src/app/features/hal/resources/mixins/attachable-mixin.ts @@ -45,7 +45,7 @@ export function Attachable>(Base:TBase) { * adding attachments is allowed. */ public get canAddAttachments():boolean { - return !!((this as HalResource).$links as unknown&{ addAttachment?:HalLink }).addAttachment || isNewResource(this); + return !!(this as HalResource).$links.addAttachment || isNewResource(this); } /** diff --git a/frontend/src/app/features/hal/resources/query-filter-instance-resource.ts b/frontend/src/app/features/hal/resources/query-filter-instance-resource.ts index 746ebd34414d..3b997ceda8de 100644 --- a/frontend/src/app/features/hal/resources/query-filter-instance-resource.ts +++ b/frontend/src/app/features/hal/resources/query-filter-instance-resource.ts @@ -49,7 +49,7 @@ export class QueryFilterInstanceResource extends HalResource { @LazyInject(PathHelperService) pathHelper:PathHelperService; - public $initialize(source:any) { + public $initialize(source:unknown) { super.$initialize(source); this.$links.schema = { @@ -81,9 +81,9 @@ export class QueryFilterInstanceResource extends HalResource { if (this.memoizedCurrentSchemas[key] === undefined) { try { - this.memoizedCurrentSchemas[key] = this.schemaCache.of(this).resultingSchema(this.operator); + this.memoizedCurrentSchemas[key] = (this.schemaCache.of(this) as unknown as QueryFilterInstanceSchemaResource).resultingSchema(this.operator); } catch (e) { - console.error(`Failed to access filter schema${e}`); + console.error(`Failed to access filter schema${String(e)}`); } } @@ -100,7 +100,7 @@ export class QueryFilterInstanceResource extends HalResource { public isTemplated() { let flag = false; - (this.values as any[]).find((value:any) => { + (this.values as { href:string, toString():string }[]).find((value) => { const href:string = value?.href || value.toString() || ''; flag = href.includes('{id}'); }); diff --git a/frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.ts b/frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.ts index 59fe0a512e86..5c39aedafe35 100644 --- a/frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.ts +++ b/frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.ts @@ -67,18 +67,19 @@ export class QueryFilterInstanceSchemaResource extends SchemaResource { return this.filter.allowedValues[0]; } - public $initialize(source:any) { + public $initialize(source:unknown) { super.$initialize(source); - if (source._dependencies) { - this.dependency = new SchemaDependencyResource(this.injector, source._dependencies[0], true, this.halInitializer, 'SchemaDependency'); + const { _dependencies } = source as { _dependencies?:unknown[] }; + if (_dependencies) { + this.dependency = new SchemaDependencyResource(this.injector, _dependencies[0], true, this.halInitializer, 'SchemaDependency'); } } public getFilter():QueryFilterInstanceResource { const operator = (this.operator.allowedValues as HalResource[])[0]; const filter = (this.filter.allowedValues as HalResource[])[0]; - const source:any = { + const source:{ name:string, values?:unknown[], _links:Record } = { name: filter.name, _links: { filter: filter.$source._links.self, diff --git a/frontend/src/app/features/hal/resources/query-filter-resource.ts b/frontend/src/app/features/hal/resources/query-filter-resource.ts index 1c2d934441fc..de9bec3b7643 100644 --- a/frontend/src/app/features/hal/resources/query-filter-resource.ts +++ b/frontend/src/app/features/hal/resources/query-filter-resource.ts @@ -30,7 +30,7 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; export class QueryFilterResource extends HalResource { - public values:any[]; + public values:unknown[]; public get id():string { return (this.$source.id as string) || idFromLink(this.href); diff --git a/frontend/src/app/features/hal/resources/schema-dependency-resource.ts b/frontend/src/app/features/hal/resources/schema-dependency-resource.ts index ac18c341d6a7..9a0baf087ee4 100644 --- a/frontend/src/app/features/hal/resources/schema-dependency-resource.ts +++ b/frontend/src/app/features/hal/resources/schema-dependency-resource.ts @@ -29,9 +29,9 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; export class SchemaDependencyResource extends HalResource { - public dependencies:any; + public dependencies:Record; - public forValue(value:string):any { + public forValue(value:string):object { return this.dependencies[value]; } } diff --git a/frontend/src/app/features/hal/resources/schema-resource.ts b/frontend/src/app/features/hal/resources/schema-resource.ts index 86411d45294a..466197f595ff 100644 --- a/frontend/src/app/features/hal/resources/schema-resource.ts +++ b/frontend/src/app/features/hal/resources/schema-resource.ts @@ -27,7 +27,7 @@ //++ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; -import { HalSource } from 'core-app/features/hal/interfaces'; +import { IOPFieldSchema } from 'core-app/features/hal/interfaces'; import { InputState } from '@openproject/reactivestates'; export class SchemaResource extends HalResource { @@ -36,7 +36,7 @@ export class SchemaResource extends HalResource { } public get availableAttributes():string[] { - return Object.keys(this.$source as HalSource).filter((name) => !name.startsWith('_')); + return Object.keys(this.$source).filter((name) => !name.startsWith('_')); } // Find the attribute name with a matching (localized) name; @@ -44,7 +44,7 @@ export class SchemaResource extends HalResource { let match:string|null = null; for (const attribute of this.availableAttributes) { - const fieldSchema = this[attribute]; + const fieldSchema = this[attribute] as IOPFieldSchema|undefined; if (fieldSchema?.name === name) { match = attribute; break; diff --git a/frontend/src/app/features/hal/resources/work-package-resource.ts b/frontend/src/app/features/hal/resources/work-package-resource.ts index 2b36a34051fb..3406d0269bf5 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -283,7 +283,7 @@ export class WorkPackageBaseResource extends HalResource { // If there is a parent, its view has to be updated as well if (newValue.parent) { - this.apiV3Service.work_packages.id(newValue.parent).refresh(); + void this.apiV3Service.work_packages.id(newValue.parent as WorkPackageResource).refresh(); } return this.apiV3Service.work_packages.cache.updateWorkPackage(newValue as unknown as WorkPackageResource); From 2a44d4b0f9ee0efe681fda321a954b9e629cd2fc Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 23:06:07 +0100 Subject: [PATCH 8/9] [OP-20344] Drop casts made redundant by HAL types Removes type assertions, eslint-disable directives and imports that the typed HAL links, sources, schemas and resource attributes made unnecessary, and marks the two now typed $load() calls whose promises were already discarded with `void`. https://community.openproject.org/wp/OP-20344 --- frontend/src/app/core/path-helper/apiv3-paths.ts | 3 +-- frontend/src/app/core/schemas/schema-cache.service.ts | 1 - .../src/app/features/bim/bcf/helper/viewpoints.service.ts | 4 ++-- .../bim/ifc_models/ifc-viewer/ifc-viewer.service.ts | 3 +-- .../team-planner/planner/team-planner.component.ts | 8 ++++---- .../wp-status-button/wp-status-button.component.ts | 2 +- .../wp-single-card/wp-single-card.component.ts | 3 +-- .../components/wp-details/wp-details-toolbar.component.ts | 1 - .../wp-edit-form/work-package-filter-values.spec.ts | 2 +- .../work-packages/components/wp-list/wp-list.service.ts | 1 - .../children/wp-children-inline-create.service.ts | 1 - .../relations/wp-relation-inline-create.service.ts | 1 - .../files-tab/op-files-tab.component.ts | 3 +-- .../components/wp-single-view/wp-single-view.component.ts | 3 +-- .../routing/wp-view-base/work-package-single-view.base.ts | 1 - .../components/fields/changeset/resource-changeset.ts | 6 +++--- .../display/field-types/excluded-icon-helper.service.ts | 2 +- .../shared/components/fields/edit/edit-form/edit-form.ts | 7 +++---- .../formattable-edit-field.component.ts | 1 - .../select-edit-field/select-edit-field.component.ts | 2 +- .../widgets/custom-text/custom-text-edit-field.service.ts | 2 +- .../wp-context-menu/wp-single-context-menu.ts | 2 +- 22 files changed, 23 insertions(+), 36 deletions(-) diff --git a/frontend/src/app/core/path-helper/apiv3-paths.ts b/frontend/src/app/core/path-helper/apiv3-paths.ts index ea34069217f7..75d1c35e3a40 100644 --- a/frontend/src/app/core/path-helper/apiv3-paths.ts +++ b/frontend/src/app/core/path-helper/apiv3-paths.ts @@ -28,7 +28,6 @@ import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; export class ApiV3Paths { readonly apiV3Base:string; @@ -96,7 +95,7 @@ export class ApiV3Paths { if (!workPackage.id || workPackage.id === 'new') { // that are members of that project: - filters.add('member', '=', [(workPackage.project as HalResource).id!]); + filters.add('member', '=', [workPackage.project.id!]); } else { // that are mentionable on the work package filters.add( diff --git a/frontend/src/app/core/schemas/schema-cache.service.ts b/frontend/src/app/core/schemas/schema-cache.service.ts index 990e68fa8f02..58177eb25773 100644 --- a/frontend/src/app/core/schemas/schema-cache.service.ts +++ b/frontend/src/app/core/schemas/schema-cache.service.ts @@ -82,7 +82,6 @@ export class SchemaCacheService extends StateCacheService { } public getSchemaHref(resource:HalResource):string|undefined { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return resource.$links.schema?.href as string|undefined; } diff --git a/frontend/src/app/features/bim/bcf/helper/viewpoints.service.ts b/frontend/src/app/features/bim/bcf/helper/viewpoints.service.ts index 04d046a4b086..1d57837e33f7 100644 --- a/frontend/src/app/features/bim/bcf/helper/viewpoints.service.ts +++ b/frontend/src/app/features/bim/bcf/helper/viewpoints.service.ts @@ -53,7 +53,7 @@ export class ViewpointsService { @LazyInject() apiV3Service:ApiV3Service; public getViewPointResource(workPackage:WorkPackageResource, index:number):BcfViewpointPaths { - const viewpointHref = (workPackage.bcfViewpoints as HalResource[])[index].href!; + const viewpointHref = workPackage.bcfViewpoints![index].href!; return this.bcfApi.parse(viewpointHref)!; } @@ -91,7 +91,7 @@ export class ViewpointsService { } public saveViewpoint$(workPackage:WorkPackageResource, viewpoint?:CreateBcfViewpointData):Observable { - const projectLink = (workPackage.project as HalResource).href; + const projectLink = workPackage.project.href; const wpProjectId = idFromLink(projectLink); const topicUUID$ = this.setBcfTopic$(workPackage); // Default to the current viewer's viewpoint diff --git a/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts b/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts index a8614c892f1c..73c979560f9c 100644 --- a/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts +++ b/frontend/src/app/features/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts @@ -41,7 +41,6 @@ import { IfcProjectDefinition, } from 'core-app/features/bim/ifc_models/pages/viewer/ifc-models-data.service'; import { BcfViewpointData, CreateBcfViewpointData } from 'core-app/features/bim/bcf/api/bcf-api.model'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; import { getMetaContent } from 'core-app/core/setup/globals/global-helpers'; @@ -256,7 +255,7 @@ export class IFCViewerService extends ViewerBridgeService { // no means of setting it from here. Hence we must make a hard transition to bcf details route of the // current work package. window.location.href = this.pathHelper.bimDetailsPath( - idFromLink((workPackage.project as HalResource).href), + idFromLink(workPackage.project.href), workPackage.id || '', index, ); diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts index 615c30852e31..5d1db60a4504 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.ts @@ -740,7 +740,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, } isStatusClosed(workPackage:WorkPackageResource):boolean { - const status = this.statuses.find((el) => el.id === (workPackage.status as StatusResource).id); + const status = this.statuses.find((el) => el.id === workPackage.status.id); return status ? status.isClosed : false; } @@ -899,7 +899,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, return constraints; } - const assignables = projectAssignables[(wp.project as HalResource).href!]; + const assignables = projectAssignables[wp.project.href!]; if (assignables) { constraints.resourceIds = [...assignables]; } @@ -917,7 +917,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, } private wpAssignee(wp:WorkPackageResource):string { - return (wp.assignee as HalResource).href!; + return wp.assignee!.href!; } private toggleAddExistingPane():void { @@ -945,7 +945,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, .pipe( filter((el) => Object.keys(el).length > 0), take(1), - map((projectAssignables) => projectAssignables[(wp.project as HalResource).href!]), + map((projectAssignables) => projectAssignables[wp.project.href!]), withLatestFrom(this.principals$), ) .subscribe(([assignable, principals]) => { diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-status-button/wp-status-button.component.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-status-button/wp-status-button.component.ts index 0d81da63513f..9ec4718a04d8 100644 --- a/frontend/src/app/features/work-packages/components/wp-buttons/wp-status-button/wp-status-button.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-status-button/wp-status-button.component.ts @@ -73,7 +73,7 @@ export class WorkPackageStatusButtonComponent extends UntilDestroyedMixin implem this.workPackage = wp; if (this.workPackage.status) { - this.workPackage.status.$load(); + void this.workPackage.status.$load(); } this.cdRef.detectChanges(); diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts index 4fbcbdc7b6f4..d085ddb54e5c 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts @@ -62,7 +62,6 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-packag import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/link-handling'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { StatusResource } from 'core-app/features/hal/resources/status-resource'; import { fromEvent, merge } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; @@ -223,7 +222,7 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen } cardTitle():string { - return `${this.workPackage.subject} (${(this.workPackage.status as StatusResource).name})`; + return `${this.workPackage.subject} (${this.workPackage.status.name})`; } public baselineIcon(workPackage:WorkPackageResource) { diff --git a/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.component.ts b/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.component.ts index 8b15c06e38f2..ccf1e85275ed 100644 --- a/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.component.ts @@ -62,7 +62,6 @@ export class WorkPackageSplitViewToolbarComponent implements OnInit { }; ngOnInit() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access this.displayShareButton$ = this.currentUserService.hasCapabilities$('work_package_shares/index', this.workPackage.project.id); this.displayReminderButton$ = this.currentUserService.isLoggedInAndHasCapabalities$( 'work_packages/read', diff --git a/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts b/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts index f7c0a9f30d76..63a442c0686f 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts @@ -121,7 +121,7 @@ describe('WorkPackageFilterValues', () => { const version = halResourceService.createHalResourceOfClass( HalResource, { _type: 'Version', id: '42', _links: { self: { href: '/api/v3/versions/42', name: 'v1.0' } } }, - ) as HalResource; + ); filters.push({ id: 'version', diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts index 024f8808e6c5..633b584ec30a 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts @@ -365,7 +365,6 @@ export class WorkPackagesListService { .then((loaded) => this.conditionallyLoadForm(loaded)); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (!currentForm || query.$links.update?.href !== currentForm.href) { return this.loadForm(query); } diff --git a/frontend/src/app/features/work-packages/components/wp-relations/embedded/children/wp-children-inline-create.service.ts b/frontend/src/app/features/work-packages/components/wp-relations/embedded/children/wp-children-inline-create.service.ts index 3afaaa5a1d38..9c30171b2ed5 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/embedded/children/wp-children-inline-create.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/embedded/children/wp-children-inline-create.service.ts @@ -78,7 +78,6 @@ export class WpChildrenInlineCreateService extends WorkPackageInlineCreateServic return of(false); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return this.canCreateWorkPackages(idFromLink(this.referenceTarget.project.href)); } diff --git a/frontend/src/app/features/work-packages/components/wp-relations/embedded/relations/wp-relation-inline-create.service.ts b/frontend/src/app/features/work-packages/components/wp-relations/embedded/relations/wp-relation-inline-create.service.ts index 9501eb7d732b..ac3cc28a25b6 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/embedded/relations/wp-relation-inline-create.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/embedded/relations/wp-relation-inline-create.service.ts @@ -91,7 +91,6 @@ export class WpRelationInlineCreateService extends WorkPackageInlineCreateServic return of(false); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return this.canCreateWorkPackages(idFromLink(this.referenceTarget.project.href)); } diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.component.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.component.ts index 64c0ebab310c..cb25661bbf3a 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/files-tab/op-files-tab.component.ts @@ -33,7 +33,6 @@ import { map } from 'rxjs/operators'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { CurrentUserService } from 'core-app/core/current-user/current-user.service'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { ProjectStoragesResourceService } from 'core-app/core/state/project-storages/project-storages.service'; import { IProjectStorage } from 'core-app/core/state/project-storages/project-storage.model'; import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; @@ -69,7 +68,7 @@ export class WorkPackageFilesTabComponent implements OnInit { showAttachments:boolean; ngOnInit():void { - const project = this.workPackage.project as HalResource; + const project = this.workPackage.project; if (project.id === null) { return; } diff --git a/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts b/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts index 24fdc530d35f..9e453b3c9eb3 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts @@ -51,7 +51,6 @@ import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { ProjectsResourceService } from 'core-app/core/state/projects/projects.service'; import { CurrentUserService } from 'core-app/core/current-user/current-user.service'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { ProjectStoragesResourceService } from 'core-app/core/state/project-storages/project-storages.service'; import { IProjectStorage } from 'core-app/core/state/project-storages/project-storage.model'; import idFromLink from 'core-app/features/hal/helpers/id-from-link'; @@ -208,7 +207,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen const resource = change.projectedResource; if (!this.currentProject.inProjectContext) { this.projectContext.field = this.getFields(change, ['project']); - this.workPackage.project = resource.project as HalResource; + this.workPackage.project = resource.project; } if (resource.project === null) { diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts index fd939fd5ee35..40eceecfb4ad 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts @@ -55,7 +55,6 @@ import { AttachmentsResourceService } from 'core-app/core/state/attachments/atta import { StoragesResourceService } from 'core-app/core/state/storages/storages.service'; import { FileLinksResourceService } from 'core-app/core/state/file-links/file-links.service'; import { ProjectsResourceService } from 'core-app/core/state/projects/projects.service'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { HttpErrorResponse } from '@angular/common/http'; diff --git a/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts b/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts index ccc50af8bb17..69b1e74ea33d 100644 --- a/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts +++ b/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts @@ -179,7 +179,7 @@ export class ResourceChangeset { this.setNewDefaults(form); this.push(); return form; - }) as Promise; + }); this.form$.putFromPromiseIfPristine(() => promise); return promise; @@ -420,7 +420,7 @@ export class ResourceChangeset { if (this.form$.value) { payload = cloneDeep((this.form$.value.payload as { $source:unknown }).$source) as typeof payload; } else { - payload = cloneDeep(this.pristineResource.$source) as typeof payload; + payload = cloneDeep(this.pristineResource.$source); } // Add attachments to be assigned. @@ -488,7 +488,7 @@ export class ResourceChangeset { * that we need to set. */ protected setNewDefaults(form:FormResource) { - Object.entries(form.payload as Record).forEach(([key, val]) => { + Object.entries(form.payload).forEach(([key, val]) => { const fieldSchema:IFieldSchema|null = this.schema.ofProperty(key); if (!fieldSchema?.writable && !fieldSchema?.required) { return; diff --git a/frontend/src/app/shared/components/fields/display/field-types/excluded-icon-helper.service.ts b/frontend/src/app/shared/components/fields/display/field-types/excluded-icon-helper.service.ts index a3c6da6c8532..a831d1a6059d 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/excluded-icon-helper.service.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/excluded-icon-helper.service.ts @@ -38,7 +38,7 @@ export class ExcludedIconHelperService { public addIconIfExcludedFromTotals(element:HTMLElement, resource:WorkPackageResource):void { if (resource?.status) { - this.apiV3Service.statuses.id(resource.status as StatusResource).get().subscribe( + this.apiV3Service.statuses.id(resource.status).get().subscribe( (status:StatusResource) => { if (status.excludedFromTotals) { this.addExcludedInfoIcon(element, status.name); diff --git a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.ts b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.ts index 28b323d2f5f0..4cce85f3ae02 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.ts @@ -44,7 +44,6 @@ import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { HalError } from 'core-app/features/hal/services/hal-error'; import { FormResource } from 'core-app/features/hal/resources/form-resource'; import { HalResourceEditFieldHandler } from 'core-app/shared/components/fields/edit/field-handler/hal-resource-edit-field-handler'; -import { ISchemaProxy } from 'core-app/features/hal/schemas/schema-proxy'; export const activeFieldContainerClassName = 'inline-edit--active-field'; export const activeFieldClassName = 'inline-edit--field'; @@ -323,7 +322,7 @@ export abstract class EditForm { // field. Returning it synchronously lets the field activate without waiting on the // form request — required by Capybara specs whose activate! check has a tight // timeout, and by tests that intentionally disable AJAX before activating a field. - const cachedSchema = (this.change.schema as ISchemaProxy).ofProperty(fieldName); + const cachedSchema = this.change.schema.ofProperty(fieldName); if (cachedSchema) { // Still kick off the form load (or piggy-back on an in-flight one) so the form's // defaults, allowed values, and projected payload are populated for subsequent @@ -344,13 +343,13 @@ export abstract class EditForm { // then retry; if still missing, force a full reload once. return this.change.getForm() .then(():Promise => { - const fieldSchema:IFieldSchema|null = (this.change.schema as ISchemaProxy).ofProperty(fieldName); + const fieldSchema:IFieldSchema|null = this.change.schema.ofProperty(fieldName); if (fieldSchema) { return Promise.resolve(fieldSchema); } return this.change.getForm(true).then( - ():IFieldSchema|null => (this.change.schema as ISchemaProxy).ofProperty(fieldName), + ():IFieldSchema|null => this.change.schema.ofProperty(fieldName), ); }) .catch((error:unknown) => { diff --git a/frontend/src/app/shared/components/fields/edit/field-types/formattable-edit-field/formattable-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/formattable-edit-field/formattable-edit-field.component.ts index 42e980539bde..146c8cd613f6 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/formattable-edit-field/formattable-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/formattable-edit-field/formattable-edit-field.component.ts @@ -64,7 +64,6 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements field: this.field.name, macros: 'none' as const, previewContext: this.previewContext, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access options: { rtl: this.schema.options?.rtl }, type: 'constrained', // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call diff --git a/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts index 9e2e872f9875..caadf3f5eac5 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/select-edit-field/select-edit-field.component.ts @@ -199,7 +199,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn return new Promise(() => {}); } - return link.$fetch(this.allowedValuesFilter(query)) as Promise; + return link.$fetch(this.allowedValuesFilter(query)); } private addValue(val:HalResource) { diff --git a/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text-edit-field.service.ts b/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text-edit-field.service.ts index 1d1d7b67846e..87f2222b2399 100644 --- a/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text-edit-field.service.ts +++ b/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text-edit-field.service.ts @@ -175,7 +175,7 @@ export class CustomTextEditFieldService extends EditFieldHandler { canAddAttachments: value.grid.canAddAttachments as boolean, _links: { addAttachment: grid.addAttachment as { href?:string }, - attachments: grid.attachments as { href?:string }, + attachments: grid.attachments, schema: { href: schemaHref, }, diff --git a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts index 4217ab9ae5b9..2da4106139d1 100644 --- a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts +++ b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.ts @@ -94,7 +94,7 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger } protected open(evt:Event) { - this.workPackage.project.$load().then(() => { + void this.workPackage.project.$load().then(() => { this.authorisationService.initModelAuth('work_package', this.workPackage.$links); const authorization = new WorkPackageAuthorization(this.workPackage, this.PathHelper); From ad050993f45954171c6832256353ae04ba272c6a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 10:28:59 +0100 Subject: [PATCH 9/9] [OP-20344] Type absent and array-valued HAL links Declares the base link map as holding a callable link, an array of links, or nothing, since the builder resolves array-valued HAL links to arrays and resources only carry the links the API sent. The self link is always present, as the builder creates one when missing. Call sites that read a link they have already checked, or that the resource always carries, cast it at the point of use. https://community.openproject.org/wp/OP-20344 --- .../src/app/core/apiv3/cache/cachable-apiv3-resource.ts | 3 ++- frontend/src/app/core/schemas/schema-cache.service.ts | 3 ++- .../src/app/core/state/attachments/attachments.service.ts | 3 ++- .../src/app/features/hal/helpers/hal-resource-builder.ts | 2 +- .../src/app/features/hal/resources/hal-resource.spec.ts | 2 +- frontend/src/app/features/hal/resources/hal-resource.ts | 7 +++++-- .../work-packages/components/wp-list/wp-list.service.ts | 3 ++- .../wp-tabs/services/wp-tabs/wp-files-count.function.ts | 5 +++-- .../routing/wp-view-base/work-package-single-view.base.ts | 5 +++-- .../components/fields/changeset/resource-changeset.ts | 8 ++++---- .../fields/display/field-types/render-hierarchy-item.ts | 3 ++- .../fields/edit/services/hal-resource-editing.service.ts | 3 ++- .../components/storages/storage/storage.component.ts | 5 +++-- 13 files changed, 32 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/core/apiv3/cache/cachable-apiv3-resource.ts b/frontend/src/app/core/apiv3/cache/cachable-apiv3-resource.ts index df460f048c93..60ec84a759b2 100644 --- a/frontend/src/app/core/apiv3/cache/cachable-apiv3-resource.ts +++ b/frontend/src/app/core/apiv3/cache/cachable-apiv3-resource.ts @@ -36,6 +36,7 @@ import { } from 'rxjs/operators'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; export abstract class ApiV3Resource extends ApiV3GettableResource { @@ -137,7 +138,7 @@ export abstract class ApiV3Resource switchMap((resource) => { if (resource.$links.schema) { return this.schemaCache - .requireAndStream(resource.$links.schema.href!) + .requireAndStream((resource.$links.schema as CallableHalLink).href!) .pipe( take(1), mapTo(resource), diff --git a/frontend/src/app/core/schemas/schema-cache.service.ts b/frontend/src/app/core/schemas/schema-cache.service.ts index 58177eb25773..973413d76e70 100644 --- a/frontend/src/app/core/schemas/schema-cache.service.ts +++ b/frontend/src/app/core/schemas/schema-cache.service.ts @@ -37,6 +37,7 @@ import { HalResourceService } from 'core-app/features/hal/services/hal-resource. import { WorkPackageSchemaProxy } from 'core-app/features/hal/schemas/work-package-schema-proxy'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; export const fallbackSchemaId = '__fallback'; @@ -82,7 +83,7 @@ export class SchemaCacheService extends StateCacheService { } public getSchemaHref(resource:HalResource):string|undefined { - return resource.$links.schema?.href as string|undefined; + return (resource.$links.schema as CallableHalLink|undefined)?.href as string|undefined; } /** diff --git a/frontend/src/app/core/state/attachments/attachments.service.ts b/frontend/src/app/core/state/attachments/attachments.service.ts index efa26d2605bc..05283868fe5e 100644 --- a/frontend/src/app/core/state/attachments/attachments.service.ts +++ b/frontend/src/app/core/state/attachments/attachments.service.ts @@ -57,6 +57,7 @@ import { import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; import isNewResource, { HAL_NEW_RESOURCE_ID } from 'core-app/features/hal/helpers/is-new-resource'; import waitForUploadsFinished from 'core-app/core/upload/wait-for-uploads-finished'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; @Injectable() export class AttachmentsResourceService extends ResourceStoreService { @@ -204,7 +205,7 @@ export class AttachmentsResourceService extends ResourceStoreService(halResourceServic halResource.$linkableKeys().forEach((linkName:string) => { OpenprojectHalModuleHelpers.lazy(halResource, linkName, () => { - const entry = halResource.$links[linkName] as CallableHalLink|CallableHalLink[]; + const entry = halResource.$links[linkName]!; const link = (entry as CallableHalLink).$link || entry; if (Array.isArray(link)) { diff --git a/frontend/src/app/features/hal/resources/hal-resource.spec.ts b/frontend/src/app/features/hal/resources/hal-resource.spec.ts index 8d6be30a1d30..baa907079f70 100644 --- a/frontend/src/app/features/hal/resources/hal-resource.spec.ts +++ b/frontend/src/app/features/hal/resources/hal-resource.spec.ts @@ -393,7 +393,7 @@ describe('HalResource', () => { return null as any; }); - expect(() => resource.$links.beaver()).not.toThrow(Error); + expect(() => (resource.$links.beaver as CallableHalLink)()).not.toThrow(Error); }); it('should have a $links property with the keys of its source _links', () => { diff --git a/frontend/src/app/features/hal/resources/hal-resource.ts b/frontend/src/app/features/hal/resources/hal-resource.ts index efba30d3fd47..c678d6d503f9 100644 --- a/frontend/src/app/features/hal/resources/hal-resource.ts +++ b/frontend/src/app/features/hal/resources/hal-resource.ts @@ -47,7 +47,10 @@ export type HalResourceClass = new( _$halType:string, ) => T; -export type HalResourceLinks = Record; +export interface HalResourceLinks { + self:CallableHalLink; + [name:string]:CallableHalLink|CallableHalLink[]|undefined; +} export type HalResourceEmbedded = Record; @@ -107,7 +110,7 @@ export class HalResource { public $source:HalSource; - public $links:HalResourceLinks = {}; + public $links = {} as HalResourceLinks; public $embedded:HalResourceEmbedded = {}; diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts index 633b584ec30a..de5a53c4c696 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-list.service.ts @@ -55,6 +55,7 @@ import { WorkPackageStatesInitializationService } from './wp-states-initializati import { WorkPackagesListInvalidQueryService } from './wp-list-invalid-query.service'; import { WorkPackagesQueryViewService } from 'core-app/features/work-packages/components/wp-list/wp-query-view.service'; import { SubmenuService } from 'core-app/core/main-menu/submenu.service'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; export interface QueryDefinition { queryParams:{ query_id?:string|null, query_props?:string|null }; @@ -365,7 +366,7 @@ export class WorkPackagesListService { .then((loaded) => this.conditionallyLoadForm(loaded)); } - if (!currentForm || query.$links.update?.href !== currentForm.href) { + if (!currentForm || (query.$links.update as CallableHalLink|undefined)?.href !== currentForm.href) { return this.loadForm(query); } diff --git a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.ts b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.ts index 997faec5b1c3..02518e6aa38e 100644 --- a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.ts +++ b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.ts @@ -35,6 +35,7 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-packag import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service'; import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; import { IFileLink } from 'core-app/core/state/file-links/file-link.model'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; export function workPackageFilesCount( workPackage:WorkPackageResource, @@ -43,7 +44,7 @@ export function workPackageFilesCount( const attachmentService = injector.get(AttachmentsResourceService); const http = injector.get(HttpClient); const attachmentsCollection = workPackage.$links.attachments - ? attachmentService.collection(workPackage.$links.attachments.href || '') + ? attachmentService.collection((workPackage.$links.attachments as CallableHalLink).href ?? '') : of([]); const totalFileLinks = workPackage.$links.fileLinks ? http.get>(href(workPackage)) @@ -60,5 +61,5 @@ function href(workPackage:WorkPackageResource):string { return ''; } - return `${workPackage.$links.fileLinks.href}?pageSize=0`; + return `${(workPackage.$links.fileLinks as CallableHalLink).href}?pageSize=0`; } diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts index 40eceecfb4ad..aadad6577512 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts @@ -57,6 +57,7 @@ import { FileLinksResourceService } from 'core-app/core/state/file-links/file-li import { ProjectsResourceService } from 'core-app/core/state/projects/projects.service'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { HttpErrorResponse } from '@angular/common/http'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; @Directive() export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { @@ -190,7 +191,7 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { // lazy load the work package's project, needed when initializing // the work package resource from split view. this.projectsResourceService - .requireEntity(this.workPackage.$links.project.href!) + .requireEntity((this.workPackage.$links.project as CallableHalLink).href!) .subscribe( () => {}, (error:HttpErrorResponse) => { @@ -209,7 +210,7 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { // Fetch attachments of current work package if (this.workPackage.$links.attachments) { - this.attachmentsResourceService.fetchCollection(this.workPackage.$links.attachments.href!).subscribe(); + this.attachmentsResourceService.fetchCollection((this.workPackage.$links.attachments as CallableHalLink).href!).subscribe(); } // Listen to tab changes to update the tab label diff --git a/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts b/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts index 69b1e74ea33d..5b29d0646c9f 100644 --- a/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts +++ b/frontend/src/app/shared/components/fields/changeset/resource-changeset.ts @@ -42,6 +42,7 @@ import { ISchemaProxy, SchemaProxy } from 'core-app/features/hal/schemas/schema- import { IHalOptionalTitledLink } from 'core-app/core/state/hal-resource'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { firstValueFrom } from 'rxjs'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; export const PROXY_IDENTIFIER = '__is_changeset_proxy'; @@ -166,13 +167,12 @@ export class ResourceChangeset { protected updateForm():Promise { const payload = this.buildPayloadFromChanges(); - if (!this.pristineResource.$links.update) { + const update = this.pristineResource.$links.update as CallableHalLink|undefined; + if (!update) { return Promise.reject(); } - const promise = this.pristineResource - .$links - .update(payload) + const promise = update(payload) .then((form:FormResource) => { this.cache = {}; this.form$.putValue(form); diff --git a/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts b/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts index ce54004b78fa..abb7b3974639 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts @@ -31,9 +31,10 @@ import { map } from 'rxjs/operators'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; export function renderHierarchyItem(item:HalResource, multiple = false):Observable { - return from(item.$links.branch() as Promise) + return from((item.$links.branch as CallableHalLink)()) .pipe( map((ancestors:CollectionResource) => spansFromAncestors(ancestors)), map((spans) => { diff --git a/frontend/src/app/shared/components/fields/edit/services/hal-resource-editing.service.ts b/frontend/src/app/shared/components/fields/edit/services/hal-resource-editing.service.ts index b2b76ded6b11..da9e543abd08 100644 --- a/frontend/src/app/shared/components/fields/edit/services/hal-resource-editing.service.ts +++ b/frontend/src/app/shared/components/fields/edit/services/hal-resource-editing.service.ts @@ -40,6 +40,7 @@ import { HookService } from 'core-app/features/plugins/hook-service'; import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; import { StateCacheService } from 'core-app/core/apiv3/cache/state-cache.service'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; class ChangesetStates extends StatesGroup { name = 'Changesets'; @@ -106,7 +107,7 @@ export class HalResourceEditingService extends StateCacheService>(change:T):Promise> { // Form the payload we're going to save const payload = await change.buildRequestPayload(); - const savedResource = await change.pristineResource.$links.updateImmediately(payload) as V; + const savedResource = await (change.pristineResource.$links.updateImmediately as CallableHalLink)(payload) as V; // Initialize any potentially new HAL values savedResource.retainFrom(change.pristineResource); diff --git a/frontend/src/app/shared/components/storages/storage/storage.component.ts b/frontend/src/app/shared/components/storages/storage/storage.component.ts index 95ed04e38adb..95a7da1f1ce8 100644 --- a/frontend/src/app/shared/components/storages/storage/storage.component.ts +++ b/frontend/src/app/shared/components/storages/storage/storage.component.ts @@ -92,6 +92,7 @@ import { import { IHalErrorBase, v3ErrorIdentifierMissingEnterpriseToken, } from 'core-app/features/hal/resources/error-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; @Component({ selector: 'op-storage', @@ -190,7 +191,7 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD return this.pathHelperService.fileLinksPath(); } - return this.resource.$links.addFileLink.href!; + return (this.resource.$links.addFileLink as CallableHalLink).href!; } private onGlobalDragLeave:(_event:DragEvent) => void = (_event) => { @@ -532,7 +533,7 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD private fileLinkSelfLink(storage:IStorage):string { const fileLinks = (this.resource as WorkPackageResource).$links.fileLinks; - return `${fileLinks?.href}?pageSize=-1&filters=[{"storage":{"operator":"=","values":["${storage.id}"]}}]`; + return `${(fileLinks as CallableHalLink|undefined)?.href}?pageSize=-1&filters=[{"storage":{"operator":"=","values":["${storage.id}"]}}]`; } public onDropFiles(event:DragEvent):void {