diff --git a/src/addons/mod/workshop/assessment/accumulative/accumulative.module.ts b/src/addons/mod/workshop/assessment/accumulative/accumulative.module.ts index ae072947fa1..e093a89b7f7 100644 --- a/src/addons/mod/workshop/assessment/accumulative/accumulative.module.ts +++ b/src/addons/mod/workshop/assessment/accumulative/accumulative.module.ts @@ -15,7 +15,9 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { AddonWorkshopAssessmentStrategyDelegate } from '../../services/assessment-strategy-delegate'; import { CoreSharedModule } from '@/core/shared.module'; -import { getAssessmentStrategyHandlerInstance } from '@addons/mod/workshop/assessment/accumulative/services/handler'; +import { + AddonModWorkshopAssessmentStrategyAccumulativeHandler, +} from '@addons/mod/workshop/assessment/accumulative/services/handler-lazy'; @NgModule({ imports: [ @@ -26,7 +28,12 @@ import { getAssessmentStrategyHandlerInstance } from '@addons/mod/workshop/asses provide: APP_INITIALIZER, multi: true, useValue: () => { - AddonWorkshopAssessmentStrategyDelegate.registerHandler(getAssessmentStrategyHandlerInstance()); + // TODO use async instances + // AddonWorkshopAssessmentStrategyDelegate.registerHandler(getAssessmentStrategyHandlerInstance()); + + AddonWorkshopAssessmentStrategyDelegate.registerHandler( + AddonModWorkshopAssessmentStrategyAccumulativeHandler.instance, + ); }, }, ], diff --git a/src/addons/mod/workshop/assessment/comments/comments.module.ts b/src/addons/mod/workshop/assessment/comments/comments.module.ts index 33d72d4b505..c6820effed8 100644 --- a/src/addons/mod/workshop/assessment/comments/comments.module.ts +++ b/src/addons/mod/workshop/assessment/comments/comments.module.ts @@ -15,7 +15,7 @@ import { CoreSharedModule } from '@/core/shared.module'; import { APP_INITIALIZER, NgModule } from '@angular/core'; import { AddonWorkshopAssessmentStrategyDelegate } from '../../services/assessment-strategy-delegate'; -import { getAssessmentStrategyHandlerInstance } from './services/handler'; +import { AddonModWorkshopAssessmentStrategyCommentsHandler } from '@addons/mod/workshop/assessment/comments/services/handler-lazy'; @NgModule({ imports: [ @@ -26,7 +26,10 @@ import { getAssessmentStrategyHandlerInstance } from './services/handler'; provide: APP_INITIALIZER, multi: true, useValue: () => { - AddonWorkshopAssessmentStrategyDelegate.registerHandler(getAssessmentStrategyHandlerInstance()); + // TODO use async instances + // AddonWorkshopAssessmentStrategyDelegate.registerHandler(getAssessmentStrategyHandlerInstance()); + + AddonWorkshopAssessmentStrategyDelegate.registerHandler(AddonModWorkshopAssessmentStrategyCommentsHandler.instance); }, }, ], diff --git a/src/addons/mod/workshop/assessment/numerrors/numerrors.module.ts b/src/addons/mod/workshop/assessment/numerrors/numerrors.module.ts index 3fdcc9df2f6..9a4f7d42b3e 100644 --- a/src/addons/mod/workshop/assessment/numerrors/numerrors.module.ts +++ b/src/addons/mod/workshop/assessment/numerrors/numerrors.module.ts @@ -15,7 +15,9 @@ import { CoreSharedModule } from '@/core/shared.module'; import { APP_INITIALIZER, NgModule } from '@angular/core'; import { AddonWorkshopAssessmentStrategyDelegate } from '../../services/assessment-strategy-delegate'; -import { getAssessmentStrategyHandlerInstance } from './services/handler'; +import { + AddonModWorkshopAssessmentStrategyNumErrorsHandler, +} from '@addons/mod/workshop/assessment/numerrors/services/handler-lazy'; @NgModule({ imports: [ @@ -26,7 +28,12 @@ import { getAssessmentStrategyHandlerInstance } from './services/handler'; provide: APP_INITIALIZER, multi: true, useValue: () => { - AddonWorkshopAssessmentStrategyDelegate.registerHandler(getAssessmentStrategyHandlerInstance()); + // TODO use async instances + // AddonWorkshopAssessmentStrategyDelegate.registerHandler(getAssessmentStrategyHandlerInstance()); + + AddonWorkshopAssessmentStrategyDelegate.registerHandler( + AddonModWorkshopAssessmentStrategyNumErrorsHandler.instance, + ); }, }, ], diff --git a/src/addons/mod/workshop/assessment/rubric/rubric.module.ts b/src/addons/mod/workshop/assessment/rubric/rubric.module.ts index 62328560e70..54b49a97592 100644 --- a/src/addons/mod/workshop/assessment/rubric/rubric.module.ts +++ b/src/addons/mod/workshop/assessment/rubric/rubric.module.ts @@ -15,7 +15,7 @@ import { CoreSharedModule } from '@/core/shared.module'; import { APP_INITIALIZER, NgModule } from '@angular/core'; import { AddonWorkshopAssessmentStrategyDelegate } from '../../services/assessment-strategy-delegate'; -import { getAssessmentStrategyHandlerInstance } from './services/handler'; +import { AddonModWorkshopAssessmentStrategyRubricHandler } from '@addons/mod/workshop/assessment/rubric/services/handler-lazy'; @NgModule({ imports: [ @@ -26,7 +26,10 @@ import { getAssessmentStrategyHandlerInstance } from './services/handler'; provide: APP_INITIALIZER, multi: true, useValue: () => { - AddonWorkshopAssessmentStrategyDelegate.registerHandler(getAssessmentStrategyHandlerInstance()); + // TODO use async instances + // AddonWorkshopAssessmentStrategyDelegate.registerHandler(getAssessmentStrategyHandlerInstance()); + + AddonWorkshopAssessmentStrategyDelegate.registerHandler(AddonModWorkshopAssessmentStrategyRubricHandler.instance); }, }, ], diff --git a/src/addons/mod/workshop/workshop.module.ts b/src/addons/mod/workshop/workshop.module.ts index 930ba20883a..ee1bda50d35 100644 --- a/src/addons/mod/workshop/workshop.module.ts +++ b/src/addons/mod/workshop/workshop.module.ts @@ -27,8 +27,8 @@ import { AddonModWorkshopIndexLinkHandler } from './services/handlers/index-link import { AddonModWorkshopListLinkHandler } from './services/handlers/list-link'; import { AddonModWorkshopModuleHandler } from './services/handlers/module'; import { ADDON_MOD_WORKSHOP_COMPONENT, ADDON_MOD_WORKSHOP_PAGE_NAME } from '@addons/mod/workshop/constants'; -import { getCronHandlerInstance } from '@addons/mod/workshop/services/handlers/sync-cron'; -import { getPrefetchHandlerInstance } from '@addons/mod/workshop/services/handlers/prefetch'; +import { AddonModWorkshopPrefetchHandler } from '@addons/mod/workshop/services/handlers/prefetch-lazy'; +import { AddonModWorkshopSyncCronHandler } from '@addons/mod/workshop/services/handlers/sync-cron-lazy'; /** * Get workshop services. @@ -85,9 +85,13 @@ const routes: Routes = [ provide: APP_INITIALIZER, multi: true, useValue: () => { + // TODO use async instances + // CoreCourseModulePrefetchDelegate.registerHandler(getPrefetchHandlerInstance()); + // CoreCronDelegate.register(getCronHandlerInstance()); + CoreCourseModuleDelegate.registerHandler(AddonModWorkshopModuleHandler.instance); - CoreCourseModulePrefetchDelegate.registerHandler(getPrefetchHandlerInstance()); - CoreCronDelegate.register(getCronHandlerInstance()); + CoreCourseModulePrefetchDelegate.registerHandler(AddonModWorkshopPrefetchHandler.instance); + CoreCronDelegate.register(AddonModWorkshopSyncCronHandler.instance); CoreContentLinksDelegate.registerHandler(AddonModWorkshopIndexLinkHandler.instance); CoreContentLinksDelegate.registerHandler(AddonModWorkshopListLinkHandler.instance); diff --git a/src/core/features/native/services/native.ts b/src/core/features/native/services/native.ts index 2f2bfc59bd0..60945027b16 100644 --- a/src/core/features/native/services/native.ts +++ b/src/core/features/native/services/native.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { makeSingleton } from '@singletons'; import { CorePlatform } from '@services/platform'; -import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance'; +import { AsyncInstance, AsyncObject, asyncInstance } from '@/core/utils/async-instance'; /** * Native plugin manager. @@ -23,7 +23,7 @@ import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance'; @Injectable({ providedIn: 'root' }) export class CoreNativeService { - private plugins: Partial> = {}; + private plugins: Partial>> = {}; private mocks: Partial> = {}; /** diff --git a/src/core/utils/async-instance.ts b/src/core/utils/async-instance.ts index 40352cb1cba..187f64c1962 100644 --- a/src/core/utils/async-instance.ts +++ b/src/core/utils/async-instance.ts @@ -14,19 +14,19 @@ import { CorePromisedValue } from '@classes/promised-value'; -// eslint-disable-next-line @typescript-eslint/ban-types -type AsyncObject = object; - /** * Create a wrapper to hold an asynchronous instance. * * @param lazyConstructor Constructor to use the first time the instance is needed. * @returns Asynchronous instance wrapper. */ -function createAsyncInstanceWrapper( - lazyConstructor?: () => TInstance | Promise, -): AsyncInstanceWrapper { - let promisedInstance: CorePromisedValue | null = null; +function createAsyncInstanceWrapper< + TLazyInstance extends TEagerInstance, + TEagerInstance extends AsyncObject = Partial +>( + lazyConstructor?: () => TLazyInstance | Promise, +): AsyncInstanceWrapper { + let promisedInstance: CorePromisedValue | null = null; let eagerInstance: TEagerInstance; return { @@ -90,20 +90,36 @@ function createAsyncInstanceWrapper unknown { + return typeof value === 'function'; +} + /** * Asynchronous instance wrapper. */ -export interface AsyncInstanceWrapper { - instance?: TInstance; +export interface AsyncInstanceWrapper< + TLazyInstance extends TEagerInstance, + TEagerInstance extends AsyncObject = Partial +> { + instance?: TLazyInstance; eagerInstance?: TEagerInstance; - getInstance(): Promise; - getProperty

(property: P): Promise; - setInstance(instance: TInstance): void; + getInstance(): Promise; + getProperty

(property: P): Promise; + setInstance(instance: TLazyInstance): void; setEagerInstance(eagerInstance: TEagerInstance): void; - setLazyConstructor(lazyConstructor: () => TInstance | Promise): void; + setLazyConstructor(lazyConstructor: () => TLazyInstance | Promise): void; resetInstance(): void; } +// eslint-disable-next-line @typescript-eslint/ban-types +export type AsyncObject = object; + /** * Asynchronous version of a method. */ @@ -121,9 +137,9 @@ export type AsyncMethod = * All methods are converted to their asynchronous version, and properties are available asynchronously using * the getProperty method. */ -export type AsyncInstance = - AsyncInstanceWrapper & TEagerInstance & { - [k in keyof TInstance]: AsyncMethod; +export type AsyncInstance> = + AsyncInstanceWrapper & { + [k in keyof TLazyInstance]: AsyncMethod; }; /** @@ -133,10 +149,10 @@ export type AsyncInstance( - lazyConstructor?: () => TInstance | Promise, -): AsyncInstance { - const wrapper = createAsyncInstanceWrapper(lazyConstructor); +export function asyncInstance>( + lazyConstructor?: () => TLazyInstance | Promise, +): AsyncInstance { + const wrapper = createAsyncInstanceWrapper(lazyConstructor); return new Proxy(wrapper, { get: (target, property, receiver) => { @@ -144,8 +160,12 @@ export function asyncInstance value.call(wrapper.instance, ...args) + : value; } if (wrapper.eagerInstance && property in wrapper.eagerInstance) { @@ -154,9 +174,14 @@ export function asyncInstance { const instance = await wrapper.getInstance(); + const method = Reflect.get(instance, property, receiver); + + if (!isMethod(method)) { + throw new Error(`'${property.toString()}' is not a function`); + } - return instance[property](...args); + return method.call(instance, ...args); }; }, - }) as AsyncInstance; + }) as AsyncInstance; } diff --git a/src/core/utils/tests/async-instance.test.ts b/src/core/utils/tests/async-instance.test.ts new file mode 100644 index 00000000000..c17f29b6891 --- /dev/null +++ b/src/core/utils/tests/async-instance.test.ts @@ -0,0 +1,94 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance'; +import { expectAnyType, expectSameTypes } from '@/testing/utils'; + +describe('AsyncInstance', () => { + + it('initializes instances lazily', async () => { + const asyncService = asyncInstance(() => new LazyService()); + + expect(asyncService.instance).toBe(undefined); + expect(await asyncService.hello()).toEqual('Hi there!'); + expect(asyncService.instance).toBeInstanceOf(LazyService); + }); + + it('does not initialize instance for eager properties', async () => { + const asyncService = asyncInstance(() => new LazyService()); + + asyncService.setEagerInstance(new EagerService()); + + expect(asyncService.instance).toBeUndefined(); + expect(asyncService.answer).toEqual(42); + expect(asyncService.instance).toBeUndefined(); + expect(await asyncService.hello()).toEqual('Hi there!'); + expect(asyncService.instance).toBeInstanceOf(LazyService); + }); + + it('preserves undefined properties after initialization', async () => { + const asyncService = asyncInstance(() => new LazyService()) as { thisDoesNotExist?: () => Promise}; + + await expect(asyncService.thisDoesNotExist?.()).rejects.toBeInstanceOf(Error); + + expect(asyncService.thisDoesNotExist).toBeUndefined(); + }); + + it('restricts types hierarchy', () => { + type GetInstances = T extends AsyncInstance + ? { eager: TEagerInstance; lazy: TLazyInstance } + : never; + type GetEagerInstance = GetInstances['eager']; + type GetLazyInstance = GetInstances['lazy']; + + expectSameTypes>, LazyService>(true); + expectSameTypes>, Partial>(true); + + expectSameTypes>, LazyService>(true); + expectSameTypes>, EagerService>(true); + + // @ts-expect-error LazyService should extend FakeEagerService. + expectAnyType>(); + }); + + it('makes methods asynchronous', () => { + expectSameTypes['hello'], () => Promise>(true); + expectSameTypes['goodbye'], () => Promise>(true); + }); + +}); + +class EagerService { + + answer = 42; + +} + +class FakeEagerService { + + answer = '42'; + +} + +class LazyService extends EagerService { + + hello(): string { + return 'Hi there!'; + } + + async goodbye(): Promise { + return 'Sayonara!'; + } + +} diff --git a/src/core/utils/types.ts b/src/core/utils/types.ts index ff4aa5dba0c..d0131a35e0a 100644 --- a/src/core/utils/types.ts +++ b/src/core/utils/types.ts @@ -18,6 +18,11 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Constructor = { new(...args: any[]): T }; +/** + * Helper type to infer whether two types are exactly the same. + */ +export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false; + /** * Helper type to flatten complex types. */ diff --git a/src/testing/utils.ts b/src/testing/utils.ts index 2a54ef6748c..092adaf135f 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -34,6 +34,7 @@ import { CoreIonLoadingElement } from '@classes/ion-loading'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { DefaultUrlSerializer, UrlSerializer } from '@angular/router'; import { CoreUtils, CoreUtilsProvider } from '@services/utils/utils'; +import { Equal } from '@/core/utils/types'; abstract class WrapperComponent { @@ -421,3 +422,12 @@ export function mockTranslate(translations: Record = {}): void { }, }); } + +export function expectSameTypes(equal: Equal): () => void { + return () => expect(equal).toBe(true); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function expectAnyType(): () => void { + return () => expect(true).toBe(true); +}