From 163d76f05c420322b779df3047a7de0e4931be4e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:06:46 +0100 Subject: [PATCH 01/27] [OP-20342] Exempt test code from any-typing rules Turns off no-explicit-any and the no-unsafe-* rules for specs and the test helpers that are kept out of the production bundle. Test doubles and expectation helpers routinely work with loosely typed values, and the typing effort under OP-20341 targets application code only. The two no-unsafe exemptions that lived in the vitest spec block move into the new block, which covers the helper files as well. Removes the inline disables in specs and test setup that the exemption makes redundant. https://community.openproject.org/wp/OP-20342 --- frontend/eslint.config.mjs | 15 +++++++++++---- .../current-project.service.spec.ts | 1 - .../components/wp-query/url-params-helper.spec.ts | 1 - .../keep-tab/keep-tab.service.spec.ts | 2 -- .../view-services/wp-view-order.service.spec.ts | 3 --- .../primer/dynamic-icon.directive.spec.ts | 2 -- .../primer/icon-button.component.spec.ts | 2 -- .../controllers/checkable.controller.spec.ts | 2 +- .../dynamic/reporting/page.controller.spec.ts | 2 -- .../expandable-text.controller.spec.ts | 2 -- frontend/src/test-setup.ts | 5 ----- frontend/src/turbo/turbo-navigation-patch.spec.ts | 1 - 12 files changed, 12 insertions(+), 26 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index ee266a20e0dc..8e9338969001 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -218,14 +218,21 @@ export default defineConfig([ rules: { ...vitest.configs.recommended.rules, - // vitest expect(...) is always any - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - // Allow more than one class definitions per file (test components) 'max-classes-per-file': 'off', }, }, + { + files: ['**/*.spec.ts', '**/testing/**/*.ts', 'src/stimulus/test-helpers.ts', 'src/test-*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + }, + }, { // esbuild follows imports past the tsconfig exclude, so the import site // is the boundary keeping test helpers out of the production bundle. diff --git a/frontend/src/app/core/current-project/current-project.service.spec.ts b/frontend/src/app/core/current-project/current-project.service.spec.ts index 6db59e0f76ba..5661fe258745 100644 --- a/frontend/src/app/core/current-project/current-project.service.spec.ts +++ b/frontend/src/app/core/current-project/current-project.service.spec.ts @@ -46,7 +46,6 @@ describe('currentProject service', () => { providers: [ CurrentProjectService, PathHelperService, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment { provide: ApiV3Service, useValue: apiV3Stub }, ], }); diff --git a/frontend/src/app/features/work-packages/components/wp-query/url-params-helper.spec.ts b/frontend/src/app/features/work-packages/components/wp-query/url-params-helper.spec.ts index 133c917211ff..0742e265499a 100644 --- a/frontend/src/app/features/work-packages/components/wp-query/url-params-helper.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-query/url-params-helper.spec.ts @@ -40,7 +40,6 @@ describe('UrlParamsHelper', () => { TestBed.configureTestingModule({ providers: [ UrlParamsHelperService, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment { provide: PaginationService, useValue: paginationStub }, ], }); diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.spec.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.spec.ts index a74513355449..bc8813b97e2c 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.spec.ts @@ -44,10 +44,8 @@ describe('keepTab service', () => { TestBed.configureTestingModule({ providers: [ KeepTabService, - /* eslint-disable @typescript-eslint/no-unsafe-assignment */ { provide: PathHelperService, useValue: pathHelper }, { provide: CurrentProjectService, useValue: currentProject }, - /* eslint-enable @typescript-eslint/no-unsafe-assignment */ ], }); diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-order.service.spec.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-order.service.spec.ts index 2c336a81a827..19fc98658d05 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-order.service.spec.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-order.service.spec.ts @@ -85,7 +85,6 @@ describe('WorkPackageViewOrderService', () => { id: '123', _links: { self: { href: 'test' } }, } as Record; - // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-argument querySpace.query.putValue(mockQuery as any); }); @@ -103,12 +102,10 @@ describe('WorkPackageViewOrderService', () => { const order = ['1', '2', '3']; const wpId = '2'; - // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(service as any, 'update'); service.remove(order, wpId); - // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((service as any).update).toHaveBeenCalledWith({ [wpId]: -1 }); }); }); diff --git a/frontend/src/app/shared/components/primer/dynamic-icon.directive.spec.ts b/frontend/src/app/shared/components/primer/dynamic-icon.directive.spec.ts index 6b601814637d..2999b557a663 100644 --- a/frontend/src/app/shared/components/primer/dynamic-icon.directive.spec.ts +++ b/frontend/src/app/shared/components/primer/dynamic-icon.directive.spec.ts @@ -26,8 +26,6 @@ // See COPYRIGHT and LICENSE files for more details. //++ -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ - import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DynamicIconDirective } from './dynamic-icon.directive'; diff --git a/frontend/src/app/shared/components/primer/icon-button.component.spec.ts b/frontend/src/app/shared/components/primer/icon-button.component.spec.ts index 0f2720b8fbe8..362fc510ab12 100644 --- a/frontend/src/app/shared/components/primer/icon-button.component.spec.ts +++ b/frontend/src/app/shared/components/primer/icon-button.component.spec.ts @@ -26,8 +26,6 @@ // See COPYRIGHT and LICENSE files for more details. //++ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PrimerIconButtonComponent } from './icon-button.component'; diff --git a/frontend/src/stimulus/controllers/checkable.controller.spec.ts b/frontend/src/stimulus/controllers/checkable.controller.spec.ts index 473e2081b203..891a796b3b42 100644 --- a/frontend/src/stimulus/controllers/checkable.controller.spec.ts +++ b/frontend/src/stimulus/controllers/checkable.controller.spec.ts @@ -26,7 +26,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ -/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-empty-function */ import { ActionEvent } from '@hotwired/stimulus'; import CheckableController from './checkable.controller'; diff --git a/frontend/src/stimulus/controllers/dynamic/reporting/page.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/reporting/page.controller.spec.ts index 1690872b2587..0bc4003eb67c 100644 --- a/frontend/src/stimulus/controllers/dynamic/reporting/page.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/reporting/page.controller.spec.ts @@ -26,8 +26,6 @@ // See COPYRIGHT and LICENSE files for more details. //++ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import PageController from './page.controller'; describe('Reporting PageController serialization', () => { diff --git a/frontend/src/stimulus/controllers/expandable-text.controller.spec.ts b/frontend/src/stimulus/controllers/expandable-text.controller.spec.ts index 69990742f869..35700ed355d2 100644 --- a/frontend/src/stimulus/controllers/expandable-text.controller.spec.ts +++ b/frontend/src/stimulus/controllers/expandable-text.controller.spec.ts @@ -26,8 +26,6 @@ // See COPYRIGHT and LICENSE files for more details. //++ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ - import ExpandableTextController from './expandable-text.controller'; import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index 2c83fff6175c..824a3b4d93ae 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -43,7 +43,6 @@ afterEach(() => { vi.useRealTimers(); }); -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (window as any).global = window; window.I18n = new I18n(); @@ -51,9 +50,7 @@ window.I18n = new I18n(); // jsdom does not implement CSS.escape; production helpers (e.g. getMetaElement) // call it unconditionally. if (typeof CSS === 'undefined' || typeof CSS.escape !== 'function') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).CSS = (globalThis as any).CSS || {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (globalThis as any).CSS.escape = (value:string) => String(value).replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`); } @@ -61,7 +58,6 @@ if (typeof CSS === 'undefined' || typeof CSS.escape !== 'function') { // constructor signature so static analysis resolving the global to this class // still accepts the callback every real call site passes. if (typeof (globalThis as any).ResizeObserver === 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).ResizeObserver = class { // eslint-disable-next-line @typescript-eslint/no-empty-function constructor(_callback:ResizeObserverCallback) {} @@ -73,7 +69,6 @@ if (typeof (globalThis as any).ResizeObserver === 'undefined') { // jsdom does not implement HTMLDialogElement.showModal/close. if (typeof HTMLDialogElement !== 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const proto = HTMLDialogElement.prototype as any; if (typeof proto.showModal !== 'function') { proto.showModal = function showModal() { this.open = true; }; diff --git a/frontend/src/turbo/turbo-navigation-patch.spec.ts b/frontend/src/turbo/turbo-navigation-patch.spec.ts index 432bf784fc7d..5f4a4593370d 100644 --- a/frontend/src/turbo/turbo-navigation-patch.spec.ts +++ b/frontend/src/turbo/turbo-navigation-patch.spec.ts @@ -26,7 +26,6 @@ // See COPYRIGHT and LICENSE files for more details. //++ -/* eslint-disable @typescript-eslint/no-explicit-any */ import * as Turbo from '@hotwired/turbo'; import { applyTurboNavigationPatch } from './turbo-navigation-patch'; From 8b783da3119575be48c98feca1164681f83585a9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:08:19 +0100 Subject: [PATCH 02/27] [OP-20342] Remove unused global type declarations Removes the api.v3 Result, Collection and Duration interfaces, the Factory global and the op.QueryParams namespace. Nothing references them, and most declared their members as any. https://community.openproject.org/wp/OP-20342 --- .../src/typings/open-project.typings.d.ts | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/frontend/src/typings/open-project.typings.d.ts b/frontend/src/typings/open-project.typings.d.ts index cbffc738fbc8..e5afbccd2d39 100644 --- a/frontend/src/typings/open-project.typings.d.ts +++ b/frontend/src/typings/open-project.typings.d.ts @@ -38,24 +38,6 @@ declare namespace api { * API v3 */ namespace v3 { - interface Result { - _links:any; - _embedded:any; - _type:string; - } - - interface Collection extends Result { - total:number; - pageSize:number; - count:number; - offset:number; - groups:any; - totalSums:any; - } - - interface Duration extends String { - } - interface Formattable { format?:string; raw:string; @@ -74,15 +56,3 @@ interface Function { _type:string; } -declare let Factory:any; - -declare namespace op { - interface QueryParams { - offset?:number; - pageSize?:number; - filters?:any[]; - groupBy?:string; - showSums?:boolean; - sortBy?:any[]; - } -} From 388e17aa5517227c46a3c14a120ea513ee1529e0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:08:20 +0100 Subject: [PATCH 03/27] [OP-20342] Type the tablesorter jQuery plugin Declares the tablesorter call and its language defaults, and the metadata plugin slot the reporting page clears, instead of any. The reporting controller no longer needs its inline disables for them. https://community.openproject.org/wp/OP-20342 --- .../controllers/dynamic/reporting/page.controller.ts | 2 -- frontend/src/typings/shims.d.ts | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/reporting/page.controller.ts b/frontend/src/stimulus/controllers/dynamic/reporting/page.controller.ts index 73dd9d7267ca..743df61d9a50 100644 --- a/frontend/src/stimulus/controllers/dynamic/reporting/page.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/reporting/page.controller.ts @@ -207,7 +207,6 @@ export default class PageController extends Controller { jQuery.metadata = undefined; // Override the default texts to enable translations - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access jQuery.tablesorter.language = { sortAsc: I18n.t('js.sort.sorted_asc'), sortDesc: I18n.t('js.sort.sorted_dsc'), @@ -218,7 +217,6 @@ export default class PageController extends Controller { nextNone: I18n.t('js.sort.activate_no'), }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call jQuery('#sortable-table') .not('.tablesorter') .tablesorter({ diff --git a/frontend/src/typings/shims.d.ts b/frontend/src/typings/shims.d.ts index ef00c32df1f8..f223098f11c5 100644 --- a/frontend/src/typings/shims.d.ts +++ b/frontend/src/typings/shims.d.ts @@ -69,12 +69,12 @@ declare global { } interface JQuery { - tablesorter:any; + tablesorter(options:object):JQuery; } interface JQueryStatic { - metadata:any; - tablesorter:any; + metadata:unknown; + tablesorter:{ language:Record }; } } From c924fbb89700672f26d92178cd053a0dcf08dd9e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:10:34 +0100 Subject: [PATCH 04/27] [OP-20342] Type hook callbacks by hook name Adds a HookSignatures map for the hooks the core registers and calls, so register and call check callback parameters and return typed results instead of any. Arbitrary hook names still fall back to an open signature, because plugins register and call hooks by name. The fallback excludes the known names, so a mismatched callback or argument for a known hook fails to compile; the spec pins that down. The bulk context menu actions are now declared as WorkPackageAction, which the typed hook result exposed as narrower than its use. https://community.openproject.org/wp/OP-20342 --- .../app/features/plugins/hook-service.spec.ts | 12 +++++ .../src/app/features/plugins/hook-service.ts | 46 ++++++++++++++++--- .../wp-context-menu-helper.service.ts | 2 +- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/features/plugins/hook-service.spec.ts b/frontend/src/app/features/plugins/hook-service.spec.ts index 59d90d27796c..1be9dda6c7ef 100644 --- a/frontend/src/app/features/plugins/hook-service.spec.ts +++ b/frontend/src/app/features/plugins/hook-service.spec.ts @@ -156,4 +156,16 @@ describe('HookService', () => { shouldBehaveLikeResultWithElements(validId, 2); }); }); + + describe('known hooks', () => { + it('rejects callbacks and arguments that break the hook signature', () => { + // @ts-expect-error gridWidgets callbacks return widget registrations + service.register('gridWidgets', () => 123); + + // @ts-expect-error prependedAttributeGroups is called with a work package + service.call('prependedAttributeGroups', 'not a work package'); + + expect(service.call('gridWidgets')).toEqual([123]); + }); + }); }); diff --git a/frontend/src/app/features/plugins/hook-service.ts b/frontend/src/app/features/plugins/hook-service.ts index 7c5971862439..5c28c263b7e6 100644 --- a/frontend/src/app/features/plugins/hook-service.ts +++ b/frontend/src/app/features/plugins/hook-service.ts @@ -26,15 +26,43 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { Injectable } from '@angular/core'; +import { Injectable, Type } from '@angular/core'; +import type { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import type { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import type { WorkPackageChangeset } from 'core-app/features/work-packages/components/wp-edit/work-package-changeset'; +import type { GroupDescriptor } from 'core-app/features/work-packages/components/wp-single-view/wp-single-view.component'; +import type { WorkPackageAction } from 'core-app/features/work-packages/components/wp-table/context-menu-helper/wp-context-menu-helper.service'; +import type { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; +import type { WidgetRegistration } from 'core-app/shared/components/grids/grid/grid.component'; + +type ResourceChangesetClass = new (...params:ConstructorParameters) => ResourceChangeset; + +export interface HookSignatures { + attributeGroupComponent:(group:GroupDescriptor, workPackage:WorkPackageResource) => Type|null; + gridWidgets:() => WidgetRegistration[]; + halResourceChangesetClass:(resource:HalResource) => ResourceChangesetClass|null; + prependedAttributeGroups:(workPackage:WorkPackageResource) => Type|undefined; + workPackageAttachmentListComponent:(workPackage:WorkPackageResource) => Type; + workPackageAttachmentUploadComponent:(workPackage:WorkPackageResource) => Type; + workPackageBulkContextMenu:() => WorkPackageAction; + workPackageNewInitialization:(change:WorkPackageChangeset) => void; + workPackageSingleContextMenu:() => WorkPackageAction; + workPackageTableContextMenu:() => WorkPackageAction; +} + +type HookCallback = (...params:never[]) => unknown; + +type CustomHookId = K extends keyof HookSignatures ? never : K; @Injectable({ providedIn: 'root', }) export class HookService { - private hooks:Record = {}; + private hooks:Record = {}; - public register(id:string, callback:Function) { + public register(id:K, callback:HookSignatures[K]):void; + public register(id:CustomHookId, callback:HookCallback):void; + public register(id:string, callback:HookCallback) { if (!callback) { return; } @@ -46,12 +74,18 @@ export class HookService { this.hooks[id].push(callback); } - public call(id:string, ...params:any[]):any[] { + public call( + id:K, + ...params:Parameters + ):NonNullable>[]; + + public call(id:CustomHookId, ...params:unknown[]):unknown[]; + public call(id:string, ...params:unknown[]):unknown[] { const results = []; if (this.hooks[id]) { - for (let x = 0; x < this.hooks[id].length; x++) { - const result = this.hooks[id][x](...params); + for (const hook of this.hooks[id] as ((...params:unknown[]) => unknown)[]) { + const result = hook(...params); if (result) { results.push(result); diff --git a/frontend/src/app/features/work-packages/components/wp-table/context-menu-helper/wp-context-menu-helper.service.ts b/frontend/src/app/features/work-packages/components/wp-table/context-menu-helper/wp-context-menu-helper.service.ts index 68d82621f843..15438eccfc9d 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/context-menu-helper/wp-context-menu-helper.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/context-menu-helper/wp-context-menu-helper.service.ts @@ -56,7 +56,7 @@ export class WorkPackageContextMenuHelperService { private wpViewIndent = inject(WorkPackageViewHierarchyIdentationService); private PathHelper = inject(PathHelperService); - private BULK_ACTIONS = [ + private BULK_ACTIONS:WorkPackageAction[] = [ { text: I18n.t('js.work_packages.bulk_actions.edit'), key: 'edit', From 61400e7d6512b337da2811578e8f809fe8ba9643 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:11:56 +0100 Subject: [PATCH 05/27] [OP-20342] Replace Function types with signatures Types the dynamic component output maps with ng-dynamic-component's EventHandler, which is what ndcDynamicOutputs accepts, and the checksum callback as a plain thunk. The unconstrained Function type accepted any callable and let calls through unchecked. https://community.openproject.org/wp/OP-20342 --- .../components/wp-list/wp-list-checksum.service.ts | 2 +- .../partitioned-query-space-page.component.ts | 3 ++- .../select-edit-field/select-edit-field.component.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts index 805ef8378c5a..a17899f6320f 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-list-checksum.service.ts @@ -116,7 +116,7 @@ export class WorkPackagesListChecksumService { public executeIfOutdated(newId:string|null, newChecksum:string|null, - callback:Function) { + callback:() => void) { if (this.isUninitialized() || this.isOutdated(newId, newChecksum)) { this.set(newId, newChecksum); diff --git a/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts index 755f6119a5a9..e9ad9b1086d4 100644 --- a/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts +++ b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts @@ -49,11 +49,12 @@ import { firstValueFrom } from 'rxjs'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; import { UrlParamsService } from 'core-app/core/navigation/url-params.service'; +import { EventHandler } from 'ng-dynamic-component'; export interface DynamicComponentDefinition { component:ComponentType; inputs?:Record; - outputs?:Record; + outputs?:Record; } export interface ToolbarButtonComponentDefinition extends DynamicComponentDefinition { 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 7a30c6b1eadf..34ef43e5c462 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 @@ -42,6 +42,7 @@ import { HalResourceNotificationService } from 'core-app/features/hal/services/h 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 { EventHandler } from 'ng-dynamic-component'; export interface ValueOption { name:string; @@ -66,7 +67,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn public appendTo:any = null; - public referenceOutputs:Record = { + public referenceOutputs:Record = { onCreate: (newElement:HalResource) => this.onCreate(newElement), onChange: (value:HalResource) => this.onChange(value), onAddNew: (value:HalResource) => this.onNewValueAdded(value), From 545a4661096902620560c35bfc52f856aff34e0c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:40:34 +0100 Subject: [PATCH 06/27] [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 2f815e0d4d4ca64f4bb2fc85bf02c0b05c7228d8 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:44:34 +0100 Subject: [PATCH 07/27] [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 a771c82f662589e987175c7caea6097b1a3e8915 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:50:16 +0100 Subject: [PATCH 08/27] [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 42c611e26f4a6e05884852809d50650b9d70b61c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:55:13 +0100 Subject: [PATCH 09/27] [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 75e8ef1265b2eda765ee555a8ee17f08f503be90 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 22:58:28 +0100 Subject: [PATCH 10/27] [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 50594c4cb709cea4941dc33a6cf7cdaa81c55744 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 23:04:10 +0100 Subject: [PATCH 11/27] [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 a541ec594daedcdebc32affc87da1adf04585d01 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 23:04:10 +0100 Subject: [PATCH 12/27] [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 9db996987cc20fd7452befd44c9908de5de5f0e0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 23:06:07 +0100 Subject: [PATCH 13/27] [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 8d2e4e0dacc03f9171edd2da17e1a0fe753a5aad Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 10:28:59 +0100 Subject: [PATCH 14/27] [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 { From 9af7643c8eeec4bf5e26032ec8a319da3d006d40 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 00:39:29 +0100 Subject: [PATCH 15/27] [OP-20349] Type field resource attribute reads Casts the resource attributes that display and edit fields read by name at the point of use, keeping today's values. These reads compiled only because HalResource declared an any index signature, which the next commit removes. https://community.openproject.org/wp/OP-20349 --- .../fields/display/display-field-renderer.ts | 22 ++++++++----------- .../fields/display/display-field.component.ts | 7 +++--- .../fields/display/display-field.module.ts | 2 +- .../combined-date-display.field.ts | 6 ++--- .../field-types/date-display-field.module.ts | 7 +++--- .../datetime-display-field.module.ts | 2 +- .../days-duration-display-field.module.ts | 2 +- .../field-types/float-display-field.module.ts | 2 +- .../formattable-display-field.module.ts | 2 +- ...ghlighted-resource-display-field.module.ts | 2 +- .../hours-duration-display-field.module.ts | 2 +- .../integer-display-field.module.ts | 2 +- ...inked-work-package-display-field.module.ts | 2 +- ...ultiple-lines-user-display-field.module.ts | 2 +- .../plain-formattable-display-field.module.ts | 2 +- .../project-status-display-field.module.ts | 3 ++- .../resource-display-field.module.ts | 3 ++- .../resources-display-field.module.ts | 3 ++- .../work-package-display-field.module.ts | 4 ++-- .../field-types/wp-id-display-field.module.ts | 6 ++--- .../editable-attribute-field.component.ts | 5 ++--- .../services/hal-resource-editing.service.ts | 2 +- 22 files changed, 42 insertions(+), 48 deletions(-) diff --git a/frontend/src/app/shared/components/fields/display/display-field-renderer.ts b/frontend/src/app/shared/components/fields/display/display-field-renderer.ts index e5cd49e77c40..1a1927b3acab 100644 --- a/frontend/src/app/shared/components/fields/display/display-field-renderer.ts +++ b/frontend/src/app/shared/components/fields/display/display-field-renderer.ts @@ -39,7 +39,7 @@ import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/r import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { LazyInject } from 'core-app/shared/helpers/angular/lazy-inject.decorator'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; -import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; +import { ISchemaProxy } from 'core-app/features/hal/schemas/schema-proxy'; import { HalResourceEditingService, } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; @@ -95,8 +95,7 @@ export class DisplayFieldRenderer { const span = document.createElement('span'); const schema = this.schema(resource, change); const attributeName = this.attributeName(requestedAttribute, schema); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const fieldSchema = schema.ofProperty(attributeName) as IFieldSchema; + const fieldSchema = schema.ofProperty(attributeName); // If the resource does not have that field, return an empty // span (e.g., for the table). @@ -171,17 +170,16 @@ export class DisplayFieldRenderer { } } - private isAttributeEditable(schema:SchemaResource, fieldName:string):boolean { + private isAttributeEditable(schema:ISchemaProxy, fieldName:string):boolean { // We need to handle start/due date cases like they were combined dates if (['startDate', 'dueDate', 'date'].includes(fieldName)) { fieldName = 'combinedDate'; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - return schema.isAttributeEditable(fieldName) as boolean; + return schema.isAttributeEditable(fieldName); } - private getAriaLabel(field:DisplayField, schema:SchemaResource):string { + private getAriaLabel(field:DisplayField, schema:ISchemaProxy):string { let titleContent; const labelContent = this.getLabelContent(field); @@ -198,7 +196,6 @@ export class DisplayFieldRenderer { titleContent = labelContent; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call if (field.writable && !!schema.isAttributeEditable(field.name)) { return this.I18n.t('js.inplace.button_edit', { attribute: `${field.displayName} ${titleContent}` }); } @@ -219,22 +216,21 @@ export class DisplayFieldRenderer { * @param schema * @param attribute */ - private attributeName(attribute:string, schema:SchemaResource):string { + private attributeName(attribute:string, schema:ISchemaProxy):string { if (schema.mappedName) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - return schema.mappedName(attribute) as string; + return schema.mappedName(attribute); } return attribute; } - private schema(resource:T, change:ResourceChangeset|null):SchemaResource { + private schema(resource:T, change:ResourceChangeset|null):ISchemaProxy { if (change) { return change.schema; } if (this.halEditing.typedState(resource).hasValue()) { - const val = this.halEditing.typedState(resource).value as { schema:SchemaResource }; + const val = this.halEditing.typedState(resource).value as { schema:ISchemaProxy }; return val.schema; } diff --git a/frontend/src/app/shared/components/fields/display/display-field.component.ts b/frontend/src/app/shared/components/fields/display/display-field.component.ts index f82da0b7f802..cc100381d2eb 100644 --- a/frontend/src/app/shared/components/fields/display/display-field.component.ts +++ b/frontend/src/app/shared/components/fields/display/display-field.component.ts @@ -33,7 +33,7 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { Constructor } from 'core-app/core/util-types'; import { DisplayField } from 'core-app/shared/components/fields/display/display-field.module'; -import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; +import { ISchemaProxy } from 'core-app/features/hal/schemas/schema-proxy'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -99,10 +99,9 @@ export class DisplayFieldComponent implements OnInit { ); } - private attributeName(attribute:string, schema:SchemaResource):string { + private attributeName(attribute:string, schema:ISchemaProxy):string { if (schema.mappedName) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - return schema.mappedName(attribute) as string; + return schema.mappedName(attribute); } return attribute; diff --git a/frontend/src/app/shared/components/fields/display/display-field.module.ts b/frontend/src/app/shared/components/fields/display/display-field.module.ts index 7a028488af1a..756081e12458 100644 --- a/frontend/src/app/shared/components/fields/display/display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/display-field.module.ts @@ -98,7 +98,7 @@ export class DisplayField extends Field { } public get valueString():string { - return this.value; + return this.value as string; } public get placeholder():string { diff --git a/frontend/src/app/shared/components/fields/display/field-types/combined-date-display.field.ts b/frontend/src/app/shared/components/fields/display/field-types/combined-date-display.field.ts index 5f9c143ad18a..40dea1e2ed80 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/combined-date-display.field.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/combined-date-display.field.ts @@ -71,13 +71,11 @@ export class CombinedDateDisplayField extends DateDisplayField { } private get startDate():string|null { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this.resource.startDate; + return this.resource.startDate as string|null; } private get dueDate():string|null { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this.resource.dueDate; + return this.resource.dueDate as string|null; } private renderSingleDate(field:'date'|'startDate'|'dueDate', element:HTMLElement):void { diff --git a/frontend/src/app/shared/components/fields/display/field-types/date-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/date-display-field.module.ts index 1e50be11aae5..c5bb368096cd 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/date-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/date-display-field.module.ts @@ -35,6 +35,7 @@ import { toDOMString, opAutoDateIconData, } from '@openproject/octicons-angular'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; export class DateDisplayField extends HighlightableDisplayField { @LazyInject() timezoneService:TimezoneService; @@ -51,12 +52,12 @@ export class DateDisplayField extends HighlightableDisplayField { // Highlight overdue tasks if (this.shouldHighlight && this.canOverdue && !!this.resource.status) { - const diff = this.timezoneService.daysFromToday(this.value); + const diff = this.timezoneService.daysFromToday(this.value as string); this .apiV3Service .statuses - .id(this.resource.status.id) + .id((this.resource.status as HalResource).id!) .get() .subscribe((status) => { if (!status.isClosed) { @@ -73,7 +74,7 @@ export class DateDisplayField extends HighlightableDisplayField { public get valueString() { if (this.value) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - return this.timezoneService.formattedDate(this.value, this.context.options.dateFormat); + return this.timezoneService.formattedDate(this.value as string, this.context.options.dateFormat); } return ''; } diff --git a/frontend/src/app/shared/components/fields/display/field-types/datetime-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/datetime-display-field.module.ts index 625592aa0356..a4678950b362 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/datetime-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/datetime-display-field.module.ts @@ -35,7 +35,7 @@ export class DateTimeDisplayField extends DisplayField { public get valueString() { if (this.value) { - return this.timezoneService.formattedDatetime(this.value); + return this.timezoneService.formattedDatetime(this.value as string); } return ''; diff --git a/frontend/src/app/shared/components/fields/display/field-types/days-duration-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/days-duration-display-field.module.ts index fab4c11275f2..14d284024b3b 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/days-duration-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/days-duration-display-field.module.ts @@ -34,6 +34,6 @@ export class DaysDurationDisplayField extends DisplayField { @LazyInject() timezoneService:TimezoneService; public get valueString() { - return this.timezoneService.formattedDuration(this.value, 'days'); + return this.timezoneService.formattedDuration(this.value as string, 'days'); } } diff --git a/frontend/src/app/shared/components/fields/display/field-types/float-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/float-display-field.module.ts index 59237f796f5e..f04a492f63fb 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/float-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/float-display-field.module.ts @@ -34,7 +34,7 @@ export class FloatDisplayField extends DisplayField { return ''; } - return this.value.toLocaleString( + return (this.value as number).toLocaleString( this.I18n.locale, { useGrouping: true, maximumFractionDigits: 20 }, ); diff --git a/frontend/src/app/shared/components/fields/display/field-types/formattable-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/formattable-display-field.module.ts index f228c5a5abc7..e64ea703268c 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/formattable-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/formattable-display-field.module.ts @@ -70,7 +70,7 @@ export class FormattableDisplayField extends DisplayField { if (!this.schema) { return null; } - const element = this.resource[this.name]; + const element = this.resource[this.name] as api.v3.Formattable|null|undefined; if (!(element?.html)) { return ''; } diff --git a/frontend/src/app/shared/components/fields/display/field-types/highlighted-resource-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/highlighted-resource-display-field.module.ts index a10ebef0f017..bf86f5bc4771 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/highlighted-resource-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/highlighted-resource-display-field.module.ts @@ -41,7 +41,7 @@ export class HighlightedResourceDisplayField extends HighlightableDisplayField { public get value() { if (this.schema) { - return this.attribute?.name; + return (this.attribute as HalResource|null|undefined)?.name; } return null; } diff --git a/frontend/src/app/shared/components/fields/display/field-types/hours-duration-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/hours-duration-display-field.module.ts index 1ab2a2af5c1a..e80b49a76e2e 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/hours-duration-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/hours-duration-display-field.module.ts @@ -34,6 +34,6 @@ export class HoursDurationDisplayField extends DisplayField { @LazyInject() timezoneService:TimezoneService; public get valueString() { - return this.timezoneService.formattedDuration(this.value, 'hour'); + return this.timezoneService.formattedDuration(this.value as string, 'hour'); } } diff --git a/frontend/src/app/shared/components/fields/display/field-types/integer-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/integer-display-field.module.ts index 3ebe7318bd6d..dfd63d27335c 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/integer-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/integer-display-field.module.ts @@ -30,7 +30,7 @@ import { DisplayField } from 'core-app/shared/components/fields/display/display- export class IntegerDisplayField extends DisplayField { public get value() { - return parseInt(this.resource[this.name]); + return parseInt(this.resource[this.name] as string); } public isEmpty():boolean { diff --git a/frontend/src/app/shared/components/fields/display/field-types/linked-work-package-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/linked-work-package-display-field.module.ts index 0c2fc4beaff8..2dfe9374052b 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/linked-work-package-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/linked-work-package-display-field.module.ts @@ -59,7 +59,7 @@ export class LinkedWorkPackageDisplayField extends WorkPackageDisplayField { const routingId = this.wpRoutingId; const link = this.uiStateBuilder.linkToShow( - this.wpId, + this.wpId!, this.text.linkTitle, this.valueString, routingId, diff --git a/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-user-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-user-display-field.module.ts index 314556f0b058..b4502155a323 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-user-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-user-display-field.module.ts @@ -35,7 +35,7 @@ export class MultipleLinesUserFieldModule extends ResourcesDisplayField { @LazyInject() principalRenderer:PrincipalRendererService; public render(element:HTMLElement, displayText:string):void { - const values = this.attribute; + const values = this.attribute as UserResource[]; element.setAttribute('title', displayText); element.textContent = displayText; diff --git a/frontend/src/app/shared/components/fields/display/field-types/plain-formattable-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/plain-formattable-display-field.module.ts index 2632484f7ddc..6528c3da6fd7 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/plain-formattable-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/plain-formattable-display-field.module.ts @@ -33,7 +33,7 @@ export class PlainFormattableDisplayField extends DisplayField { if (!this.schema) { return null; } - const element = this.resource[this.name]; + const element = this.resource[this.name] as api.v3.Formattable|null|undefined; return element?.raw || ''; } diff --git a/frontend/src/app/shared/components/fields/display/field-types/project-status-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/project-status-display-field.module.ts index 112395ac7492..6065c66ea940 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/project-status-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/project-status-display-field.module.ts @@ -31,10 +31,11 @@ import { projectStatusCodeCssClass, projectStatusI18n, } from 'core-app/shared/components/fields/helpers/project-status-helper'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; export class ProjectStatusDisplayField extends DisplayField { public render(element:HTMLElement, displayText:string):void { - const code = this.value?.id; + const code = (this.value as HalResource|null|undefined)?.id; const bulb = document.createElement('span'); bulb.classList.add('project-status--bulb', projectStatusCodeCssClass(code)); diff --git a/frontend/src/app/shared/components/fields/display/field-types/resource-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/resource-display-field.module.ts index 0a92d640779c..813499878617 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/resource-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/resource-display-field.module.ts @@ -27,11 +27,12 @@ //++ import { DisplayField } from 'core-app/shared/components/fields/display/display-field.module'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; export class ResourceDisplayField extends DisplayField { public get value() { if (this.schema) { - return this.attribute?.name; + return (this.attribute as HalResource|null|undefined)?.name; } return null; } diff --git a/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.ts index cd9c76f0a915..e2fd6e295aaa 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.ts @@ -28,6 +28,7 @@ import { take } from 'lodash-es'; import { cssClassCustomOption, DisplayField } from 'core-app/shared/components/fields/display/display-field.module'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; export class ResourcesDisplayField extends DisplayField { public isEmpty():boolean { @@ -35,7 +36,7 @@ export class ResourcesDisplayField extends DisplayField { } public get stringValue():string[] { - const cf = this.resource[this.name]; + const cf = this.resource[this.name] as { elements?:HalResource[], map?:HalResource[]['map'], name?:string }|null|undefined; if (this.schema && cf) { if (cf.elements) { return cf.elements.map((e:any) => e.name); diff --git a/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.ts index 42c82e2e02eb..de92c385be37 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.ts @@ -36,7 +36,7 @@ export class WorkPackageDisplayField extends DisplayField { }; public get value() { - return this.resource[this.name]; + return this.resource[this.name] as WorkPackageResource; } public get title() { @@ -55,7 +55,7 @@ export class WorkPackageDisplayField extends DisplayField { return this.value.id; } - return this.value.href.match(/(\d+)$/)[0]; + return /(\d+)$/.exec(this.value.href!)![0]; } /** diff --git a/frontend/src/app/shared/components/fields/display/field-types/wp-id-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/wp-id-display-field.module.ts index d88cb6042d9f..55ef2fde4aed 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/wp-id-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/wp-id-display-field.module.ts @@ -46,8 +46,7 @@ export class WorkPackageIdDisplayField extends IdDisplayField { private uiStateBuilder:UiStateLinkBuilder = new UiStateLinkBuilder(this.keepTab, this.currentProject, this.pathHelper, this.urlParams); public get valueString():string { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-member-access - return this.resource.displayId ?? this.value?.toString() ?? ''; + return (this.resource.displayId as string|undefined) ?? (this.value as string|number|null|undefined)?.toString() ?? ''; } public render(element:HTMLElement, displayText:string):void { @@ -55,8 +54,7 @@ export class WorkPackageIdDisplayField extends IdDisplayField { return; } const link = this.uiStateBuilder.linkToShow( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.value, + this.value as string, displayText, displayText, this.valueString, diff --git a/frontend/src/app/shared/components/fields/edit/field/editable-attribute-field.component.ts b/frontend/src/app/shared/components/fields/edit/field/editable-attribute-field.component.ts index 108d00a843fb..46b6d370075a 100644 --- a/frontend/src/app/shared/components/fields/edit/field/editable-attribute-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field/editable-attribute-field.component.ts @@ -46,7 +46,7 @@ import { States } from 'core-app/core/states/states.service'; import { debugLog } from '../../../../helpers/debug_output'; import { hasSelectionWithin } from '../../../../helpers/selection-helpers'; import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; -import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; +import { ISchemaProxy } from 'core-app/features/hal/schemas/schema-proxy'; @Component({ selector: 'op-editable-attribute-field', @@ -151,7 +151,6 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme } public get isEditable():boolean { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call return !!(this.editForm && this.schema.isAttributeEditable(this.fieldName)); } @@ -246,7 +245,7 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme private get schema() { if (this.halEditing.typedState(this.resource).hasValue()) { - const val = this.halEditing.typedState(this.resource).value as { schema:SchemaResource }; + const val = this.halEditing.typedState(this.resource).value as { schema:ISchemaProxy }; return val.schema; } 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 da9e543abd08..95175d162bb4 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 @@ -199,7 +199,7 @@ export class HalResourceEditingService extends StateCacheService(resource); } - if (Object.hasOwn(resource, 'lockVersion') && changeset.pristineResource.lockVersion < resource.lockVersion) { + if (Object.hasOwn(resource, 'lockVersion') && (changeset.pristineResource.lockVersion as number) < (resource.lockVersion as number)) { return this.edit(resource); } From 982f01d230309e785c6f5d487938c48c1dee5dc2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 00:39:29 +0100 Subject: [PATCH 16/27] [OP-20349] Type work package attribute reads Casts work package attributes and raw links that views and services read without a declared type, keeping today's behaviour, ahead of the HalResource index signature becoming unknown. https://community.openproject.org/wp/OP-20349 --- .../filter-integer-value.component.ts | 3 ++- .../filters/query-filters/query-filters.component.ts | 3 +-- .../wp-breadcrumb/wp-breadcrumb-parent.component.ts | 2 +- .../wp-breadcrumb/wp-breadcrumb.component.ts | 2 +- .../wp-single-card/wp-single-card.component.ts | 3 ++- .../builders/modes/grouped/group-sums-builder.ts | 2 +- .../components/wp-query/url-params-helper.ts | 11 ++++++----- .../wp-relations-autocomplete.component.ts | 7 +++---- .../wp-single-view/wp-single-view.component.ts | 5 +++-- .../tabs/highlighting-tab.component.ts | 2 +- .../wp-table/timeline/cells/timeline-cell-renderer.ts | 3 ++- .../cells/timeline-milestone-cell-renderer.ts | 3 ++- .../container/wp-timeline-container.directive.ts | 3 ++- .../wp-view-base/work-package-single-view.base.ts | 2 +- 14 files changed, 28 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/filters/filter-integer-value/filter-integer-value.component.ts b/frontend/src/app/features/work-packages/components/filters/filter-integer-value/filter-integer-value.component.ts index f1c3b24ae45c..63d4f2404515 100644 --- a/frontend/src/app/features/work-packages/components/filters/filter-integer-value/filter-integer-value.component.ts +++ b/frontend/src/app/features/work-packages/components/filters/filter-integer-value/filter-integer-value.component.ts @@ -34,6 +34,7 @@ import { componentDestroyed } from '@w11k/ngx-componentdestroyed'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource'; import { QueryFilterResource } from 'core-app/features/hal/resources/query-filter-resource'; +import { SchemaAttributeObject } from 'core-app/features/hal/resources/schema-attribute-object'; @Component({ selector: 'op-filter-integer-value', @@ -69,7 +70,7 @@ export class FilterIntegerValueComponent extends UntilDestroyedMixin { } public get unit() { - switch ((this.schema.filter.allowedValues as QueryFilterResource[])[0].id) { + switch (((this.schema.filter as SchemaAttributeObject).allowedValues as QueryFilterResource[])[0].id) { case 'startDate': case 'dueDate': case 'updatedAt': diff --git a/frontend/src/app/features/work-packages/components/filters/query-filters/query-filters.component.ts b/frontend/src/app/features/work-packages/components/filters/query-filters/query-filters.component.ts index c9925e076c6d..378f7ea9b7da 100644 --- a/frontend/src/app/features/work-packages/components/filters/query-filters/query-filters.component.ts +++ b/frontend/src/app/features/work-packages/components/filters/query-filters/query-filters.component.ts @@ -168,8 +168,7 @@ export class QueryFiltersComponent extends UntilDestroyedMixin implements OnInit public isFilterAvailable(filter:QueryFilterResource):boolean { return (this.wpTableFilters.availableFilters.some((availableFilter) => availableFilter.id === filter.id) - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - && !(this.wpTableFilters.hidden.includes(filter.id) || filter.isTemplated())); + && !(this.wpTableFilters.hidden.includes(filter.id) || (filter as QueryFilterResource & { isTemplated():boolean }).isTemplated())); } public onOpen() { diff --git a/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb-parent.component.ts b/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb-parent.component.ts index ddc040c1a534..8babc4278744 100644 --- a/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb-parent.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb-parent.component.ts @@ -108,6 +108,6 @@ export class WorkPackageBreadcrumbParentComponent { } public parentLink(parent:WorkPackageResource):string { - return this.pathHelper.genericWorkPackagePath(parent.project?.identifier, parent.displayId) + window.location.search; + return this.pathHelper.genericWorkPackagePath(parent.project?.identifier as string|null, parent.displayId) + window.location.search; } } diff --git a/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb.component.ts b/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb.component.ts index e5d41ed2cc9c..40f04ae46c2d 100644 --- a/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb.component.ts @@ -64,7 +64,7 @@ export class WorkPackageBreadcrumbComponent { } public ancestorPath(ancestor:WorkPackageResource):string { - return this.pathHelper.genericWorkPackagePath(this.workPackage.project?.identifier, ancestor.displayId) + window.location.search; + return this.pathHelper.genericWorkPackagePath(this.workPackage.project?.identifier as string|null, ancestor.displayId) + window.location.search; } public updateActiveInput(val:boolean) { 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 d085ddb54e5c..01a5e874c7e4 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 @@ -75,6 +75,7 @@ import { } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service'; import { matchesRoutingId } from 'core-app/features/work-packages/helpers/work-package-id-resolvers'; import { UrlParamsService } from 'core-app/core/navigation/url-params.service'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -268,7 +269,7 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen private cardHighlighting(wp:WorkPackageResource):string { if (['status', 'priority', 'type'].includes(this.highlightingMode)) { - return Highlighting.backgroundClass(this.highlightingMode, wp[this.highlightingMode].id); + return Highlighting.backgroundClass(this.highlightingMode, (wp[this.highlightingMode] as HalResource).id!); } return ''; } diff --git a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts index 5290417138dc..5200bc39a52c 100644 --- a/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts +++ b/frontend/src/app/features/work-packages/components/wp-fast-table/builders/modes/grouped/group-sums-builder.ts @@ -59,7 +59,7 @@ export class GroupSumsBuilder extends SingleRowBuilder { public renderColumns(sums:Record, tr:HTMLTableRowElement) { this.augmentedColumns.forEach((column, i:number) => { const td = document.createElement('td'); - const div = this.renderContent(sums, column.id, this.sumsSchema[column.id]); + const div = this.renderContent(sums, column.id, this.sumsSchema[column.id] as IFieldSchema); if (i === 0) { this.appendFirstLabel(div); diff --git a/frontend/src/app/features/work-packages/components/wp-query/url-params-helper.ts b/frontend/src/app/features/work-packages/components/wp-query/url-params-helper.ts index f43ce7d7c3ed..a0abb9618ddd 100644 --- a/frontend/src/app/features/work-packages/components/wp-query/url-params-helper.ts +++ b/frontend/src/app/features/work-packages/components/wp-query/url-params-helper.ts @@ -403,8 +403,9 @@ export class UrlParamsHelperService { if (query.columns) { return query.columns.map((column:any) => column.id || idFromLink(column.href)) as string[]; } - if (query._links.columns) { - return query._links.columns.map((column:HalLink) => idFromLink(column.href)) as string[]; + const links = query._links as { columns?:HalLink[] }; + if (links.columns) { + return links.columns.map((column:HalLink) => idFromLink(column.href)); } return []; @@ -450,7 +451,7 @@ export class UrlParamsHelperService { } public buildV3GetFilterIdFromFilter(filter:QueryFilterInstanceResource) { - const href = filter.filter ? filter.filter.href : filter._links.filter.href; + const href = filter.filter ? filter.filter.href : (filter._links as { filter:HalLink }).filter.href; return idFromLink(href as string); } @@ -466,13 +467,13 @@ export class UrlParamsHelperService { if (filter.operator) { return filter.operator.id || idFromLink(filter.operator.href); } - const { href } = filter._links.operator; + const { href } = (filter._links as { operator:HalLink }).operator; return idFromLink(href as string); } private buildV3GetSortByFromQuery(query:QueryResource) { - const sortBys = query.sortBy ? query.sortBy : query._links.sortBy; + const sortBys = query.sortBy ? query.sortBy : (query._links as { sortBy:QuerySortByResource[] }).sortBy; const sortByIds = sortBys.map((sort:QuerySortByResource) => { if (sort.id) { return sort.id; diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts index dbbc928fdf9a..09cb65e3fbd0 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts @@ -42,6 +42,7 @@ import { } from 'core-app/features/work-packages/services/notifications/work-package-notification.service'; import { TOpAutocompleterResource } from 'core-app/shared/components/autocompleter/op-autocompleter/typings'; import { repositionDropdownBugfix } from 'core-app/shared/components/autocompleter/op-autocompleter/autocompleter.helper'; +import { HalLink } from 'core-app/features/hal/hal-link/hal-link'; export interface IWorkPackageAutocompleteItem extends WorkPackageResource { id:string, @@ -104,14 +105,12 @@ export class WorkPackageRelationsAutocompleteComponent extends OpAutocompleterCo } return from( - // Please address the disabled eslint rule when making major changes to this file. - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - this.workPackage.availableRelationCandidates.$link.$fetch({ + (this.workPackage.availableRelationCandidates as { $link:HalLink }).$link.$fetch({ query, filters: JSON.stringify(this.createFilters()), type: this.filterCandidatesFor || this.selectedRelationType, sortBy: JSON.stringify([['exactMatch', 'desc'], ['updatedAt', 'desc']]), - }) as Promise, + }), ) .pipe( map((collection) => collection.elements), 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 9e453b3c9eb3..648b55267733 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 @@ -56,6 +56,7 @@ import { IProjectStorage } from 'core-app/core/state/project-storages/project-st import idFromLink from 'core-app/features/hal/helpers/id-from-link'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { isSemanticWorkPackageId } from 'core-app/shared/helpers/work-package-id-pattern'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; export interface FieldDescriptor { name:string; @@ -395,13 +396,13 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen const projectHref:string|null = workPackage.project?.href; if (schema.baseSchema) { - schemaHref = schema.baseSchema.href; + schemaHref = (schema.baseSchema as HalResource).href; } else { schemaHref = schema.href; } return { - isNew: workPackage.isNew, + isNew: workPackage.isNew as boolean, schema: schemaHref, project: projectHref, }; diff --git a/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/tabs/highlighting-tab.component.ts b/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/tabs/highlighting-tab.component.ts index c08972f9e94b..8fd6e7b97612 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/tabs/highlighting-tab.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/configuration-modal/tabs/highlighting-tab.component.ts @@ -121,7 +121,7 @@ export class WpTableConfigurationHighlightingTabComponent implements TabComponen public get availableHighlightedAttributes():HalResource[] { const { schema } = this.querySpace.queryForm.value!; - return schema.highlightedAttributes.allowedValues; + return schema.highlightedAttributes!.allowedValues as HalResource[]; } public onOpen(component:unknown) { diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-cell-renderer.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-cell-renderer.ts index 6804660bccea..27a385e7db65 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-cell-renderer.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-cell-renderer.ts @@ -48,6 +48,7 @@ import { timelineElementCssClass, timelineMarkerSelectionStartClass, } from '../wp-timeline'; +import { IOPFieldSchema } from 'core-app/features/hal/interfaces'; export interface CellDateMovement { // Target values to move work package to @@ -107,7 +108,7 @@ export class TimelineCellRenderer { public canMoveDates(wp:WorkPackageResource):boolean { const schema = this.schemaCache.of(wp); - return schema.startDate.writable && schema.dueDate.writable && schema.isAttributeEditable('startDate'); + return (schema.startDate as IOPFieldSchema).writable && (schema.dueDate as IOPFieldSchema).writable && schema.isAttributeEditable('startDate'); } public isEmpty(wp:WorkPackageResource) { diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts index 199c5bedf2b9..9ee0254d11a9 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts @@ -51,6 +51,7 @@ import { MouseDirection, } from './timeline-cell-renderer'; import { WorkPackageCellLabels } from './wp-timeline-cell-labels'; +import { IOPFieldSchema } from 'core-app/features/hal/interfaces'; export class TimelineMilestoneCellRenderer extends TimelineCellRenderer { public get type():string { @@ -64,7 +65,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer { public canMoveDates(wp:WorkPackageResource) { const schema = this.schemaCache.of(wp); - return schema.date.writable && schema.isAttributeEditable('date'); + return (schema.date as IOPFieldSchema).writable && schema.isAttributeEditable('date'); } public displayPlaceholderUnderCursor(ev:MouseEvent, renderInfo:RenderInfo):HTMLElement { diff --git a/frontend/src/app/features/work-packages/components/wp-table/timeline/container/wp-timeline-container.directive.ts b/frontend/src/app/features/work-packages/components/wp-table/timeline/container/wp-timeline-container.directive.ts index 835912bc7cfd..28bbe9fb2150 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/timeline/container/wp-timeline-container.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/timeline/container/wp-timeline-container.directive.ts @@ -84,6 +84,7 @@ import { WeekdayService } from 'core-app/core/days/weekday.service'; import Mousetrap from 'mousetrap'; import { DayResourceService } from 'core-app/core/state/days/day.service'; import { IDay } from 'core-app/core/state/days/day.model'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -566,7 +567,7 @@ export class WorkPackageTimelineTableController extends UntilDestroyedMixin impl const changedGroupId = groupIdFromIdentifier(groupIdentifier); const changedGroupType = groupTypeFromIdentifier(groupIdentifier); - const changedGroupTableWorkPackages = tableWorkPackages.filter((tableWorkPackage) => tableWorkPackage[changedGroupType].id === changedGroupId); + const changedGroupTableWorkPackages = tableWorkPackages.filter((tableWorkPackage) => (tableWorkPackage[changedGroupType] as HalResource).id === changedGroupId); const changedGroupWpsWithHeaderCells = changedGroupTableWorkPackages.filter((tableWorkPackage) => this.shouldBeShownInCollapsedGroupHeaders(tableWorkPackage) && (tableWorkPackage.date || tableWorkPackage.startDate)); const changedGroupWpsWithHeaderCellsIds = changedGroupWpsWithHeaderCells.map((workPackage) => workPackage.id!); 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 aadad6577512..91ee260ad83e 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 @@ -184,7 +184,7 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { .id(this.workPackage.project) .requireAndStream() .subscribe(() => { - this.projectIdentifier = this.workPackage.project.identifier; + this.projectIdentifier = this.workPackage.project.identifier as string; this.cdRef.detectChanges(); }); From 9b93d8ba23f76510a595b38d3c7bbd0a8761ec8f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 00:39:29 +0100 Subject: [PATCH 17/27] [OP-20349] Type remaining resource attribute reads Casts undeclared resource attributes read in grids, context menus, modals, search, BCF, the team planner and the costs and GitLab plugin modules, keeping today's behaviour, ahead of the HalResource index signature becoming unknown. https://community.openproject.org/wp/OP-20349 --- .../apiv3/endpoints/grids/apiv3-grid-form.ts | 3 ++- .../input/global-search-input.component.ts | 2 +- .../bim/bcf/helper/viewpoints.service.ts | 2 +- .../team-planner/calendar-drag-drop.service.ts | 2 +- .../planner/team-planner.component.ts | 4 ++-- ...tries-work-package-autocompleter.component.ts | 4 ++-- .../ckeditor-augmented-textarea.component.ts | 6 ++---- .../custom-text-edit-field.service.ts | 5 ++--- .../widgets/custom-text/custom-text.component.ts | 2 +- .../widgets/documents/documents.component.ts | 4 ++-- .../wp-button-macro.modal.ts | 2 +- .../share-modal/query-sharing-form.component.ts | 2 +- .../handlers/op-types-context-menu.directive.ts | 3 +-- .../wp-create-settings-menu.directive.ts | 2 +- .../wp-status-dropdown-menu.directive.ts | 10 +++++----- .../wp-context-menu/wp-single-context-menu.ts | 2 +- .../costs-by-type-display-field.module.ts | 16 +++++++++------- .../wp-display/currency-display-field.module.ts | 2 +- .../module/tab-issue/wp-gitlab-issue.service.ts | 2 +- .../module/tab-mrs/wp-gitlab-mrs.service.ts | 2 +- 20 files changed, 38 insertions(+), 39 deletions(-) diff --git a/frontend/src/app/core/apiv3/endpoints/grids/apiv3-grid-form.ts b/frontend/src/app/core/apiv3/endpoints/grids/apiv3-grid-form.ts index 3283e5c0017e..7b82266a1447 100644 --- a/frontend/src/app/core/apiv3/endpoints/grids/apiv3-grid-form.ts +++ b/frontend/src/app/core/apiv3/endpoints/grids/apiv3-grid-form.ts @@ -31,6 +31,7 @@ import { SchemaResource } from 'core-app/features/hal/resources/schema-resource' import { HalPayloadHelper } from 'core-app/features/hal/schemas/hal-payload.helper'; import { GridWidgetResource } from 'core-app/features/hal/resources/grid-widget-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { GridResource } from 'core-app/features/hal/resources/grid-resource'; export class ApiV3GridForm extends ApiV3FormResource { /** @@ -42,7 +43,7 @@ export class ApiV3GridForm extends ApiV3FormResource { */ public static extractPayload(resource:HalResource|object, schema:SchemaResource|null = null):object { if (resource instanceof HalResource && schema) { - const grid = resource; + const grid = resource as GridResource; const payload = HalPayloadHelper.extractPayloadFromSchema(grid, schema); // The widget only states the type of the widget resource but does not explain diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.ts b/frontend/src/app/core/global_search/input/global-search-input.component.ts index 010d027feb66..bf2fbdbc2038 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.ts @@ -292,7 +292,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { if (item instanceof WorkPackageResource) { window.location.href = this.wpPath(item.displayId); } else if (item) { - this.searchInScope(item.projectScope); + this.searchInScope((item as SearchOptionItem).projectScope); } } 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 1d57837e33f7..3241813b3532 100644 --- a/frontend/src/app/features/bim/bcf/helper/viewpoints.service.ts +++ b/frontend/src/app/features/bim/bcf/helper/viewpoints.service.ts @@ -137,7 +137,7 @@ export class ViewpointsService { private createBcfTopic$(workPackage:WorkPackageResource):Observable { const wpProjectId = idFromLink(workPackage.project.href); - const wpPayload = workPackage.convertBCF.payload; + const wpPayload = (workPackage.convertBCF as HalResource).payload; return this.bcfApi .projects.id(wpProjectId) diff --git a/frontend/src/app/features/team-planner/team-planner/calendar-drag-drop.service.ts b/frontend/src/app/features/team-planner/team-planner/calendar-drag-drop.service.ts index 89be9c7c5a42..8e817cd19498 100644 --- a/frontend/src/app/features/team-planner/team-planner/calendar-drag-drop.service.ts +++ b/frontend/src/app/features/team-planner/team-planner/calendar-drag-drop.service.ts @@ -144,7 +144,7 @@ export class CalendarDragDropService { const startDate = moment(workPackage.startDate); const dueDate = moment(workPackage.dueDate); - const duration = Number(moment.duration(workPackage.duration).asDays().toFixed(0)); + const duration = Number(moment.duration(workPackage.duration as string).asDays().toFixed(0)); const diff = duration > 0 ? duration : dueDate.diff(startDate, 'days') + 1; return { 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 5d1db60a4504..abe0ae755224 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 @@ -122,6 +122,7 @@ import { personIconData, toDOMString, } from '@openproject/octicons-angular'; +import { IOPFieldSchema } from 'core-app/features/hal/interfaces'; export type TeamPlannerViewOptionKey = 'resourceTimelineWorkWeek'|'resourceTimelineWeek'|'resourceTimelineTwoWeeks'|'resourceTimelineFourWeeks'|'resourceTimelineEightWeeks'; export type TeamPlannerViewOptions = Record>>; @@ -874,8 +875,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, private eventResourceEditable(wp:WorkPackageResource):boolean { const schema = this.schemaCache.of(wp); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return !!schema.assignee?.writable && schema.isAttributeEditable('assignee'); + return !!(schema.assignee as IOPFieldSchema|undefined)?.writable && schema.isAttributeEditable('assignee'); } // Todo: Evaluate whether we really want to use that from a UI perspective ¯\_(ツ)_/¯ diff --git a/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component.ts index b545aa3cc1e4..df6c3ebd0f83 100644 --- a/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component.ts @@ -107,8 +107,8 @@ export class TimeEntriesWorkPackageAutocompleterComponent extends OpAutocomplete switchMap((collection:CollectionResource) => { this.recentWorkPackageIds = collection .elements - .filter((timeEntry) => timeEntry.workPackage?.href) - .map((timeEntry) => idFromLink(timeEntry.workPackage.href)) + .filter((timeEntry) => (timeEntry.workPackage as HalResource|undefined)?.href) + .map((timeEntry) => idFromLink((timeEntry.workPackage as HalResource).href)) .filter((v, i, a) => a.indexOf(v) === i); return this.loadWorkPackages(query); diff --git a/frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.ts b/frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.ts index 2d8ee00d05d4..e44208cfd793 100644 --- a/frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.ts +++ b/frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.ts @@ -285,8 +285,7 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl } private setupAttachmentRemovalSignal(editor:ICKEditorInstance) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access - this.attachments = [...(this.halResource as HalResource).attachments.elements]; + this.attachments = [...this.halResource!.attachments.elements]; this .states @@ -300,8 +299,7 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl const presentIds = new Set(resource.attachments.elements.map((other:HalResource) => other.id)); const missingAttachments = this.attachments.filter((attachment:HalResource) => !presentIds.has(attachment.id)); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return - const removedUrls = missingAttachments.map((attachment) => attachment.downloadLocation.href); + const removedUrls = missingAttachments.map((attachment) => (attachment.downloadLocation as HalResource).href); if (removedUrls.length) { editor.model.fire('op:attachment-removed', removedUrls); 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 87f2222b2399..59b37c2e4bab 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 @@ -37,7 +37,7 @@ import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/r import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { ICKEditorContext } from 'core-app/shared/components/editor/components/ckeditor/ckeditor.types'; import { GridResource } from 'core-app/features/hal/resources/grid-resource'; -import { HalSource } from 'core-app/features/hal/interfaces'; +import { HalSource, HalSourceLink } from 'core-app/features/hal/interfaces'; @Injectable() export class CustomTextEditFieldService extends EditFieldHandler { @@ -183,8 +183,7 @@ export class CustomTextEditFieldService extends EditFieldHandler { }; if (grid.prepareAttachment as { href?:string }) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - resourceSource._links.prepareAttachment = grid.prepareAttachment; + resourceSource._links.prepareAttachment = grid.prepareAttachment as HalSourceLink; } const resource = this.halResource.createHalResource(resourceSource, true); diff --git a/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts b/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts index a888f1f795f6..fd2453663860 100644 --- a/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/custom-text/custom-text.component.ts @@ -149,7 +149,7 @@ export class WidgetCustomTextComponent extends AbstractWidgetComponent implement } private memorizeRawText() { - this.currentRawText = (this.resource.options.text as HalResource).raw; + this.currentRawText = (this.resource.options.text as HalResource).raw as string; } private memorizeCustomText() { diff --git a/frontend/src/app/shared/components/grids/widgets/documents/documents.component.ts b/frontend/src/app/shared/components/grids/widgets/documents/documents.component.ts index b36f6d1ce432..e08e7fb580bb 100644 --- a/frontend/src/app/shared/components/grids/widgets/documents/documents.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/documents/documents.component.ts @@ -81,11 +81,11 @@ export class WidgetDocumentsComponent extends AbstractWidgetComponent implements } public documentCreated(document:DocumentResource) { - return this.timezone.formattedDatetime(document.createdAt); + return this.timezone.formattedDatetime(document.createdAt as string); } public documentDescription(document:DocumentResource) { - return this.domSanitizer.sanitize(SecurityContext.HTML, document.description.html); + return this.domSanitizer.sanitize(SecurityContext.HTML, (document.description as { html:string }).html); } public get noEntries() { diff --git a/frontend/src/app/shared/components/modals/editor/macro-wp-button-modal/wp-button-macro.modal.ts b/frontend/src/app/shared/components/modals/editor/macro-wp-button-modal/wp-button-macro.modal.ts index b29124165e09..03a3e812ae19 100644 --- a/frontend/src/app/shared/components/modals/editor/macro-wp-button-modal/wp-button-macro.modal.ts +++ b/frontend/src/app/shared/components/modals/editor/macro-wp-button-modal/wp-button-macro.modal.ts @@ -85,7 +85,7 @@ export class WpButtonMacroModalComponent extends OpModalComponent implements Aft .form .post({}) .subscribe((form:FormResource) => { - this.availableTypes = form.schema.type.allowedValues; + this.availableTypes = form.schema.type!.allowedValues as TypeResource[]; this.cdRef.detectChanges(); }); } diff --git a/frontend/src/app/shared/components/modals/share-modal/query-sharing-form.component.ts b/frontend/src/app/shared/components/modals/share-modal/query-sharing-form.component.ts index 58efd78e4ef3..e1afed0d19dc 100644 --- a/frontend/src/app/shared/components/modals/share-modal/query-sharing-form.component.ts +++ b/frontend/src/app/shared/components/modals/share-modal/query-sharing-form.component.ts @@ -80,7 +80,7 @@ export class QuerySharingFormComponent { const form = this.querySpace.queryForm.value!; return this.authorisationService.can('query', 'updateImmediately') - && form.schema.public.writable; + && form.schema.public!.writable; } public updateStarred(val:boolean) { diff --git a/frontend/src/app/shared/components/op-context-menu/handlers/op-types-context-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/handlers/op-types-context-menu.directive.ts index 44c786402169..d4f64e36ddc9 100644 --- a/frontend/src/app/shared/components/op-context-menu/handlers/op-types-context-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/handlers/op-types-context-menu.directive.ts @@ -75,8 +75,7 @@ export class OpTypesContextMenuDirective extends OpContextMenuTrigger implements .wpCreate .getEmptyForm(this.projectIdentifier) .then((form) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.buildItems(form.schema.type.allowedValues as TypeResource[]); + this.buildItems(form.schema.type!.allowedValues as TypeResource[]); this.opContextMenu.show(this, evt); }); } else { 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 4ca70334471c..9f70b138fd86 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 @@ -64,7 +64,7 @@ export class WorkPackageCreateSettingsMenuDirective extends OpContextMenuTrigger if (queryCustomFields) { this.items.push({ - href: queryCustomFields.href, + href: queryCustomFields.href as string|undefined, icon: 'icon-custom-fields', linkText: queryCustomFields.name, onClick: () => false, 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 fb4f4d2097b6..73d65ad5adda 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 @@ -37,7 +37,6 @@ import { 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 } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions'; @@ -49,6 +48,7 @@ import { } from 'core-app/features/work-packages/services/notifications/work-package-notification.service'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { HalError } from 'core-app/features/hal/services/hal-error'; +import { IOPFieldSchema } from 'core-app/features/hal/interfaces'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector @@ -68,11 +68,11 @@ export class WorkPackageStatusDropdownDirective extends OpContextMenuTrigger { protected open(evt:Event) { const change = this.halEditing.changeFor(this.workPackage); - change.getForm().then((form:any) => { - const statuses = form.schema.status.allowedValues; + void change.getForm().then((form) => { + const statuses = form.schema.status!.allowedValues as HalResource[]; this.buildItems(statuses); - const { writable } = change.schema.status; + const { writable } = change.schema.status as IOPFieldSchema; if (!writable) { this.toastService.addError(this.I18n.t('js.work_packages.message_work_package_status_blocked')); } else { @@ -104,7 +104,7 @@ export class WorkPackageStatusDropdownDirective extends OpContextMenuTrigger { } } - private buildItems(statuses:CollectionResource) { + private buildItems(statuses:HalResource[]) { this.items = statuses.map((status:HalResource) => ({ disabled: false, linkText: status.name, 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 2da4106139d1..0fbdc6d73381 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 @@ -125,7 +125,7 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger break; case 'copy': if (this.workPackage.id) { - window.location.href = `${this.PathHelper.workPackageCopyPath(this.workPackage.project.identifier, this.workPackage.id)}`; + window.location.href = `${this.PathHelper.workPackageCopyPath(this.workPackage.project.identifier as string|null, this.workPackage.id)}`; } break; case 'delete': { diff --git a/modules/costs/frontend/module/wp-display/costs-by-type-display-field.module.ts b/modules/costs/frontend/module/wp-display/costs-by-type-display-field.module.ts index baa19c16d893..3cdf3fcb9aeb 100644 --- a/modules/costs/frontend/module/wp-display/costs-by-type-display-field.module.ts +++ b/modules/costs/frontend/module/wp-display/costs-by-type-display-field.module.ts @@ -30,6 +30,8 @@ import { DisplayField } from "core-app/shared/components/fields/display/display- import { IFieldSchema } from "core-app/shared/components/fields/field.base"; import { LazyInject } from "core-app/shared/helpers/angular/lazy-inject.decorator"; import { ApiV3Service } from "core-app/core/apiv3/api-v3.service"; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; interface ICostsByType { costObjectId:string; @@ -53,8 +55,8 @@ export class CostsByTypeDisplayField extends DisplayField { } protected loadIfNecessary() { - if (this.value && this.value.$loaded === false) { - this.value.$load().then(() => { + if (this.value && (this.value as HalResource).$loaded === false) { + (this.value as HalResource).$load().then(() => { if (this.resource.$source._type === 'WorkPackage') { this @@ -77,7 +79,7 @@ export class CostsByTypeDisplayField extends DisplayField { return; } - this.value.elements.forEach((val:ICostsByType, i:number) => { + (this.value as CollectionResource).elements.forEach((val:ICostsByType, i:number) => { if (this.resource.showCosts) { this.renderCostAsLink(val, element, i); } else { @@ -88,8 +90,8 @@ export class CostsByTypeDisplayField extends DisplayField { public isEmpty():boolean { return !this.value || - !this.value.elements || - this.value.elements.length === 0; + !(this.value as CollectionResource).elements || + (this.value as CollectionResource).elements.length === 0; } @@ -100,7 +102,7 @@ export class CostsByTypeDisplayField extends DisplayField { const showCosts = this.resource.showCosts; const link = document.createElement('a') as HTMLAnchorElement; - link.href = showCosts.href + '&unit=' + val.costType.id; + link.href = (showCosts as { href:string }).href + '&unit=' + val.costType.id; link.setAttribute('target', '_blank'); link.textContent = val.spentUnits + ' ' + val.costType.name; element.appendChild(link); @@ -119,7 +121,7 @@ export class CostsByTypeDisplayField extends DisplayField { } private addSeparator(element:HTMLElement, i:number) { - if (i < this.value.elements.length - 1) { + if (i < (this.value as CollectionResource).elements.length - 1) { const sep = document.createElement('span'); sep.textContent = ', '; diff --git a/modules/costs/frontend/module/wp-display/currency-display-field.module.ts b/modules/costs/frontend/module/wp-display/currency-display-field.module.ts index 319996ba8ee7..e7b0c5ca42c6 100644 --- a/modules/costs/frontend/module/wp-display/currency-display-field.module.ts +++ b/modules/costs/frontend/module/wp-display/currency-display-field.module.ts @@ -32,7 +32,7 @@ export class CurrencyDisplayField extends DisplayField { public isEmpty():boolean { return !this.value || - !parseFloat(this.value.match(/\d+/g)[0]); + !parseFloat((this.value as string).match(/\d+/g)![0]); } } diff --git a/modules/gitlab_integration/frontend/module/tab-issue/wp-gitlab-issue.service.ts b/modules/gitlab_integration/frontend/module/tab-issue/wp-gitlab-issue.service.ts index 6fe7d7771177..26696df75c9d 100644 --- a/modules/gitlab_integration/frontend/module/tab-issue/wp-gitlab-issue.service.ts +++ b/modules/gitlab_integration/frontend/module/tab-issue/wp-gitlab-issue.service.ts @@ -40,7 +40,7 @@ export class WorkPackagesGitlabIssueService extends WorkPackageLinkedResourceCac protected load(workPackage:WorkPackageResource):Promise { - return workPackage.gitlab_issues.$update().then((data:any) => { + return (workPackage.gitlab_issues as HalResource).$update().then((data:any) => { return this.sortList(data.elements); }); } diff --git a/modules/gitlab_integration/frontend/module/tab-mrs/wp-gitlab-mrs.service.ts b/modules/gitlab_integration/frontend/module/tab-mrs/wp-gitlab-mrs.service.ts index 7035c20a9c59..a661b5c501e8 100644 --- a/modules/gitlab_integration/frontend/module/tab-mrs/wp-gitlab-mrs.service.ts +++ b/modules/gitlab_integration/frontend/module/tab-mrs/wp-gitlab-mrs.service.ts @@ -40,7 +40,7 @@ export class WorkPackagesGitlabMrsService extends WorkPackageLinkedResourceCache protected load(workPackage:WorkPackageResource):Promise { - return workPackage.gitlab_merge_requests.$update().then((data:any) => { + return (workPackage.gitlab_merge_requests as HalResource).$update().then((data:any) => { return this.sortList(data.elements); }); } From 9464b16dc9ff04ff630fec3274028d9c8d8ea5f3 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 00:39:30 +0100 Subject: [PATCH 18/27] [OP-20349] Type spec access to resource attributes Casts the resource attribute a spec reads by name, since specs are typechecked even though they are exempt from the typing lint rules. https://community.openproject.org/wp/OP-20349 --- .../hal/resources/hal-resource.spec.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) 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 baa907079f70..7030bb772da4 100644 --- a/frontend/src/app/features/hal/resources/hal-resource.spec.ts +++ b/frontend/src/app/features/hal/resources/hal-resource.spec.ts @@ -135,7 +135,7 @@ describe('HalResource', () => { }); it('should not be loaded', () => { - expect(resource.someResource.$loaded).toBeFalsy(); + expect((resource.someResource as HalResource).$loaded).toBeFalsy(); }); }); }); @@ -422,7 +422,7 @@ describe('HalResource', () => { }); it('should have a property, that is a loaded resource', () => { - expect(resource.resource.$loaded).toBeTruthy(); + expect((resource.resource as HalResource).$loaded).toBeTruthy(); }); it('should have an embedded resource, that is loaded', () => { @@ -445,7 +445,7 @@ describe('HalResource', () => { }); it('should set the property to that resource', () => { - expect(resource.resource.href).toEqual(link.href); + expect((resource.resource as HalResource).href).toEqual(link.href); }); }); @@ -485,7 +485,7 @@ describe('HalResource', () => { describe('when creating a resource from a source with a linked array property', () => { const expectLengthsToBe = (length:any, update = 'update') => { it(`should ${update} the values of the resource`, () => { - expect(resource.values.length).toEqual(length); + expect((resource.values as unknown[]).length).toEqual(length); }); it(`should ${update} the source`, () => { @@ -519,28 +519,28 @@ describe('HalResource', () => { describe('when adding resources to the array', () => { beforeEach(() => { - resource.values.push(resource); + (resource.values as unknown[]).push(resource); }); expectLengthsToBe(3); }); describe('when adding arbitrary values to the array', () => { beforeEach(() => { - resource.values.push('something'); + (resource.values as unknown[]).push('something'); }); expectLengthsToBe(2, 'not update'); }); describe('when removing resources from the array', () => { beforeEach(() => { - resource.values.pop(); + (resource.values as unknown[]).pop(); }); expectLengthsToBe(1); }); describe('when each value is transformed', () => { beforeEach(() => { - resource = resource.values[0]; + resource = (resource.values as HalResource[])[0]; source = source._links.values[0]; }); @@ -691,7 +691,7 @@ describe('HalResource', () => { }); it('should be a resource, if the link method is "get"', () => { - expect(resource.property.$isHal).toBeTruthy(); + expect((resource.property as HalResource).$isHal).toBeTruthy(); }); describe('when a property is a resource', () => { @@ -700,12 +700,12 @@ describe('HalResource', () => { }); it('should not be loaded initially', () => { - expect(resource.property.$loaded).toBeFalsy(); - expect(resource.notLinked.$loaded).toBeTruthy(); + expect((resource.property as HalResource).$loaded).toBeFalsy(); + expect((resource.notLinked as HalResource).$loaded).toBeTruthy(); }); it('should be loaded, if the resource is embedded', () => { - expect(resource.embedded.$loaded).toBeTruthy(); + expect((resource.embedded as HalResource).$loaded).toBeTruthy(); }); it('should update the source when set', () => { @@ -733,7 +733,7 @@ describe('HalResource', () => { return false as any; }); - resource = resource.property; + resource = resource.property as HalResource; promise = resource.$load().then((result:HalResource) => { newResult = result; }); From 2705f0ada9fb968271b7aa46e97b65ea06210e22 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 00:39:30 +0100 Subject: [PATCH 19/27] [OP-20349] Type HalResource index as unknown Declares the HalResource index signature as unknown instead of any. The any signature let every undeclared attribute read skip type checking, which a code comment had tracked as a source of bugs since 2019 (#31462). Reads of undeclared attributes now need a declaration or a cast, as the preceding commits add. The payload helper picks the current schema with ?? instead of ||; the value is a schema resource or nullish, so the result is unchanged. https://community.openproject.org/wp/OP-20349 --- frontend/src/app/features/hal/resources/form-resource.ts | 2 ++ .../app/features/hal/resources/grid-widget-resource.ts | 2 ++ frontend/src/app/features/hal/resources/hal-resource.ts | 8 ++------ .../src/app/features/hal/resources/query-form-resource.ts | 4 ++-- .../app/features/hal/resources/work-package-resource.ts | 4 ++-- .../src/app/features/hal/schemas/hal-payload.helper.ts | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/features/hal/resources/form-resource.ts b/frontend/src/app/features/hal/resources/form-resource.ts index f76723b91cf7..63a45e4a77c4 100644 --- a/frontend/src/app/features/hal/resources/form-resource.ts +++ b/frontend/src/app/features/hal/resources/form-resource.ts @@ -55,6 +55,8 @@ export class FormResource extends HalResource implements FormRe public configureForm?:HalResource; + public customFields?:HalResource; + public getErrors():ErrorResource|null { const errors = Object.values(this.validationErrors ?? {}); const count = errors.length; diff --git a/frontend/src/app/features/hal/resources/grid-widget-resource.ts b/frontend/src/app/features/hal/resources/grid-widget-resource.ts index be3350c2626a..da654144b39f 100644 --- a/frontend/src/app/features/hal/resources/grid-widget-resource.ts +++ b/frontend/src/app/features/hal/resources/grid-widget-resource.ts @@ -57,6 +57,8 @@ export class GridWidgetResource extends HalResource { public grid:GridResource; + public isNewWidget:boolean; + public get schema():SchemaResource { return this.halResource.createHalResource({ _type: 'Schema' }, true); } diff --git a/frontend/src/app/features/hal/resources/hal-resource.ts b/frontend/src/app/features/hal/resources/hal-resource.ts index c678d6d503f9..7be0e963d27e 100644 --- a/frontend/src/app/features/hal/resources/hal-resource.ts +++ b/frontend/src/app/features/hal/resources/hal-resource.ts @@ -55,11 +55,7 @@ export interface HalResourceLinks { 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 - // Since 2019-10-21 I'm documenting what bugs this caused: - // https://community.openproject.com/wp/31462 - [attribute:string]:any; + [attribute:string]:unknown; // The API type reported from API public _type:string; @@ -237,7 +233,7 @@ export class HalResource { public previewPath():string|undefined { if (isNewResource(this) && this.project) { - return this.project.href; + return (this.project as { href:string }).href; } return undefined; 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 90210741d102..6c1107d9785d 100644 --- a/frontend/src/app/features/hal/resources/query-form-resource.ts +++ b/frontend/src/app/features/hal/resources/query-form-resource.ts @@ -27,7 +27,7 @@ //++ import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; -import { FormResource } from 'core-app/features/hal/resources/form-resource'; +import { FormResource, FormSchemaResource } 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'; @@ -44,7 +44,7 @@ export interface QueryFormResourceEmbedded extends HalResourceEmbedded { export class QueryFormResource extends FormResource { public $embedded:QueryFormResourceEmbedded; - public schema:SchemaResource; + public schema:FormSchemaResource; public get filtersSchemas():QueryFilterInstanceSchemaResource[] { return this.$embedded.filtersSchemas.elements; 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 3406d0269bf5..43a07f181eb1 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -99,6 +99,7 @@ export interface WorkPackageResourceLinks { update:CallableHalLink>; updateImmediately:CallableHalLink; watch:CallableHalLink; + unwatch:CallableHalLink; } export interface WorkPackageLinksObject extends HalResourceLinks, WorkPackageResourceLinks { @@ -201,8 +202,7 @@ export class WorkPackageBaseResource extends HalResource { * Return ": ()" if type and id are known. */ public subjectWithType(truncateSubject = 40):string { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return `${this.type.name}: ${this.subjectWithId(truncateSubject)}`; + return `${(this.type as TypeResource).name}: ${this.subjectWithId(truncateSubject)}`; } /** 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 3ed967b3f308..062f18e64a3b 100644 --- a/frontend/src/app/features/hal/schemas/hal-payload.helper.ts +++ b/frontend/src/app/features/hal/schemas/hal-payload.helper.ts @@ -84,7 +84,7 @@ export class HalPayloadHelper { if (Array.isArray(resource[property])) { payload[property] = (resource[property] as HalResource[]).map((element) => { if (element instanceof HalResource) { - return this.extractPayloadFromSchema(element, element.currentSchema || element.schema); + return this.extractPayloadFromSchema(element, (element.currentSchema ?? element.schema) as SchemaResource); } return element; }); From 539fd5b78ebe75c2b05458255665b89b6aeae3b4 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 01:11:44 +0100 Subject: [PATCH 20/27] [OP-20347] Type single view form attribute groups Declares the two attribute group shapes the work package form schema embeds (attribute groups and query groups) and casts the schema's _attributeGroups to them at the boundary, so rebuilding the grouped fields no longer reads untyped members. Drops the redundant unknown intersection on the project casts. https://community.openproject.org/wp/OP-20347 --- .../wp-single-view.component.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) 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 648b55267733..50ca464d9ca1 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 @@ -57,6 +57,7 @@ import idFromLink from 'core-app/features/hal/helpers/id-from-link'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { isSemanticWorkPackageId } from 'core-app/shared/helpers/work-package-id-pattern'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { HalSource } from 'core-app/features/hal/interfaces'; export interface FieldDescriptor { name:string; @@ -77,6 +78,21 @@ export interface GroupDescriptor { type:string; } +interface WorkPackageFormAttributeGroup { + _type:'WorkPackageFormAttributeGroup'; + name:string; + attributes:string[]; +} + +interface WorkPackageFormQueryGroup { + _type:'WorkPackageFormQueryGroup'; + name:string; + relationType?:string; + _embedded:{ query:HalSource }; +} + +type FormAttributeGroup = WorkPackageFormAttributeGroup|WorkPackageFormQueryGroup; + export interface ResourceContextChange { isNew:boolean; schema:string|null; @@ -182,7 +198,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen if (!resource.project) { this.projectContext = { matches: false, href: null, id: null }; } else { - const project = resource.project as unknown&{ href:string, id:string }; + const project = resource.project as { href:string, id:string }; const workPackageId = this.workPackage.id; if (!workPackageId) { throw new Error('work package id is invalid'); @@ -199,8 +215,11 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen this.updateWorkPackageCreationState(change); } - // eslint-disable-next-line no-underscore-dangle - this.groupedFields = this.rebuildGroupedFields(change, this.schema(resource)._attributeGroups) as GroupDescriptor[]; + this.groupedFields = this.rebuildGroupedFields( + change, + // eslint-disable-next-line no-underscore-dangle + this.schema(resource)._attributeGroups as FormAttributeGroup[]|undefined, + ) as GroupDescriptor[]; this.cdRef.detectChanges(); } @@ -214,7 +233,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen if (resource.project === null) { this.projectStorages.next([]); } else { - const project = resource.project as unknown&{ href:string, id:string }; + const project = resource.project as { href:string, id:string }; combineLatest([ this.projectsResourceService.requireEntity(project.href), this.projectStoragesService.requireCollection({ filters: [['projectId', '=', [project.id]]] }), @@ -299,12 +318,12 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen return this.element.getBoundingClientRect().width > 750; } - private rebuildGroupedFields(change:WorkPackageChangeset, attributeGroups:any) { + private rebuildGroupedFields(change:WorkPackageChangeset, attributeGroups:FormAttributeGroup[]|undefined) { if (!attributeGroups) { return []; } - return attributeGroups.map((group:any) => { + return attributeGroups.map((group) => { const groupId = this.getAttributesGroupId(group); if (group._type === 'WorkPackageFormAttributeGroup') { From 7617d2cbc32b920e6e00eb565e34bc19b2afb1be Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 01:11:44 +0100 Subject: [PATCH 21/27] [OP-20347] Type watchers and activity collections Types the watchers tab's list as user resources, its autocompleter input as the user autocomplete item and its rejections as error resources. Types the aggregated activity pages as nested resource arrays and the full view's watchers as resource elements. https://community.openproject.org/wp/OP-20347 --- .../activity-panel/wp-activity.service.ts | 9 ++++--- .../watchers-tab/watchers-tab.component.ts | 26 ++++++++++--------- .../wp-full-view/wp-full-view.component.ts | 5 ++-- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/wp-activity.service.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/wp-activity.service.ts index f977a25d81d4..58ca518c14c3 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/wp-activity.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/wp-activity.service.ts @@ -29,6 +29,7 @@ import { sortBy } from 'lodash-es'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; import { Injectable, inject } from '@angular/core'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; import { WorkPackageLinkedResourceCache } from 'core-app/features/work-packages/components/wp-single-view-tabs/wp-linked-resource-cache.service'; @@ -54,10 +55,10 @@ export class WorkPackagesActivityService extends WorkPackageLinkedResourceCache< * whose order depends on the 'commentsSortedInDescendingOrder' property. */ protected load(workPackage:WorkPackageResource):Promise { - const aggregated:any[] = []; const - promises:Promise[] = []; + const aggregated:HalResource[][] = []; const + promises:Promise[] = []; - const add = function (data:any) { + const add = function (data:CollectionResource) { aggregated.push(data.elements); }; @@ -69,7 +70,7 @@ export class WorkPackagesActivityService extends WorkPackageLinkedResourceCache< return Promise.all(promises).then(() => this.sortedActivityList(aggregated)); } - protected sortedActivityList(activities:HalResource[], attr = 'createdAt'):HalResource[] { + protected sortedActivityList(activities:HalResource[][], attr = 'createdAt'):HalResource[] { const sorted = sortBy(activities.flat(), attr); if (this.isReversed) { diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component.ts index f04964b1886d..c63b6f036779 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component.ts @@ -28,7 +28,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit, inject } from '@angular/core'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { UserResource } from 'core-app/features/hal/resources/user-resource'; +import { ErrorResource } from 'core-app/features/hal/resources/error-resource'; import { LoadingIndicatorService } from 'core-app/core/loading-indicator/loading-indicator.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { @@ -41,6 +42,7 @@ import { import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; +import { IUserAutocompleteItem } from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component'; @Component({ templateUrl: './watchers-tab.html', @@ -78,7 +80,7 @@ export class WorkPackageWatchersTabComponent extends UntilDestroyedMixin impleme private element:HTMLElement; - public watching:any[] = []; + public watching:UserResource[] = []; public text = { loading: this.I18n.t('js.watchers.label_loading'), @@ -120,20 +122,20 @@ export class WorkPackageWatchersTabComponent extends UntilDestroyedMixin impleme } this.wpWatchersService.require(this.workPackage) - .then((watchers:HalResource[]) => { - this.watching = watchers; + .then((watchers) => { + this.watching = watchers as UserResource[]; this.cdRef.detectChanges(); }) - .catch((error:any) => { + .catch((error:ErrorResource) => { this.notificationService.showError(error, this.workPackage); }); } - public set loadingPromise(promise:Promise) { + public set loadingPromise(promise:Promise) { this.loadingIndicator.wpDetails.promise = promise; } - public addWatcher(user:any) { + public addWatcher(user:IUserAutocompleteItem) { this.loadingPromise = this.workPackage.addWatcher.$link.$fetch({ user: { href: user.href } }) .then(() => { // Forcefully reload the resource to update the watch/unwatch links @@ -149,13 +151,13 @@ export class WorkPackageWatchersTabComponent extends UntilDestroyedMixin impleme this.cdRef.detectChanges(); }) - .catch((error:any) => this.notificationService.showError(error, this.workPackage)); + .catch((error:ErrorResource) => this.notificationService.showError(error, this.workPackage)); } - public removeWatcher(watcher:any) { - this.workPackage.removeWatcher.$link.$prepare({ user_id: watcher.id })() + public removeWatcher(watcher:UserResource) { + this.workPackage.removeWatcher.$link.$prepare({ user_id: watcher.id! })() .then(() => { - this.watching = this.watching.filter((other:HalResource) => other.href !== watcher.href); + this.watching = this.watching.filter((other) => other.href !== watcher.href); // Forcefully reload the resource to update the watch/unwatch links // should the current user have been removed @@ -170,7 +172,7 @@ export class WorkPackageWatchersTabComponent extends UntilDestroyedMixin impleme this.cdRef.detectChanges(); }) - .catch((error:any) => this.notificationService.showError(error, this.workPackage)); + .catch((error:ErrorResource) => this.notificationService.showError(error, this.workPackage)); } public updateCounter() { diff --git a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts index 4a42d7844ef2..4130932f0572 100644 --- a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts @@ -31,6 +31,7 @@ import { CurrentUserService } from 'core-app/core/current-user/current-user.serv import { TabDefinition } from 'core-app/shared/components/tabs/tab.interface'; import { RecentItemsService } from 'core-app/core/recent-items.service'; import { ProjectResource } from 'core-app/features/hal/resources/project-resource'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; import { WpSingleViewService } from 'core-app/features/work-packages/routing/wp-view-base/state/wp-single-view.service'; @@ -66,7 +67,7 @@ export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase imp public displayWatchButton = false; - public watchers:any; + public watchers:HalResource[]; public text = { fullView: { @@ -116,7 +117,7 @@ export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase imp // watchers if (wp.watchers) { - this.watchers = (wp.watchers as any).elements; + this.watchers = wp.watchers.elements; } } } From ea95bb9b06d3a7066eac8b5d2b578635305230f7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 01:15:41 +0100 Subject: [PATCH 22/27] [OP-20347] Type relations and hierarchy payloads Types the change-parent payload, the children query props and the relation update params, and treats relation rejections as unknown, since they only flow into handleRawError, which accepts unknown. https://community.openproject.org/wp/OP-20347 --- .../wp-breadcrumb/wp-breadcrumb-parent.component.ts | 4 ++-- .../add-existing/wp-relation-inline-add-existing.component.ts | 2 +- .../wp-relations/wp-relation-row/wp-relation-row.component.ts | 4 ++-- .../wp-relations-hierarchy.directive.ts | 2 +- .../wp-relations-hierarchy/wp-relations-hierarchy.service.ts | 2 +- .../components/wp-relations/wp-relations.interfaces.ts | 2 +- .../components/wp-relations/wp-relations.service.ts | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb-parent.component.ts b/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb-parent.component.ts index 8babc4278744..ca953b2e49ab 100644 --- a/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb-parent.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-breadcrumb/wp-breadcrumb-parent.component.ts @@ -93,8 +93,8 @@ export class WorkPackageBreadcrumbParentComponent { } this.isSaving = true; - this.wpRelationsHierarchy.changeParent(this.workPackage, newParentId) - .catch((error:any) => { + void this.wpRelationsHierarchy.changeParent(this.workPackage, newParentId) + .catch((error:unknown) => { this.notificationService.handleRawError(error, this.workPackage); }) .then(() => this.isSaving = false); // Behaves as .finally() diff --git a/frontend/src/app/features/work-packages/components/wp-relations/embedded/inline/add-existing/wp-relation-inline-add-existing.component.ts b/frontend/src/app/features/work-packages/components/wp-relations/embedded/inline/add-existing/wp-relation-inline-add-existing.component.ts index 0b15da6ac83b..dc0457607412 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/embedded/inline/add-existing/wp-relation-inline-add-existing.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/embedded/inline/add-existing/wp-relation-inline-add-existing.component.ts @@ -107,7 +107,7 @@ export class WpRelationInlineAddExistingComponent { this.wpInlineCreate.newInlineWorkPackageReferenced.next(newRelationId); this.cancel(); }) - .catch((err:any) => { + .catch((err:unknown) => { this.notificationService.handleRawError(err, this.workPackage); this.isDisabled = false; this.cancel(); diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relation-row/wp-relation-row.component.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relation-row/wp-relation-row.component.ts index 32d9f9195dbc..1d0d930f74c0 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relation-row/wp-relation-row.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relation-row/wp-relation-row.component.ts @@ -206,7 +206,7 @@ export class WorkPackageRelationRowComponent extends UntilDestroyedMixin impleme this.userInputs.showRelationTypesForm = false; this.cdRef.detectChanges(); }) - .catch((error:any) => this.notificationService.handleRawError(error, this.workPackage)); + .catch((error:unknown) => this.notificationService.handleRawError(error, this.workPackage)); } public toggleUserDescriptionForm() { @@ -232,7 +232,7 @@ export class WorkPackageRelationRowComponent extends UntilDestroyedMixin impleme this.notificationService.showSave(this.relatedWorkPackage); }) - .catch((err:any) => this.notificationService.handleRawError(err, + .catch((err:unknown) => this.notificationService.handleRawError(err, this.relatedWorkPackage)); } diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts index 10eeb145d9b4..9dbccd616cf3 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts @@ -70,7 +70,7 @@ export class WorkPackageRelationsHierarchyComponent extends UntilDestroyedMixin public canAddRelation:boolean; - public childrenQueryProps:any; + public childrenQueryProps:{ filters:string, 'columns[]':string[], showHierarchies:boolean }; public text = { parentHeadline: this.I18n.t('js.relations_hierarchy.parent_headline'), diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.service.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.service.ts index 05025b708db8..961d668e5b80 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.service.ts @@ -44,7 +44,7 @@ export class WorkPackageRelationsHierarchyService { public changeParent(workPackage:WorkPackageResource, parentId:string|null) { - const payload:any = { + const payload:{ lockVersion:number, _links?:{ parent:{ href:string|null } } } = { lockVersion: workPackage.lockVersion, }; diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations.interfaces.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations.interfaces.ts index 737cda907f31..6d88bb5e1c90 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations.interfaces.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations.interfaces.ts @@ -26,4 +26,4 @@ // See COPYRIGHT and LICENSE files for more details. //++ -export type RelatedWorkPackagesGroup = Record; +export type RelatedWorkPackagesGroup = Record; diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations.service.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations.service.ts index c1199878e412..b5a08ce1652a 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations.service.ts @@ -175,7 +175,7 @@ export class WorkPackageRelationsService extends StateCacheService) { + public updateRelation(relation:RelationResource, params:Record) { return relation.updateImmediately(params) .then((savedRelation:RelationResource) => { this.insertIntoStates(savedRelation); From c377eaeafa5dff043c67158127270589be36798f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 01:15:42 +0100 Subject: [PATCH 23/27] [OP-20347] Type work package filter values Types the date-time filter parser and formatter inputs as the ISO strings they receive, and the single date-time filter's value getter as a string so its template binding matches. Types the query filter's operators as operator resources read from the filter schema and the unsubscribe notifier as an observable of unknown. https://community.openproject.org/wp/OP-20347 --- .../abstract-filter-date-time-value.controller.ts | 4 ++-- .../filter-date-time-value.component.ts | 7 +++---- .../filters/query-filter/query-filter.component.ts | 5 +++-- .../components/filters/wp-filters/wp-filters.service.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/filters/abstract-filter-date-time-value/abstract-filter-date-time-value.controller.ts b/frontend/src/app/features/work-packages/components/filters/abstract-filter-date-time-value/abstract-filter-date-time-value.controller.ts index ae7f5269166f..73385a4ed674 100644 --- a/frontend/src/app/features/work-packages/components/filters/abstract-filter-date-time-value/abstract-filter-date-time-value.controller.ts +++ b/frontend/src/app/features/work-packages/components/filters/abstract-filter-date-time-value/abstract-filter-date-time-value.controller.ts @@ -48,7 +48,7 @@ export abstract class AbstractDateTimeValueController extends UntilDestroyedMixi public abstract get upperBoundary():Moment|null; - public isoDateParser(data:any) { + public isoDateParser(data:string) { if (!this.timezoneService.isValidISODate(data)) { return ''; } @@ -56,7 +56,7 @@ export abstract class AbstractDateTimeValueController extends UntilDestroyedMixi return this.timezoneService.formattedISODateTime(d); } - public isoDateFormatter(data:any) { + public isoDateFormatter(data:string) { if (!this.timezoneService.isValidISODateTime(data)) { return ''; } diff --git a/frontend/src/app/features/work-packages/components/filters/filter-date-time-value/filter-date-time-value.component.ts b/frontend/src/app/features/work-packages/components/filters/filter-date-time-value/filter-date-time-value.component.ts index 4cf02fa6a918..f90237b2b607 100644 --- a/frontend/src/app/features/work-packages/components/filters/filter-date-time-value/filter-date-time-value.component.ts +++ b/frontend/src/app/features/work-packages/components/filters/filter-date-time-value/filter-date-time-value.component.ts @@ -27,7 +27,6 @@ //++ import { ChangeDetectionStrategy, Component, Input, HostBinding, OnInit, Output } from '@angular/core'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { DebouncedEventEmitter } from 'core-app/shared/helpers/rxjs/debounced-event-emitter'; import { Moment } from 'moment'; import { componentDestroyed } from '@w11k/ngx-componentdestroyed'; @@ -56,12 +55,12 @@ export class FilterDateTimeValueComponent extends AbstractDateTimeValueControlle @Output() public filterChanged = new DebouncedEventEmitter(componentDestroyed(this)); - public get value():HalResource|string { - return this.filter.values[0]; + public get value():string { + return this.filter.values[0] as string; } public set value(val) { - this.filter.values = [val as string]; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion + this.filter.values = [val]; this.filterChanged.emit(this.filter); } diff --git a/frontend/src/app/features/work-packages/components/filters/query-filter/query-filter.component.ts b/frontend/src/app/features/work-packages/components/filters/query-filter/query-filter.component.ts index 2563c1530892..d53f8cb42809 100644 --- a/frontend/src/app/features/work-packages/components/filters/query-filter/query-filter.component.ts +++ b/frontend/src/app/features/work-packages/components/filters/query-filter/query-filter.component.ts @@ -36,6 +36,7 @@ import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; import { QueryFilterInstanceResource } from 'core-app/features/hal/resources/query-filter-instance-resource'; import { QueryFilterResource } from 'core-app/features/hal/resources/query-filter-resource'; +import { QueryOperatorResource } from 'core-app/features/hal/resources/query-operator-resource'; import { WorkPackageViewBaselineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-baseline.service'; @Component({ @@ -67,7 +68,7 @@ export class QueryFilterComponent implements OnInit { @Output() public deactivateFilter = new EventEmitter(); - public availableOperators:any; + public availableOperators:QueryOperatorResource[]; public showValuesInput = false; @@ -106,7 +107,7 @@ export class QueryFilterComponent implements OnInit { } ngOnInit() { - this.availableOperators = this.schemaCache.of(this.filter).availableOperators; + this.availableOperators = this.schemaCache.of(this.filter).availableOperators as QueryOperatorResource[]; this.showValuesInput = this.showValues(); this.baselineIncompatibleFilter = this.wpTableBaseline.isActive() && this.wpTableBaseline.isIncompatibleFilter(this.filter.id); } diff --git a/frontend/src/app/features/work-packages/components/filters/wp-filters/wp-filters.service.ts b/frontend/src/app/features/work-packages/components/filters/wp-filters/wp-filters.service.ts index 956f47e6cf5c..49fe198b4854 100644 --- a/frontend/src/app/features/work-packages/components/filters/wp-filters/wp-filters.service.ts +++ b/frontend/src/app/features/work-packages/components/filters/wp-filters/wp-filters.service.ts @@ -43,7 +43,7 @@ export class WorkPackageFiltersService { this.state.putValue(val); } - public observeUntil(unsubscribe:Observable) { + public observeUntil(unsubscribe:Observable) { return this.state.values$().pipe(takeUntil(unsubscribe)); } From 9d74344f9bba769e7ea7d622655647539d5a3c09 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 01:15:42 +0100 Subject: [PATCH 24/27] [OP-20347] Type work package list and edit form Types the query loading error as an error resource, the starred toggle as a promise of unknown, the query form schema as a query schema resource and the copy view's breadcrumbs as breadcrumb items, translating through the I18n service like its sibling entries. Makes the table edit form's requireVisible resolve to void, matching the EditForm contract whose only caller ignores the resolved container. Removes the unused types field from the create button. https://community.openproject.org/wp/OP-20347 --- .../wp-create-button/wp-create-button.component.ts | 2 -- .../components/wp-copy/wp-copy-full-view.component.ts | 7 ++++--- .../components/wp-edit-form/table-edit-form.ts | 6 +++--- .../wp-inline-create/wp-inline-create.service.ts | 2 +- .../work-packages/components/wp-list/wp-list.service.ts | 4 ++-- .../components/wp-list/wp-states-initialization.service.ts | 2 +- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-create-button/wp-create-button.component.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-create-button/wp-create-button.component.ts index 125d642b2534..1e459846be76 100644 --- a/frontend/src/app/features/work-packages/components/wp-buttons/wp-create-button/wp-create-button.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-create-button/wp-create-button.component.ts @@ -59,8 +59,6 @@ export class WorkPackageCreateButtonComponent extends UntilDestroyedMixin implem projectIdentifier:string|null; - types:any; - text = { title: this.I18n.t('js.work_packages.create.title'), createWithDropdown: this.I18n.t('js.work_packages.create.button'), diff --git a/frontend/src/app/features/work-packages/components/wp-copy/wp-copy-full-view.component.ts b/frontend/src/app/features/work-packages/components/wp-copy/wp-copy-full-view.component.ts index de58d6996110..73fd7435f6e2 100644 --- a/frontend/src/app/features/work-packages/components/wp-copy/wp-copy-full-view.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-copy/wp-copy-full-view.component.ts @@ -28,6 +28,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { WorkPackageCopyController } from 'core-app/features/work-packages/components/wp-copy/wp-copy.controller'; +import { BreadcrumbItem } from 'core-app/shared/components/breadcrumbs/op-breadcrumbs.component'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -39,11 +40,11 @@ import { WorkPackageCopyController } from 'core-app/features/work-packages/compo }) export class WorkPackageCopyFullViewComponent extends WorkPackageCopyController { breadcrumbItems() { - const items = []; + const items:BreadcrumbItem[] = []; if (this.currentProjectService?.identifier) { items.push({ href: this.pathHelper.projectPath(this.currentProjectService.identifier), - text: this.currentProjectService.name, + text: this.currentProjectService.name!, }); } items.push({ @@ -54,7 +55,7 @@ export class WorkPackageCopyFullViewComponent extends WorkPackageCopyController href: this.pathHelper.projectWorkPackagePath(this.currentProjectService.identifier!, this.stateParams.copiedFromWorkPackageId!), text: this.newWorkPackage.subject, }); - items.push(I18n.t('js.button_duplicate')); + items.push(this.I18n.t('js.button_duplicate')); return items; } diff --git a/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts b/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts index 95d28995ec97..a5e9ac91dc04 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit-form/table-edit-form.ts @@ -141,14 +141,14 @@ export class TableEditForm extends EditForm { } } - public requireVisible(fieldName:string):Promise { + public requireVisible(fieldName:string):Promise { // Ensure the query form is loaded before trying to set fields // as we require new columns to be present return this.wpListService .conditionallyLoadForm() - .then(() => { + .then(async () => { this.wpTableColumns.addColumn(fieldName); - return this.waitForContainer(fieldName); + await this.waitForContainer(fieldName); }); } diff --git a/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.service.ts b/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.service.ts index e86f00a7f2c6..cfcda31fa5a9 100644 --- a/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-inline-create/wp-inline-create.service.ts @@ -51,7 +51,7 @@ export class WorkPackageInlineCreateService implements OnDestroy { /** * A separate reference pane for the inline create component */ - public readonly referenceComponentClass:ComponentType|null = null; + public readonly referenceComponentClass:ComponentType|null = null; /** * A related work package for the inline create context 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 de5a53c4c696..d36e48e7c699 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 @@ -122,7 +122,7 @@ export class WorkPackagesListService { .find(queryData, queryParams.query_id, projectIdentifier); return stream.pipe( - catchError((error) => { + catchError((error:ErrorResource) => { // Load a default query const queryProps = this.UrlParamsHelper.buildV3GetQueryFromJsonParams(decodedProps); return from(this.handleQueryLoadingError(error, queryProps, queryParams.query_id, projectIdentifier)); @@ -336,7 +336,7 @@ export class WorkPackagesListService { return this.save(query); } - public toggleStarred(query:QueryResource):Promise { + public toggleStarred(query:QueryResource):Promise { const promise = this .apiV3Service .queries diff --git a/frontend/src/app/features/work-packages/components/wp-list/wp-states-initialization.service.ts b/frontend/src/app/features/work-packages/components/wp-list/wp-states-initialization.service.ts index f72453dd1182..ee1dd6c8a333 100644 --- a/frontend/src/app/features/work-packages/components/wp-list/wp-states-initialization.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-list/wp-states-initialization.service.ts @@ -114,7 +114,7 @@ export class WorkPackageStatesInitializationService { * @param form */ public updateStatesFromForm(query:QueryResource, form:QueryFormResource) { - const schema:QuerySchemaResource = form.schema as any; + const schema = form.schema as QuerySchemaResource; schema.filtersSchemas.elements.forEach((schema) => { this.states.schemas.get(schema.href!).putValue(schema); From b14812d6e27907b4efb13ce17c1a6d99cf9e7d51 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 01:15:42 +0100 Subject: [PATCH 25/27] [OP-20347] Type changeset setValue overrides Types the setValue overrides of the work package and time entry changesets as accepting unknown values and casts the time entry payload to its link bag before dropping the project link. https://community.openproject.org/wp/OP-20347 --- .../components/wp-edit/work-package-changeset.ts | 2 +- .../helpers/time-entries/time-entry-changeset.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 310f02cf2869..081c701d3d9f 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 @@ -33,7 +33,7 @@ import { WorkPackageSchemaProxy } from 'core-app/features/hal/schemas/work-packa import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; export class WorkPackageChangeset extends ResourceChangeset { - public setValue(key:string, val:any) { + public setValue(key:string, val:unknown) { super.setValue(key, val); if (key === 'project' || key === 'type') { diff --git a/frontend/src/app/features/work-packages/helpers/time-entries/time-entry-changeset.ts b/frontend/src/app/features/work-packages/helpers/time-entries/time-entry-changeset.ts index a5afb61907cc..20c70e67f317 100644 --- a/frontend/src/app/features/work-packages/helpers/time-entries/time-entry-changeset.ts +++ b/frontend/src/app/features/work-packages/helpers/time-entries/time-entry-changeset.ts @@ -30,7 +30,7 @@ import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/r import { TimeEntryResource } from 'core-app/features/hal/resources/time-entry-resource'; export class TimeEntryChangeset extends ResourceChangeset { - public setValue(key:string, val:any) { + public setValue(key:string, val:unknown) { super.setValue(key, val); // Update the form for fields that may alter the form itself @@ -40,7 +40,7 @@ export class TimeEntryChangeset extends ResourceChangeset { } protected buildPayloadFromChanges() { - const payload = super.buildPayloadFromChanges(); + const payload = super.buildPayloadFromChanges() as { _links:Record }; // we ignore the project and instead rely completely on the work package. delete payload._links.project; From 952e15972990eb60c27d261ece808df3789c86f9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 01:15:42 +0100 Subject: [PATCH 26/27] [OP-20347] Type work package view routing Types the view handler token as the handler registry it provides, dynamic component definitions with unknown inputs, timeline label entries as nullable strings and the custom error hook's argument as an error resource. Hoists the focus anchor tab label into a local so its type is not inferred as any from the translate options. https://community.openproject.org/wp/OP-20347 --- .../partitioned-query-space-page.component.ts | 4 ++-- .../wp-view-base/event-handling/event-handler-registry.ts | 2 +- .../wp-view-base/view-services/wp-view-timeline.service.ts | 2 +- .../routing/wp-view-base/work-package-single-view.base.ts | 3 ++- .../notifications/work-package-notification.service.ts | 3 ++- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts index e9ad9b1086d4..31528fe3b017 100644 --- a/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts +++ b/frontend/src/app/features/work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.ts @@ -52,8 +52,8 @@ import { UrlParamsService } from 'core-app/core/navigation/url-params.service'; import { EventHandler } from 'ng-dynamic-component'; export interface DynamicComponentDefinition { - component:ComponentType; - inputs?:Record; + component:ComponentType; + inputs?:Record; outputs?:Record; } diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry.ts index bb3f70dcef6c..761ab58d86d8 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/event-handling/event-handler-registry.ts @@ -52,7 +52,7 @@ export interface WorkPackageViewOutputs { stateLinkClicked:EventEmitter<{ workPackageId:string, requestedState:string }>; } -export const WorkPackageViewHandlerToken = new InjectionToken>('CardEventHandler'); +export const WorkPackageViewHandlerToken = new InjectionToken>('CardEventHandler'); /** * Abstract view handler registry for globally handling arbitrary event on the diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-timeline.service.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-timeline.service.ts index d50efec1ff62..64232562a8af 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-timeline.service.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-timeline.service.ts @@ -105,7 +105,7 @@ export class WorkPackageViewTimelineService extends WorkPackageQueryStateService public getNormalizedLabels(workPackage:WorkPackageResource) { const labels:TimelineLabels = this.defaultLabels; - Object.entries(this.current.labels).forEach(([positionAsString, attribute]) => { + (Object.entries(this.current.labels) as [string, string|null][]).forEach(([positionAsString, attribute]) => { // RR: Lodash typings declare the position as string. However, it is save to cast // to `keyof TimelineLabels` because `this.current.labels` is of type TimelineLabels. const position:keyof TimelineLabels = positionAsString as keyof TimelineLabels; 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 91ee260ad83e..e8e79e71621f 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 @@ -229,8 +229,9 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { * Recompute the current tab focus label */ public updateFocusAnchorLabel(tabName:string):string { + const tab = this.i18n.t(`js.work_packages.tabs.${tabName}`); this.focusAnchorLabel = this.i18n.t('js.label_work_package_details_you_are_here', { - tab: this.i18n.t(`js.work_packages.tabs.${tabName}`), + tab, type: this.workPackage.type.name, subject: this.workPackage.subject, }); diff --git a/frontend/src/app/features/work-packages/services/notifications/work-package-notification.service.ts b/frontend/src/app/features/work-packages/services/notifications/work-package-notification.service.ts index 971f54b92934..c184c189a298 100644 --- a/frontend/src/app/features/work-packages/services/notifications/work-package-notification.service.ts +++ b/frontend/src/app/features/work-packages/services/notifications/work-package-notification.service.ts @@ -33,6 +33,7 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-packag import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; +import { ErrorResource } from 'core-app/features/hal/resources/error-resource'; @Injectable() export class WorkPackageNotificationService extends HalResourceNotificationService { @@ -48,7 +49,7 @@ export class WorkPackageNotificationService extends HalResourceNotificationServi this.ToastService.addSuccess(message); } - protected showCustomError(errorResource:any, resource:WorkPackageResource):boolean { + protected showCustomError(errorResource:ErrorResource, resource:WorkPackageResource):boolean { if (errorResource.errorIdentifier === 'urn:openproject-org:api:v3:errors:UpdateConflict') { // currently we do not have a programmatic way to show the primer flash messages // so we just do a request to the server to show it From ccf6f64898a95b12429a70a6e6d14393a89e3bb7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 29 Sep 2026 01:15:43 +0100 Subject: [PATCH 27/27] [OP-20347] Type group object values as strings Types the API group value as a nullable string. The embedded graph now stringifies it explicitly for its count map key, which is what the computed key did implicitly, and collects labels as nullable strings. https://community.openproject.org/wp/OP-20347 --- .../app/features/hal/resources/wp-collection-resource.ts | 2 +- .../embedded/wp-embedded-graph.component.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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 eab07b4dccea..d1d31884daab 100644 --- a/frontend/src/app/features/hal/resources/wp-collection-resource.ts +++ b/frontend/src/app/features/hal/resources/wp-collection-resource.ts @@ -61,7 +61,7 @@ export class WorkPackageCollectionResource extends CollectionResource { - const countMap = (dataset.groups || []).reduce((hash, group) => ({ + const countMap = (dataset.groups ?? []).reduce>((hash, group) => ({ ...hash, - [group.value]: group.count, + [String(group.value)]: group.count, }), {}); return { @@ -242,7 +242,7 @@ export class WorkPackageEmbeddedGraphComponent implements OnChanges { private setHeight() { if (this.chartType === 'horizontalBar' && this.datasets?.[0]) { - const labels:string[] = []; + const labels:(string|null)[] = []; this.datasets.forEach((d) => { d.groups!.forEach((g) => { if (!labels.includes(g.value)) { labels.push(g.value);