Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ describe('currentProject service', () => {
providers: [
CurrentProjectService,
PathHelperService,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ provide: ApiV3Service, useValue: apiV3Stub },
],
});
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/app/features/plugins/hook-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
});
46 changes: 40 additions & 6 deletions frontend/src/app/features/plugins/hook-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ResourceChangeset>) => ResourceChangeset;

export interface HookSignatures {
attributeGroupComponent:(group:GroupDescriptor, workPackage:WorkPackageResource) => Type<unknown>|null;
gridWidgets:() => WidgetRegistration[];
halResourceChangesetClass:(resource:HalResource) => ResourceChangesetClass|null;
prependedAttributeGroups:(workPackage:WorkPackageResource) => Type<unknown>|undefined;
workPackageAttachmentListComponent:(workPackage:WorkPackageResource) => Type<unknown>;
workPackageAttachmentUploadComponent:(workPackage:WorkPackageResource) => Type<unknown>;
workPackageBulkContextMenu:() => WorkPackageAction;
workPackageNewInitialization:(change:WorkPackageChangeset) => void;
workPackageSingleContextMenu:() => WorkPackageAction;
workPackageTableContextMenu:() => WorkPackageAction;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating this list would now be up to the registrees of the Hook service, wouldn't it?

In my understanding, this pattern where modules register themselves is an attempt to decouple the specific implementations from being depended on by the central registry. In the past, I had the impression that it hides more than it shows. Since we already have the specific types here, maybe we can also remove the registering and just register them all explicitly when this module loads.

Or, if that is not a feasible refactoring, instead of having all the signatures here, every module that registers itself would also extend the HookSignatures interface? Or does this run into other problems? I've read that the core difference between type A = {} and interface A {} is that the interface is open and can be extended, but I'm not sure I've seen that in action. So this might not be possible 😅.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating this list would now be up to the registrees of the Hook service, wouldn't it?

Not quite. The contract lives with the caller.

Adding a new hook.call(...) will mean adding its signature here, Plugins registering callbacks will never touch this list. Unknown hook ids will still fall through to the untyped overload, so nothing breaks.

Declaration merging would work (declare module 'core-app/features/plugins/hook-service' { interface HookSignatures { ... } } next to each call site) and would drop the imports here. But it scatters the hooks back across the codebase, and I think the central list is what makes the indirection visible, which is your concern.

Replacing self-registration with explicit wiring is worth doing, but it's out of scope for a typing PR IMO: two plugins register through pluginContext.hooks (untyped, outside this interface), and four hooks (gridWidgets, workPackageAttachmentUploadComponent, workPackageBulkContextMenu, workPackageNewInitialization) have no in-repo registrant at all. Removing the dead ones would be part of that decision. I'd prefer to tackle it in a follow-up PR, if that works for you?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've created OP-20381 to follow up on this. Feedback appreciated! 🙏🏻


type HookCallback = (...params:never[]) => unknown;

type CustomHookId<K extends string> = K extends keyof HookSignatures ? never : K;

@Injectable({
providedIn: 'root',
})
export class HookService {
private hooks:Record<string, Function[]> = {};
private hooks:Record<string, HookCallback[]> = {};

public register(id:string, callback:Function) {
public register<K extends keyof HookSignatures>(id:K, callback:HookSignatures[K]):void;
public register<K extends string>(id:CustomHookId<K>, callback:HookCallback):void;
public register(id:string, callback:HookCallback) {
if (!callback) {
return;
}
Expand All @@ -46,12 +74,18 @@ export class HookService {
this.hooks[id].push(callback);
}

public call(id:string, ...params:any[]):any[] {
public call<K extends keyof HookSignatures>(
id:K,
...params:Parameters<HookSignatures[K]>
):NonNullable<ReturnType<HookSignatures[K]>>[];

public call<K extends string>(id:CustomHookId<K>, ...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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ describe('UrlParamsHelper', () => {
TestBed.configureTestingModule({
providers: [
UrlParamsHelperService,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ provide: PaginationService, useValue: paginationStub },
],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
],
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
inputs?:Record<string, any>;
outputs?:Record<string, Function>;
outputs?:Record<string, EventHandler>;
}

export interface ToolbarButtonComponentDefinition extends DynamicComponentDefinition {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ describe('WorkPackageViewOrderService', () => {
id: '123',
_links: { self: { href: 'test' } },
} as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-argument
querySpace.query.putValue(mockQuery as any);
});

Expand All @@ -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 });
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -66,7 +67,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn

public appendTo:any = null;

public referenceOutputs:Record<string, Function> = {
public referenceOutputs:Record<string, EventHandler> = {
onCreate: (newElement:HalResource) => this.onCreate(newElement),
onChange: (value:HalResource) => this.onChange(value),
onAddNew: (value:HalResource) => this.onNewValueAdded(value),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
5 changes: 0 additions & 5 deletions frontend/src/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,21 @@ 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();

// 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}`);
}

// jsdom does not implement ResizeObserver. The shim declares the native
// 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) {}
Expand All @@ -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; };
Expand Down
1 change: 0 additions & 1 deletion frontend/src/turbo/turbo-navigation-patch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
30 changes: 0 additions & 30 deletions frontend/src/typings/open-project.typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
}
}
6 changes: 3 additions & 3 deletions frontend/src/typings/shims.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> };
}
}

Expand Down
Loading