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/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-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/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/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', 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/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/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), 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/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/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'; 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[]; - } -} 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 }; } }