diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 729f72a3e..94a5d5b1e 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -16,7 +16,7 @@ on: required: true default: 'https://github.com/moodle/moodle' pull_request: - branches: [ main, v*.x ] + branches: [ main, ionic7, v*.x ] jobs: behat: @@ -24,7 +24,7 @@ jobs: env: MOODLE_DOCKER_DB: pgsql MOODLE_DOCKER_BROWSER: chrome - MOODLE_DOCKER_PHP_VERSION: '8.0' + MOODLE_DOCKER_PHP_VERSION: '8.1' MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }} MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }} BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }} diff --git a/local_moodleappbehat/tests/behat/behat_app.php b/local_moodleappbehat/tests/behat/behat_app.php index 498b1c111..aeacba67a 100644 --- a/local_moodleappbehat/tests/behat/behat_app.php +++ b/local_moodleappbehat/tests/behat/behat_app.php @@ -214,10 +214,7 @@ class behat_app extends behat_app_helper { return true; }); - $this->wait_for_pending_js(); - - // Wait scroll animation to finish. - $this->getSession()->wait(300); + $this->wait_animations_done(); } /** @@ -263,10 +260,7 @@ class behat_app extends behat_app_helper { throw new DriverException('Error when swiping - ' . $result); } - $this->wait_for_pending_js(); - - // Wait swipe animation to finish. - $this->getSession()->wait(300); + $this->wait_animations_done(); } /** @@ -689,10 +683,7 @@ class behat_app extends behat_app_helper { return true; }); - $this->wait_for_pending_js(); - - // Wait for UI to settle after refreshing. - $this->getSession()->wait(300); + $this->wait_animations_done(); if (is_null($locator)) { return; diff --git a/local_moodleappbehat/tests/behat/behat_app_helper.php b/local_moodleappbehat/tests/behat/behat_app_helper.php index c5538a701..d67fa4a82 100644 --- a/local_moodleappbehat/tests/behat/behat_app_helper.php +++ b/local_moodleappbehat/tests/behat/behat_app_helper.php @@ -641,4 +641,15 @@ EOF; return $text; } } + + /** + * Wait until animations have finished. + */ + protected function wait_animations_done() { + $this->wait_for_pending_js(); + + // Ideally, we wouldn't wait a fixed amount of time. But it is not straightforward to wait for animations + // to finish, so for now we'll just wait 300ms. + usleep(300000); + } } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index b66bd26b1..869d3af27 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -27,6 +27,8 @@ import { import { CoreArray } from '@singletons/array'; +const modulesRoutes: WeakMap, ModuleRoutes> = new WeakMap(); + /** * Build app routes. * @@ -175,6 +177,10 @@ export function conditionalRoutes(routes: Routes, condition: () => boolean): Rou * @returns Routes. */ export function resolveModuleRoutes(injector: Injector, token: InjectionToken): ModuleRoutes { + if (modulesRoutes.has(token)) { + return modulesRoutes.get(token) as ModuleRoutes; + } + const configs = injector.get(token, []); const routes = configs.map(config => { if (Array.isArray(config)) { @@ -190,10 +196,14 @@ export function resolveModuleRoutes(injector: Injector, token: InjectionToken r.children)), siblings: CoreArray.flatten(routes.map(r => r.siblings)), }; + + modulesRoutes.set(token, moduleRoutes); + + return moduleRoutes; } export const APP_ROUTES = new InjectionToken('APP_ROUTES'); diff --git a/src/core/components/swipe-slides/swipe-slides.html b/src/core/components/swipe-slides/swipe-slides.html index d3b02b245..bcfc05ac8 100644 --- a/src/core/components/swipe-slides/swipe-slides.html +++ b/src/core/components/swipe-slides/swipe-slides.html @@ -1,5 +1,4 @@ - + diff --git a/src/core/components/swipe-slides/swipe-slides.ts b/src/core/components/swipe-slides/swipe-slides.ts index a2569739e..f063ffd72 100644 --- a/src/core/components/swipe-slides/swipe-slides.ts +++ b/src/core/components/swipe-slides/swipe-slides.ts @@ -50,6 +50,13 @@ export class CoreSwipeSlidesComponent implements OnChanges, OnDe if (swiperRef?.nativeElement?.swiper) { this.swiper = swiperRef.nativeElement.swiper as Swiper; + if (this.options.initialSlide) { + this.swiper.slideTo(this.options.initialSlide, 0, this.options.runCallbacksOnInit); + } + + this.swiper.on('slideChangeTransitionStart', () => this.slideWillChange()); + this.swiper.on('slideChangeTransitionEnd', () => this.slideDidChange()); + Object.keys(this.options).forEach((key) => { if (this.swiper) { this.swiper.params[key] = this.options[key]; diff --git a/src/core/constants.ts b/src/core/constants.ts index 5dab49dcc..35a26c3b0 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -14,8 +14,15 @@ import envJson from '@/assets/env.json'; import { EnvironmentConfig } from '@/types/config'; +import { InjectionToken } from '@angular/core'; import { CoreBrowser } from '@singletons/browser'; +/** + * Injection token used for dependencies marked as optional that will never + * be resolved by Angular injectors. + */ +export const NULL_INJECTION_TOKEN = new InjectionToken('null'); + /** * Context levels enumeration. */ diff --git a/src/core/directives/swipe-navigation.ts b/src/core/directives/swipe-navigation.ts index ffff2497e..927446d40 100644 --- a/src/core/directives/swipe-navigation.ts +++ b/src/core/directives/swipe-navigation.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { CoreConstants } from '@/core/constants'; import { AfterViewInit, Directive, ElementRef, Input, OnDestroy } from '@angular/core'; import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; import { CoreSwipeNavigationTourComponent } from '@components/swipe-navigation-tour/swipe-navigation-tour'; @@ -42,6 +43,11 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { constructor(el: ElementRef) { this.element = el.nativeElement; + + if (CoreConstants.enableDevTools()) { + this.element['swipeNavigation'] = this; + this.element.classList.add('uses-swipe-navigation'); + } } get enabled(): boolean { diff --git a/src/core/features/mainmenu/mainmenu-lazy.module.ts b/src/core/features/mainmenu/mainmenu-lazy.module.ts index 8e4290ba0..0d71ebe0a 100644 --- a/src/core/features/mainmenu/mainmenu-lazy.module.ts +++ b/src/core/features/mainmenu/mainmenu-lazy.module.ts @@ -36,10 +36,6 @@ function buildRoutes(injector: Injector): Routes { path: '', component: CoreMainMenuPage, children: [ - { - path: '', - pathMatch: 'full', - }, { path: CoreMainMenuHomeHandlerService.PAGE_NAME, loadChildren: () => import('./mainmenu-home-lazy.module').then(m => m.CoreMainMenuHomeLazyModule), diff --git a/src/core/features/mainmenu/mainmenu-tab-routing.module.ts b/src/core/features/mainmenu/mainmenu-tab-routing.module.ts index 1e58919a7..6218138f1 100644 --- a/src/core/features/mainmenu/mainmenu-tab-routing.module.ts +++ b/src/core/features/mainmenu/mainmenu-tab-routing.module.ts @@ -18,24 +18,64 @@ import { Route, Routes } from '@angular/router'; import { ModuleRoutesConfig, resolveModuleRoutes } from '@/app/app-routing.module'; const MAIN_MENU_TAB_ROUTES = new InjectionToken('MAIN_MENU_TAB_ROUTES'); +const modulesPaths: Record> = {}; + +/** + * Get the name of the module the injector belongs to. + * + * @param injector Injector. + * @returns Injector module name. + */ +function getInjectorModule(injector: Injector): string | null { + if (!('source' in injector) || typeof injector.source !== 'string') { + return null; + } + + // Get module name from R3Injector source. + // See https://github.com/angular/angular/blob/16.2.0/packages/core/src/di/r3_injector.ts#L161C8 + return injector.source; +} + +/** + * Get module paths. + * + * @param injector Injector. + * @returns Module paths. + */ +function getModulePaths(injector: Injector): Set | null { + const module = getInjectorModule(injector); + + if (!module) { + return null; + } + + return modulesPaths[module] ??= new Set(); +} /** * Build module routes. * * @param injector Injector. + * @param mainRoute Main route. * @returns Routes. */ export function buildTabMainRoutes(injector: Injector, mainRoute: Route): Routes { + const path = mainRoute.path ?? ''; + const modulePaths = getModulePaths(injector); + const isRootRoute = modulePaths && !modulePaths.has(path); const routes = resolveModuleRoutes(injector, MAIN_MENU_TAB_ROUTES); - mainRoute.path = mainRoute.path || ''; - mainRoute.children = mainRoute.children || []; - mainRoute.children = mainRoute.children.concat(routes.children); + mainRoute.path = path; + modulePaths?.add(path); - return [ - mainRoute, - ...routes.siblings, - ]; + if (isRootRoute && !('redirectTo' in mainRoute)) { + mainRoute.children = mainRoute.children || []; + mainRoute.children = mainRoute.children.concat(routes.children); + } + + return isRootRoute + ? [mainRoute, ...routes.siblings] + : [mainRoute]; } @NgModule() diff --git a/src/core/features/settings/pages/deviceinfo/deviceinfo.ts b/src/core/features/settings/pages/deviceinfo/deviceinfo.ts index af619378a..4fa595af8 100644 --- a/src/core/features/settings/pages/deviceinfo/deviceinfo.ts +++ b/src/core/features/settings/pages/deviceinfo/deviceinfo.ts @@ -213,7 +213,7 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy { } const showDevOptionsOnConfig = await CoreConfig.get('showDevOptions', 0); - this.devOptionsForced = CoreConstants.BUILD.isDevelopment || CoreConstants.BUILD.isTesting; + this.devOptionsForced = CoreConstants.enableDevTools(); this.showDevOptions = this.devOptionsForced || showDevOptionsOnConfig == 1; const publicKey = this.deviceInfo.pushId ? diff --git a/src/core/services/storage.ts b/src/core/services/storage.ts index 25066f221..4191f8060 100644 --- a/src/core/services/storage.ts +++ b/src/core/services/storage.ts @@ -24,6 +24,7 @@ import { SQLiteDB } from '@classes/sqlitedb'; import { APP_SCHEMA, CoreStorageRecord, TABLE_NAME } from './database/storage'; import { CoreSites } from './sites'; import { CoreSite } from '@classes/sites/site'; +import { NULL_INJECTION_TOKEN } from '@/core/constants'; /** * Service to store data using key-value pairs. @@ -38,7 +39,7 @@ export class CoreStorageService { table: AsyncInstance; - constructor(@Optional() @Inject(null) lazyTableConstructor?: () => Promise) { + constructor(@Optional() @Inject(NULL_INJECTION_TOKEN) lazyTableConstructor?: () => Promise) { this.table = asyncInstance(lazyTableConstructor); } diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts index 4945a1246..bb0bd4740 100644 --- a/src/testing/services/behat-runtime.ts +++ b/src/testing/services/behat-runtime.ts @@ -404,17 +404,13 @@ export class TestingBehatRuntimeService { this.log('Action - pullToRefresh'); try { - // 'el' is protected, but there's no other way to trigger refresh programatically. - const ionRefresher = this.getAngularInstance<{ el: HTMLIonRefresherElement }>( - 'ion-refresher', - 'IonRefresher', - ); + const ionRefresher = this.getElement('ion-refresher'); if (!ionRefresher) { return 'ERROR: It\'s not possible to pull to refresh the current page.'; } - ionRefresher.el.dispatchEvent(new CustomEvent('ionRefresh')); + ionRefresher.dispatchEvent(new CustomEvent('ionRefresh')); return 'OK'; } catch (error) { @@ -521,20 +517,13 @@ export class TestingBehatRuntimeService { } /** - * Get an Angular component instance. + * Get element instance. * - * @param selector Element selector - * @param className Constructor class name + * @param selector Element selector. * @param referenceLocator The locator to the reference element to start looking for. If not specified, document body. - * @returns Component instance + * @returns Element instance. */ - getAngularInstance( - selector: string, - className: string, - referenceLocator?: TestingBehatElementLocator, - ): T | null { - this.log('Action - Get Angular instance ' + selector + ', ' + className, referenceLocator); - + private getElement(selector: string, referenceLocator?: TestingBehatElementLocator): T | null { let startingElement: HTMLElement | undefined = document.body; let queryPrefix = ''; @@ -552,15 +541,8 @@ export class TestingBehatRuntimeService { queryPrefix = '.ion-page:not(.ion-page-hidden) '; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const activeElement = Array.from(startingElement.querySelectorAll(`${queryPrefix}${selector}`)).pop() ?? - startingElement.closest(selector); - - if (!activeElement || !activeElement.__ngContext__) { - return null; - } - - return activeElement.__ngContext__.find(node => node?.constructor?.name === className); + return Array.from(startingElement.querySelectorAll(`${queryPrefix}${selector}`)).pop() as T + ?? startingElement.closest(selector) as T; } /** @@ -632,26 +614,26 @@ export class TestingBehatRuntimeService { if (locator) { // Locator specified, try to find swiper-container first. - const instance = this.getAngularInstance('swiper-container', 'Swiper', locator); - if (instance) { - direction === 'left' ? instance.slideNext() : instance.slidePrev(); + const swiperContainer = this.getElement<{ swiper: Swiper }>('swiper-container', locator); + + if (swiperContainer) { + direction === 'left' ? swiperContainer.swiper.slideNext() : swiperContainer.swiper.slidePrev(); return 'OK'; } } // No locator specified or swiper-container not found, search swipe navigation now. - const instance = this.getAngularInstance( - 'ion-content', - 'CoreSwipeNavigationDirective', + const ionContent = this.getElement<{ swipeNavigation: CoreSwipeNavigationDirective }>( + 'ion-content.uses-swipe-navigation', locator, ); - if (!instance) { + if (!ionContent) { return 'ERROR: Element to swipe not found.'; } - direction === 'left' ? instance.swipeLeft() : instance.swipeRight(); + direction === 'left' ? ionContent.swipeNavigation.swipeLeft() : ionContent.swipeNavigation.swipeRight(); return 'OK'; }