Skip to content

[OP-20344] Frontend typings: HAL resources - #25660

Open
myabc wants to merge 9 commits into
implementation/op-20342-eslint-typing-fix-foundationsfrom
implementation/op-20344-eslint-typing-fix-hal-resources
Open

myabc wants to merge 9 commits into
implementation/op-20342-eslint-typing-fix-foundationsfrom
implementation/op-20344-eslint-typing-fix-hal-resources

Conversation

@myabc

@myabc myabc commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Ticket

https://community.openproject.org/wp/OP-20344

What are you trying to accomplish?

Part of OP-20341: types the HAL resource layer, the main source of any in the frontend, without changing behaviour. The any index signature on HalResource itself stays; OP-20349 replaces it on top of this.

  • Types HAL links as callable links (generic $fetch), $source as HalSource, $embedded, and the lazy accessor.
  • Types schemas: the changeset schema is the schema proxy it really is, while FormResource#schema stays a raw form schema. IFieldSchema now builds on IOPFieldSchema.
  • Declares resource attributes and link callables that were read without declarations.
  • Types the base link map as a callable link, an array of links or absent (with self always present), marks nullable embedded links (assignee, responsible, category, version) and the optional star/unstar links.
  • Types HAL services and error resources, and drops casts the new types make redundant.
  • Removes the unused WorkPackageResource#updateLinkedResources, which referenced an undeclared service.

Application-code typing violations drop from 1,174 to 664; features/hal goes from 364 to 6.

What approach did you choose and why?

Changes are types and boundary casts only; no runtime narrowing was added. Resource link and embedded interfaces are intersected with the base maps rather than extending them, so they don't add a second index signature before the index flip. About 17 files outside features/hal needed small compile fixes for the stricter types.

A few behaviour-neutral edits: now-typed floating $load()/refresh() promises are marked with void, and SchemaProxy binds proxied methods by name through its existing proxyMethod, so subclass overrides still apply.

The six remaining features/hal violations exist only because of the index signature, or belong to the work package views (GroupObject.value).

Error handlers that assert HalError#resource on network failures are tracked in OP-20361.

Stacked on #25657, which types the hook and global declarations this builds on.

AI involvement

Directed – I specified the requirements and AI implemented most of it; I validated via testing rather than a full line-by-line review.

Merge checklist

  • Added/updated tests
  • Added/updated documentation in Lookbook (patterns, previews, etc)
  • Tested major browsers (Chrome, Firefox, Edge, ...)

Browser JavaScript diff (19 files, type-only changes omitted)

Each changed browser file (TypeScript and templates, excluding specs, test helpers and declaration files) is transpiled on its own with frontend/tsconfig.json, before and after; this is the emitted JavaScript that differs.

diff --git frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.js
index 7a78a0f..7609749 100644
--- frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.js
+++ frontend/src/app/features/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.js
@@ -109,16 +109,19 @@ let BcfWpAttributeGroupComponent = class BcfWpAttributeGroupComponent extends Un
             .id(this.workPackage)
             .requireAndStream()
             .pipe(this.untilDestroyed())
-            .subscribe(async (wp) => {
-            this.workPackage = wp;
-            if (!this.projectId) {
-                await this.initialize(this.workPackage);
-            }
-            if (wp.bcfViewpoints) {
-                this.refreshViewpoints(wp.bcfViewpoints);
-            }
+            .subscribe((wp) => {
+            void this.onWorkPackageChange(wp);
         });
     }
+    async onWorkPackageChange(wp) {
+        this.workPackage = wp;
+        if (!this.projectId) {
+            await this.initialize(this.workPackage);
+        }
+        if (wp.bcfViewpoints) {
+            this.refreshViewpoints(wp.bcfViewpoints);
+        }
+    }
     async initialize(workPackage) {
         this.projectId = idFromLink(workPackage.project.href);
         this.viewAllowed = await this.bcfAuthorization.isAllowedTo(this.projectId, 'project_actions', 'viewTopic');
diff --git frontend/src/app/features/hal/hal-link/hal-link.js
index 37dbcb6..a525a2a 100644
--- frontend/src/app/features/hal/hal-link/hal-link.js
+++ frontend/src/app/features/hal/hal-link/hal-link.js
@@ -14,8 +14,7 @@ export class HalLink {
     static fromObject(halResourceService, link) {
         return new HalLink((method, href, data, headers) => firstValueFrom(halResourceService.request(method, href, data, headers)), link.href, link.title, link.method, link.templated, link.payload, link.type, link.identifier, link.displayId);
     }
-    $fetch(...params) {
-        const [data, headers] = params;
+    $fetch(data, headers) {
         return this.requestMethod(this.method, this.href, data, headers);
     }
     $prepare(templateValues) {
@@ -30,8 +29,8 @@ export class HalLink {
         return new HalLink(this.requestMethod, href, this.title, this.method, false, this.payload, this.type, this.identifier, this.displayId).$callable();
     }
     $callable() {
-        const linkFunc = (...params) => this.$fetch(...params);
-        Object.assign(linkFunc, {
+        const linkFunc = (data, headers) => this.$fetch(data, headers);
+        return Object.assign(linkFunc, {
             $link: this,
             href: this.href,
             title: this.title,
@@ -42,6 +41,5 @@ export class HalLink {
             identifier: this.identifier,
             displayId: this.displayId,
         });
-        return linkFunc;
     }
 }
diff --git frontend/src/app/features/hal/helpers/hal-resource-builder.js
index f631dcd..28c3c6c 100644
--- frontend/src/app/features/hal/helpers/hal-resource-builder.js
+++ frontend/src/app/features/hal/helpers/hal-resource-builder.js
@@ -20,6 +20,12 @@ export function initializeHalProperties(halResourceService, halResource) {
     proxyProperties();
     setLinksAsProperties();
     setEmbeddedAsProperties();
+    function sourceLinks() {
+        return halResource.$source._links;
+    }
+    function sourceEmbedded() {
+        return halResource.$source._embedded;
+    }
     function setSource() {
         if (!halResource.$source._links) {
             halResource.$source._links = {};
@@ -55,7 +61,8 @@ export function initializeHalProperties(halResourceService, halResource) {
     function setLinksAsProperties() {
         halResource.$linkableKeys().forEach((linkName) => {
             OpenprojectHalModuleHelpers.lazy(halResource, linkName, () => {
-                const link = halResource.$links[linkName].$link || halResource.$links[linkName];
+                const entry = halResource.$links[linkName];
+                const link = entry.$link || entry;
                 if (Array.isArray(link)) {
                     const items = link.map((item) => halResourceService.createLinkedResource(halResource, linkName, item.$link));
                     const property = new ObservableArray(...items).on('change', () => {
@@ -64,7 +71,7 @@ export function initializeHalProperties(halResourceService, halResource) {
                                 property.splice(property.indexOf(item), 1);
                             }
                         });
-                        halResource.$source._links[linkName] = property.map((item) => item.$link);
+                        sourceLinks()[linkName] = property.map((item) => item.$link);
                     });
                     return property;
                 }
@@ -79,25 +86,24 @@ export function initializeHalProperties(halResourceService, halResource) {
         });
     }
     function setEmbeddedAsProperties() {
-        if (!halResource.$source._embedded) {
+        const embedded = sourceEmbedded();
+        if (!embedded) {
             return;
         }
-        Object.keys(halResource.$source._embedded).forEach((name) => {
+        Object.keys(embedded).forEach((name) => {
             OpenprojectHalModuleHelpers.lazy(halResource, name, () => halResource.$embedded[name], (val) => setter(val, name));
         });
     }
-    function setupProperty(name, callback) {
-        const instanceName = `$${name}`;
-        const sourceName = `_${name}`;
+    function setupProperty(sourceName, target, callback) {
         const sourceObj = halResource.$source[sourceName];
         if (typeof sourceObj === 'object' && sourceObj !== null) {
             Object.keys(sourceObj).forEach((propName) => {
-                OpenprojectHalModuleHelpers.lazy((halResource)[instanceName], propName, () => callback(sourceObj[propName]));
+                OpenprojectHalModuleHelpers.lazy(target, propName, () => callback(sourceObj[propName]));
             });
         }
     }
     function setupLinks() {
-        setupProperty('links', (link) => {
+        setupProperty('_links', halResource.$links, (link) => {
             if (Array.isArray(link)) {
                 return link.map((l) => HalLink.fromObject(halResourceService, l).$callable());
             }
@@ -105,7 +111,7 @@ export function initializeHalProperties(halResourceService, halResource) {
         });
     }
     function setupEmbedded() {
-        setupProperty('embedded', (element) => {
+        setupProperty('_embedded', halResource.$embedded, (element) => {
             if (Array.isArray(element)) {
                 return element.map((source) => asHalResource(source, true));
             }
@@ -122,28 +128,29 @@ export function initializeHalProperties(halResourceService, halResource) {
     function setter(val, linkName) {
         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) => ({ href: el.href }));
+            sourceLinks()[linkName] = val.map((el) => ({ href: el.href }));
         }
         else if (Object.hasOwn(val, '$link')) {
             const link = val.$link;
             if (link.href) {
-                halResource.$source._links[linkName] = link;
+                sourceLinks()[linkName] = link;
             }
         }
         else if ('href' in val) {
-            halResource.$source._links[linkName] = { href: val.href };
+            sourceLinks()[linkName] = { href: val.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.map((el) => el.$source);
             }
             else {
                 const source = val?.$source;
-                halResource.$source._embedded[linkName] = source === undefined ? val : source;
+                embedded[linkName] = source === undefined ? val : source;
             }
         }
         return val;
diff --git frontend/src/app/features/hal/resources/error-resource.js
index 13febc7..915d23c 100644
--- frontend/src/app/features/hal/resources/error-resource.js
+++ frontend/src/app/features/hal/resources/error-resource.js
@@ -48,7 +48,7 @@ export class ErrorResource extends HalResource {
             this.errors?.forEach((error) => {
                 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]) {
diff --git frontend/src/app/features/hal/resources/hal-resource.js
index 460b364..31c1935 100644
--- frontend/src/app/features/hal/resources/hal-resource.js
+++ frontend/src/app/features/hal/resources/hal-resource.js
@@ -10,11 +10,11 @@ import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
 export class HalResource {
     constructor(injector, $source, $loaded, halInitializer, $halType) {
         this.injector = injector;
-        this.$source = $source;
         this.$loaded = $loaded;
         this.halInitializer = halInitializer;
         this.$links = {};
         this.$embedded = {};
+        this.$source = $source;
         this.$halType = $halType;
         this.$initialize($source);
     }
@@ -26,7 +26,13 @@ export class HalResource {
         return match?.[1] ?? null;
     }
     $initialize(source) {
-        this.$source = source.$source || source;
+        const wrapped = source.$source;
+        if (wrapped) {
+            this.$source = wrapped;
+        }
+        else {
+            this.$source = source;
+        }
         this.halInitializer(this);
     }
     toString() {
diff --git frontend/src/app/features/hal/resources/query-filter-instance-resource.js
index 0868064..101b656 100644
--- frontend/src/app/features/hal/resources/query-filter-instance-resource.js
+++ frontend/src/app/features/hal/resources/query-filter-instance-resource.js
@@ -32,7 +32,7 @@ export class QueryFilterInstanceResource extends HalResource {
                 this.memoizedCurrentSchemas[key] = this.schemaCache.of(this).resultingSchema(this.operator);
             }
             catch (e) {
-                console.error(`Failed to access filter schema${e}`);
+                console.error(`Failed to access filter schema${String(e)}`);
             }
         }
         return this.memoizedCurrentSchemas[key];
diff --git frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.js
index 56cc12d..73b90e6 100644
--- frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.js
+++ frontend/src/app/features/hal/resources/query-filter-instance-schema-resource.js
@@ -19,8 +19,9 @@ export class QueryFilterInstanceSchemaResource extends SchemaResource {
     }
     $initialize(source) {
         super.$initialize(source);
-        if (source._dependencies) {
-            this.dependency = new SchemaDependencyResource(this.injector, source._dependencies[0], true, this.halInitializer, 'SchemaDependency');
+        const { _dependencies } = source;
+        if (_dependencies) {
+            this.dependency = new SchemaDependencyResource(this.injector, _dependencies[0], true, this.halInitializer, 'SchemaDependency');
         }
     }
     getFilter() {
diff --git frontend/src/app/features/hal/resources/work-package-resource.js
index 368d8b4..6af0076 100644
--- frontend/src/app/features/hal/resources/work-package-resource.js
+++ frontend/src/app/features/hal/resources/work-package-resource.js
@@ -68,18 +68,6 @@ export class WorkPackageBaseResource extends HalResource {
     isParentOf(otherWorkPackage) {
         return otherWorkPackage.parent?.$links.self.$link.href === this.$links.self.$link.href;
     }
-    updateLinkedResources(...resourceNames) {
-        const resources = {};
-        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;
-    }
     $initialize(source) {
         super.$initialize(source);
         const attachments = this.attachments || { $source: {}, elements: [] };
@@ -94,7 +82,7 @@ export class WorkPackageBaseResource extends HalResource {
     push(newValue) {
         this.wpActivity.clear(newValue.id);
         if (newValue.parent) {
-            this.apiV3Service.work_packages.id(newValue.parent).refresh();
+            void this.apiV3Service.work_packages.id(newValue.parent).refresh();
         }
         return this.apiV3Service.work_packages.cache.updateWorkPackage(newValue);
     }
diff --git frontend/src/app/features/hal/schemas/hal-payload.helper.js
index 20d2628..747f49a 100644
--- frontend/src/app/features/hal/schemas/hal-payload.helper.js
+++ frontend/src/app/features/hal/schemas/hal-payload.helper.js
@@ -15,14 +15,14 @@ export class HalPayloadHelper {
         };
         const nonLinkProperties = [];
         for (const key in schema) {
-            if (schema.hasOwnProperty(key) && schema[key]?.writable) {
+            if (Object.hasOwn(schema, key) && schema[key]?.writable) {
                 if (resource.$links[key]) {
                     if (Array.isArray(resource[key])) {
                         payload._links[key] = resource[key].map((element) => ({ href: element.href }));
                     }
                     else {
                         payload._links[key] = {
-                            href: (resource[key]?.href),
+                            href: resource[key]?.href,
                         };
                     }
                 }
diff --git frontend/src/app/features/hal/schemas/schema-proxy.js
index 7fb1e2b..86a7f2c 100644
--- frontend/src/app/features/hal/schemas/schema-proxy.js
+++ frontend/src/app/features/hal/schemas/schema-proxy.js
@@ -9,14 +9,10 @@ export class SchemaProxy {
     }
     get(schema, property, receiver) {
         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;
@@ -43,12 +39,7 @@ export class SchemaProxy {
     mappedName(property) {
         return property;
     }
-    proxyMethod(method) {
-        const self = this;
-        return new Proxy(method, {
-            apply(_, __, argumentsList) {
-                return method.apply(self, [argumentsList[0]]);
-            },
-        });
+    proxyMethod(name) {
+        return (property) => this[name](property);
     }
 }
diff --git frontend/src/app/features/hal/services/hal-resource.service.js
index d50c5cf..09df659 100644
--- frontend/src/app/features/hal/services/hal-resource.service.js
+++ frontend/src/app/features/hal/services/hal-resource.service.js
@@ -72,9 +72,9 @@ let HalResourceService = class HalResourceService {
         return defaultCls;
     }
     createHalResource(source, loaded = true) {
-        source ??= HalResource.getEmptyResource();
-        const type = source._type || 'HalResource';
-        return this.createHalResourceOfType(type, source, loaded);
+        const halSource = (source ?? HalResource.getEmptyResource());
+        const type = halSource._type || 'HalResource';
+        return this.createHalResourceOfType(type, halSource, loaded);
     }
     createHalResourceOfType(type, source, loaded = false) {
         const resourceClass = this.getResourceClassOfType(type);
@@ -104,8 +104,11 @@ let HalResourceService = class HalResourceService {
         return this.createHalResourceOfType(toType, source, false);
     }
     getResourceClassOfType(type) {
-        const config = this.config[type];
-        return (config?.cls) ? config.cls : this.defaultClass;
+        const cls = this.config[type]?.cls;
+        if (cls) {
+            return cls;
+        }
+        return this.defaultClass;
     }
     getResourceClassOfAttribute(type, attribute) {
         const typeConfig = this.config[type];
diff --git frontend/src/app/features/work-packages/components/wp-buttons/wp-status-button/wp-status-button.component.js
index cffa95a..3d214fb 100644
--- frontend/src/app/features/work-packages/components/wp-buttons/wp-status-button/wp-status-button.component.js
+++ frontend/src/app/features/work-packages/components/wp-buttons/wp-status-button/wp-status-button.component.js
@@ -29,7 +29,7 @@ let WorkPackageStatusButtonComponent = class WorkPackageStatusButtonComponent ex
             .subscribe((wp) => {
             this.workPackage = wp;
             if (this.workPackage.status) {
-                this.workPackage.status.$load();
+                void this.workPackage.status.$load();
             }
             this.cdRef.detectChanges();
         });
diff --git frontend/src/app/features/work-packages/components/wp-new/wp-create.service.js
index 2afd7dc..fc11569 100644
--- frontend/src/app/features/work-packages/components/wp-new/wp-create.service.js
+++ frontend/src/app/features/work-packages/components/wp-new/wp-create.service.js
@@ -235,8 +235,8 @@ let WorkPackageCreateService = class WorkPackageCreateService extends UntilDestr
         wp._type = 'WorkPackage';
         wp.__initialized_at = Date.now();
         wp.update = wp.$links.update = form.$links.self;
-        wp.updateImmediately = (data) => firstValueFrom(this.apiV3Service.work_packages.post(data));
-        wp.$links.updateImmediately = (data) => firstValueFrom(this.apiV3Service.work_packages.post(data));
+        wp.updateImmediately = ((data) => firstValueFrom(this.apiV3Service.work_packages.post(data)));
+        wp.$links.updateImmediately = ((data) => firstValueFrom(this.apiV3Service.work_packages.post(data)));
         if (form.schema.$links.attachments) {
             wp.$links.attachments = { elements: [] };
         }
diff --git frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.js
index 476175a..3a75716 100644
--- frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.js
+++ frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.js
@@ -6,7 +6,7 @@ export function workPackageFilesCount(workPackage, injector) {
     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.href ?? '')
         : of([]);
     const totalFileLinks = workPackage.$links.fileLinks
         ? http.get(href(workPackage))
diff --git frontend/src/app/shared/components/fields/changeset/resource-changeset.js
index 3b2c816..10a9033 100644
--- frontend/src/app/shared/components/fields/changeset/resource-changeset.js
+++ frontend/src/app/shared/components/fields/changeset/resource-changeset.js
@@ -55,12 +55,11 @@ export class ResourceChangeset {
     }
     updateForm() {
         const payload = this.buildPayloadFromChanges();
-        if (!this.pristineResource.$links.update) {
+        const update = this.pristineResource.$links.update;
+        if (!update) {
             return Promise.reject();
         }
-        const promise = this.pristineResource
-            .$links
-            .update(payload)
+        const promise = update(payload)
             .then((form) => {
             this.cache = {};
             this.form$.putValue(form);
diff --git frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.js
index f182202..0519139 100644
--- frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.js
+++ frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.js
@@ -1,8 +1,7 @@
 import { from } from 'rxjs';
 import { map } from 'rxjs/operators';
 export function renderHierarchyItem(item, multiple = false) {
-    const customFieldItemLinks = item.$links;
-    return from(customFieldItemLinks.branch())
+    return from(item.$links.branch())
         .pipe(map((ancestors) => spansFromAncestors(ancestors)), map((spans) => {
         const span = document.createElement('span');
         span.classList.add('path');
diff --git frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.js
index ed5b70a..6e5e355 100644
--- frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.js
+++ frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-single-context-menu.js
@@ -45,7 +45,7 @@ let WorkPackageSingleContextMenuDirective = class WorkPackageSingleContextMenuDi
         document.removeEventListener('dialog:close', this.closeDialogHandler);
     }
     open(evt) {
-        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);
             const permittedActions = this.getPermittedActions(authorization);
diff --git modules/budgets/frontend/module/hal/resources/budget-resource.js
index c8663c3..2f64002 100644
--- modules/budgets/frontend/module/hal/resources/budget-resource.js
+++ modules/budgets/frontend/module/hal/resources/budget-resource.js
@@ -1,4 +1,4 @@
-import { HalResource } from "core-app/features/hal/resources/hal-resource";
+import { HalResource } from 'core-app/features/hal/resources/hal-resource';
 import { Attachable } from "core-app/features/hal/resources/mixins/attachable-mixin";
 class BudgetBaseResource extends HalResource {
 }
diff --git modules/documents/frontend/module/hal/resources/document-resource.js
index 8bf57c6..2f6bece 100644
--- modules/documents/frontend/module/hal/resources/document-resource.js
+++ modules/documents/frontend/module/hal/resources/document-resource.js
@@ -1,4 +1,4 @@
-import { HalResource } from "core-app/features/hal/resources/hal-resource";
+import { HalResource } from 'core-app/features/hal/resources/hal-resource';
 import { Attachable } from "core-app/features/hal/resources/mixins/attachable-mixin";
 class DocumentBaseResource extends HalResource {
     constructor() {

@myabc
myabc added this pull request to stack #25658 September 28, 2026 22:15
@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./spec/features/roles/report_spec.rb[1:1]
  • rspec ./spec/features/roles/report_spec.rb[1:2]
🤖 Ask Copilot to investigate

Copy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer.

@copilot The following spec(s) are flaky in CI (first seen on PR #25660, linked for reference only):

- `rspec ./spec/features/roles/report_spec.rb[1:1]`
- `rspec ./spec/features/roles/report_spec.rb[1:2]`

Treat this as a standalone task, unrelated to PR #25660. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #25660 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @myabc to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @myabc, and request a review from @myabc.
On every commit, set @myabc as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@myabc
myabc requested a balanced review from Copilot September 29, 2026 08:15
@myabc
myabc marked this pull request as ready for review September 29, 2026 08:16

Copilot AI left a comment

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.

Copilot review overview

🟡 Changes recommended

Several new contracts incorrectly exclude valid absent, array-valued, or null HAL states, and nullable transport errors can still crash error handling.

Review effort: Balanced
Findings: 5 Medium severity

Open (5)
What changed in this PR

Types the frontend HAL resource layer, building on #25657 to reduce unsafe any usage without intended runtime changes.

Changes:

  • Adds typed HAL links, sources, embedded resources, schemas, services, and errors.
  • Declares concrete resource attributes and removes obsolete casts and suppressions.
  • Updates dependent frontend code and tests for stricter HAL types.
File Description
modules/​documents/​frontend/​module/​hal/​resources/​document-resource.ts Types attachment links.
modules/​budgets/​frontend/​module/​hal/​resources/​budget-resource.ts Types attachment links.
frontend/​src/​typings/​open-project.typings.d.ts Removes obsolete function link typing.
frontend/​src/​app/​shared/​components/​storages/​storage/​storage.component.ts Uses typed file links.
frontend/​src/​app/​shared/​components/​op-context-menu/​wp-context-menu/​wp-single-context-menu.ts Uses typed links and promises.
frontend/​src/​app/​shared/​components/​op-context-menu/​handlers/​wp-status-dropdown-menu.directive.ts Types status updates and errors.
frontend/​src/​app/​shared/​components/​op-context-menu/​handlers/​wp-create-settings-menu.directive.ts Types configuration links.
frontend/​src/​app/​shared/​components/​op-context-menu/​handlers/​op-settings-dropdown-menu.directive.ts Types custom-field links.
frontend/​src/​app/​shared/​components/​grids/​widgets/​custom-text/​custom-text-edit-field.service.ts Removes redundant attachment cast.
frontend/​src/​app/​shared/​components/​fields/​field.base.ts Builds field schemas on HAL schemas.
frontend/​src/​app/​shared/​components/​fields/​edit/​services/​hal-resource-editing.service.ts Types saved resources.
frontend/​src/​app/​shared/​components/​fields/​edit/​field-types/​work-package-edit-field.component.ts Types allowed-value links.
frontend/​src/​app/​shared/​components/​fields/​edit/​field-types/​select-edit-field/​select-edit-field.component.ts Types allowed-value loading.
frontend/​src/​app/​shared/​components/​fields/​edit/​field-types/​project-edit-field.component.ts Types project-value links.
frontend/​src/​app/​shared/​components/​fields/​edit/​field-types/​multi-select-edit-field.component.ts Narrows allowed-value resources.
frontend/​src/​app/​shared/​components/​fields/​edit/​field-types/​formattable-edit-field/​formattable-edit-field.component.ts Uses typed schema options.
frontend/​src/​app/​shared/​components/​fields/​edit/​edit-form/​edit-form.ts Uses typed schema proxies.
frontend/​src/​app/​shared/​components/​fields/​display/​field-types/​render-hierarchy-item.ts Types hierarchy loading.
frontend/​src/​app/​shared/​components/​fields/​display/​field-types/​excluded-icon-helper.service.ts Uses typed status resources.
frontend/​src/​app/​shared/​components/​fields/​changeset/​resource-changeset.ts Types forms, schemas, and payloads.
frontend/​src/​app/​features/​work-packages/​routing/​wp-view-base/​work-package-single-view.base.ts Uses typed project links.
frontend/​src/​app/​features/​work-packages/​components/​wp-single-view/​wp-single-view.component.ts Removes project casts.
frontend/​src/​app/​features/​work-packages/​components/​wp-single-view-tabs/​files-tab/​op-files-tab.component.ts Uses typed projects.
frontend/​src/​app/​features/​work-packages/​components/​wp-relations/​embedded/​relations/​wp-relation-inline-create.service.ts Removes unsafe project suppression.
frontend/​src/​app/​features/​work-packages/​components/​wp-relations/​embedded/​children/​wp-children-inline-create.service.ts Removes unsafe project suppression.
frontend/​src/​app/​features/​work-packages/​components/​wp-new/​wp-create.service.ts Types synthetic creation links.
frontend/​src/​app/​features/​work-packages/​components/​wp-list/​wp-list.service.ts Uses typed query update links.
frontend/​src/​app/​features/​work-packages/​components/​wp-edit/​work-package-changeset.ts Types schema proxy and description.
frontend/​src/​app/​features/​work-packages/​components/​wp-edit-form/​work-package-filter-values.spec.ts Updates resource test casts.
frontend/​src/​app/​features/​work-packages/​components/​wp-details/​wp-details-toolbar.component.ts Uses typed project IDs.
frontend/​src/​app/​features/​work-packages/​components/​wp-card-view/​wp-single-card/​wp-single-card.component.ts Uses typed status names.
frontend/​src/​app/​features/​work-packages/​components/​wp-buttons/​wp-status-button/​wp-status-button.component.ts Marks floating load promise.
frontend/​src/​app/​features/​team-planner/​team-planner/​planner/​team-planner.component.ts Removes HAL relationship casts.
frontend/​src/​app/​features/​hal/​services/​hal-resource.service.ts Types resource construction and requests.
frontend/​src/​app/​features/​hal/​services/​hal-resource-notification.service.ts Types error and toast handling.
frontend/​src/​app/​features/​hal/​schemas/​work-package-schema-proxy.ts Narrows proxy handler types.
frontend/​src/​app/​features/​hal/​schemas/​schema-proxy.ts Types proxy dispatch and binding.
frontend/​src/​app/​features/​hal/​schemas/​hal-payload.helper.ts Types schema-driven payloads.
frontend/​src/​app/​features/​hal/​resources/​wp-collection-resource.ts Declares collection links and fields.
frontend/​src/​app/​features/​hal/​resources/​work-package-timestamp-resource.ts Reuses base link typing.
frontend/​src/​app/​features/​hal/​resources/​work-package-resource.ts Types work-package relationships and links.
frontend/​src/​app/​features/​hal/​resources/​work-package-resource.spec.ts Updates callable-link fixture.
frontend/​src/​app/​features/​hal/​resources/​wiki-page-resource.ts Types attachment links.
frontend/​src/​app/​features/​hal/​resources/​user-resource.ts Narrows cached state.
frontend/​src/​app/​features/​hal/​resources/​type-resource.ts Narrows cached state.
frontend/​src/​app/​features/​hal/​resources/​time-entry-resource.ts Declares fields and delete link.
frontend/​src/​app/​features/​hal/​resources/​status-resource.ts Narrows cached state.
frontend/​src/​app/​features/​hal/​resources/​share-resource.ts Declares embedded share fields.
frontend/​src/​app/​features/​hal/​resources/​schema-resource.ts Types schema attributes and state.
frontend/​src/​app/​features/​hal/​resources/​schema-dependency-resource.ts Types dependency maps.
frontend/​src/​app/​features/​hal/​resources/​relation-resource.ts Types relation operations and fields.
frontend/​src/​app/​features/​hal/​resources/​query-sort-by-resource.ts Types embedded query sorting.
frontend/​src/​app/​features/​hal/​resources/​query-resource.ts Declares query operation links.
frontend/​src/​app/​features/​hal/​resources/​query-operator-resource.ts Narrows source ID.
frontend/​src/​app/​features/​hal/​resources/​query-form-resource.ts Types nested query schemas.
frontend/​src/​app/​features/​hal/​resources/​query-filter-resource.ts Types filter values and IDs.
frontend/​src/​app/​features/​hal/​resources/​query-filter-instance-schema-resource.ts Types filter schema links and sources.
frontend/​src/​app/​features/​hal/​resources/​query-filter-instance-resource.ts Types dynamic schema access.
frontend/​src/​app/​features/​hal/​resources/​project-resource.ts Types project state.
frontend/​src/​app/​features/​hal/​resources/​post-resource.ts Types attachment links.
frontend/​src/​app/​features/​hal/​resources/​placeholder-user-resource.ts Narrows cached state.
frontend/​src/​app/​features/​hal/​resources/​mixins/​attachable-mixin.ts Uses base attachment-link typing.
frontend/​src/​app/​features/​hal/​resources/​membership-resource.ts Declares membership fields and links.
frontend/​src/​app/​features/​hal/​resources/​meeting-resource.ts Types attachment links.
frontend/​src/​app/​features/​hal/​resources/​hal-resource.ts Defines typed HAL source maps.
frontend/​src/​app/​features/​hal/​resources/​hal-resource.spec.ts Updates tests for stricter maps.
frontend/​src/​app/​features/​hal/​resources/​grid-resource.ts Types grid links and attachments.
frontend/​src/​app/​features/​hal/​resources/​form-resource.ts Types form schemas and payloads.
frontend/​src/​app/​features/​hal/​resources/​error-resource.ts Types API error details.
frontend/​src/​app/​features/​hal/​resources/​custom-action-resource.ts Types custom-action operations.
frontend/​src/​app/​features/​hal/​resources/​attachment-collection-resource.ts Declares attachment elements.
frontend/​src/​app/​features/​hal/​resources/​activity-comment-resource.ts Types embedded data and links.
frontend/​src/​app/​features/​hal/​http/​openproject-header-interceptor.ts Replaces HTTP event any.
frontend/​src/​app/​features/​hal/​http/​http.interfaces.ts Narrows HTTP options.
frontend/​src/​app/​features/​hal/​helpers/​lazy-accessor.ts Makes lazy accessors generic.
frontend/​src/​app/​features/​hal/​helpers/​hal-resource-builder.ts Types HAL transformation internals.
frontend/​src/​app/​features/​hal/​hal-link/​hal-link.ts Adds generic callable links.
frontend/​src/​app/​features/​bim/​ifc_models/​ifc-viewer/​ifc-viewer.service.ts Removes project cast.
frontend/​src/​app/​features/​bim/​bcf/​helper/​viewpoints.service.ts Uses typed work-package links.
frontend/​src/​app/​features/​bim/​bcf/​bcf-wp-attribute-group/​bcf-wp-attribute-group.component.ts Adapts viewpoint typing.
frontend/​src/​app/​core/​state/​attachments/​attachments.service.ts Uses base HAL links.
frontend/​src/​app/​core/​schemas/​schema-cache.service.ts Uses typed schema links.
frontend/​src/​app/​core/​path-helper/​apiv3-paths.ts Uses typed project IDs.
frontend/​src/​app/​core/​global_search/​input/​global-search-input.component.spec.ts Types test HAL sources.
frontend/​src/​app/​core/​apiv3/​virtual/​apiv3-boards-paths.ts Narrows grid form payload.
frontend/​src/​app/​core/​apiv3/​cache/​cachable-apiv3-resource.ts Narrows schema-link href.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread frontend/src/app/features/hal/resources/hal-resource.ts Outdated
Comment thread frontend/src/app/features/hal/resources/query-resource.ts Outdated
Comment thread frontend/src/app/features/hal/resources/work-package-resource.ts Outdated
} catch (e:unknown) {
this.loading$?.complete();
this.halNotification.showError((e as HalError).resource, changeset.projectedResource);
this.halNotification.showError((e as HalError).resource!, changeset.projectedResource);

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.

Pre-existing: before this PR the same null reached showError untyped, so the ! only makes the existing assumption visible. Routing these through handleRawError changes runtime behaviour, so it is tracked separately in OP-20361 to keep this PR type-only.

})
.catch((e:unknown) => {
this.workPackageNotificationService.showError((e as HalError).resource, change.projectedResource);
this.workPackageNotificationService.showError((e as HalError).resource!, change.projectedResource);

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.

Pre-existing: before this PR the same null reached showError untyped, so the ! only makes the existing assumption visible. Routing these through handleRawError changes runtime behaviour, so it is tracked separately in OP-20361 to keep this PR type-only.

@myabc
myabc force-pushed the implementation/op-20344-eslint-typing-fix-hal-resources branch from 721c8e0 to 8d2e4e0 Compare September 29, 2026 09:47
@myabc myabc added eslint javascript Pull requests that update Javascript code needs review labels Sep 29, 2026
@myabc myabc added this to the 18.0.x milestone Sep 29, 2026
@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./modules/gantt/spec/features/timeline/timeline_dates_spec.rb[1:2:1]
  • rspec ./modules/overviews/spec/features/managing_dashboard_page_spec.rb[1:1:1]
  • rspec ./spec/features/oauth/authorization_code_flow_spec.rb[1:5:1]
🤖 Ask Copilot to investigate

Copy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer.

@copilot The following spec(s) are flaky in CI (first seen on PR #25660, linked for reference only):

- `rspec ./modules/gantt/spec/features/timeline/timeline_dates_spec.rb[1:2:1]`
- `rspec ./modules/overviews/spec/features/managing_dashboard_page_spec.rb[1:1:1]`
- `rspec ./spec/features/oauth/authorization_code_flow_spec.rb[1:5:1]`

Treat this as a standalone task, unrelated to PR #25660. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #25660 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @myabc to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @myabc, and request a review from @myabc.
On every commit, set @myabc as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@github-actions github-actions Bot added the ai: Directed 🪄 A human specified the requirements and AI implemented most of it; They validated via testing. label Sep 29, 2026
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
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<T> instead of `Promise<any>` 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
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
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
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
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
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
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
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
@myabc
myabc force-pushed the implementation/op-20344-eslint-typing-fix-hal-resources branch from 8d2e4e0 to 03912a1 Compare September 29, 2026 18:30

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai: Directed 🪄 A human specified the requirements and AI implemented most of it; They validated via testing. eslint javascript Pull requests that update Javascript code needs review

Development

Successfully merging this pull request may close these issues.

2 participants