diff --git a/.eslintrc.js b/.eslintrc.js index d5a05ca9e..eba7eb33f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -139,7 +139,6 @@ const appConfig = { 'always', ], '@typescript-eslint/type-annotation-spacing': 'error', - '@typescript-eslint/unified-signatures': 'error', 'header/header': [ 2, 'line', diff --git a/scripts/lang_functions.php b/scripts/lang_functions.php index 38bd83b72..a6698560c 100644 --- a/scripts/lang_functions.php +++ b/scripts/lang_functions.php @@ -257,6 +257,7 @@ function build_lang($lang, $keys) { $text = str_replace('$a->', '$a.', $text); $text = str_replace('{$a', '{{$a', $text); $text = str_replace('}', '}}', $text); + $text = preg_replace('/@@.+?@@(
)?\\s*/', '', $text); // Prevent double. $text = str_replace(array('{{{', '}}}'), array('{{', '}}'), $text); } else { diff --git a/scripts/langindex.json b/scripts/langindex.json index de6d427b4..ec7f4e8eb 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1450,6 +1450,8 @@ "core.block.blocks": "moodle", "core.block.noblocks": "error", "core.block.opendrawerblocks": "moodle", + "core.block.tour_navigation_dashboard_content": "tool_usertours", + "core.block.tour_navigation_dashboard_title": "tool_usertours", "core.browser": "local_moodlemobileapp", "core.cancel": "moodle", "core.cannotconnect": "local_moodlemobileapp", @@ -1567,6 +1569,8 @@ "core.course.startdate": "moodle", "core.course.thisweek": "format_weeks/currentsection", "core.course.todo": "completion", + "core.course.tour_navigation_course_index_student_content": "tool_usertours", + "core.course.tour_navigation_course_index_student_title": "tool_usertours", "core.course.useactivityonbrowser": "local_moodlemobileapp", "core.course.viewcourse": "block_timeline", "core.course.warningmanualcompletionmodified": "local_moodlemobileapp", @@ -2003,6 +2007,8 @@ "core.mainmenu.home": "moodle", "core.mainmenu.logout": "moodle", "core.mainmenu.switchaccount": "local_moodlemobileapp", + "core.mainmenu.usermenutourdescription": "local_moodlemobileapp", + "core.mainmenu.usermenutourtitle": "local_moodlemobileapp", "core.maxfilesize": "moodle", "core.maxsizeandattachments": "moodle", "core.min": "moodle", @@ -2263,6 +2269,7 @@ "core.submit": "moodle", "core.success": "moodle", "core.summary": "moodle", + "core.swipenavigationtourdescription": "local_moodlemobileapp", "core.tablet": "local_moodlemobileapp", "core.tag.defautltagcoll": "tag", "core.tag.errorareanotsupported": "local_moodlemobileapp", @@ -2335,6 +2342,7 @@ "core.usernotfullysetup": "error", "core.users": "moodle", "core.usersuspended": "tool_reportbuilder", + "core.endonesteptour": "tool_usertours", "core.view": "moodle", "core.viewcode": "local_moodlemobileapp", "core.vieweditor": "local_moodlemobileapp", diff --git a/src/assets/img/user-tours/course-index.svg b/src/assets/img/user-tours/course-index.svg new file mode 100644 index 000000000..c3ecbf014 --- /dev/null +++ b/src/assets/img/user-tours/course-index.svg @@ -0,0 +1 @@ + diff --git a/src/assets/img/user-tours/side-blocks.svg b/src/assets/img/user-tours/side-blocks.svg new file mode 100644 index 000000000..c2c8d4372 --- /dev/null +++ b/src/assets/img/user-tours/side-blocks.svg @@ -0,0 +1 @@ + diff --git a/src/assets/img/user-tours/swipe-navigation.svg b/src/assets/img/user-tours/swipe-navigation.svg new file mode 100644 index 000000000..ac0c1732c --- /dev/null +++ b/src/assets/img/user-tours/swipe-navigation.svg @@ -0,0 +1 @@ + diff --git a/src/assets/img/user-tours/user-menu.svg b/src/assets/img/user-tours/user-menu.svg new file mode 100644 index 000000000..33528d215 --- /dev/null +++ b/src/assets/img/user-tours/user-menu.svg @@ -0,0 +1 @@ + diff --git a/src/core/classes/database/database-table-proxy.ts b/src/core/classes/database/database-table-proxy.ts index b1cba7e88..ea7bffd96 100644 --- a/src/core/classes/database/database-table-proxy.ts +++ b/src/core/classes/database/database-table-proxy.ts @@ -137,6 +137,13 @@ export class CoreDatabaseTableProxy< return this.target.hasAny(conditions); } + /** + * @inheritdoc + */ + hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise { + return this.target.hasAnyByPrimaryKey(primaryKey); + } + /** * @inheritdoc */ diff --git a/src/core/classes/database/database-table.ts b/src/core/classes/database/database-table.ts index 43e0ac125..807f5dad1 100644 --- a/src/core/classes/database/database-table.ts +++ b/src/core/classes/database/database-table.ts @@ -219,6 +219,23 @@ export class CoreDatabaseTable< } } + /** + * Check whether the table has any record matching the given primary key. + * + * @param primaryKey Record primary key. + * @returns Whether the table contains a record matching the given primary key. + */ + async hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise { + try { + await this.getOneByPrimaryKey(primaryKey); + + return true; + } catch (error) { + // Couldn't get the record. + return false; + } + } + /** * Count records in table. * diff --git a/src/core/classes/database/debug-database-table.ts b/src/core/classes/database/debug-database-table.ts index 5ab22bb8e..db146144b 100644 --- a/src/core/classes/database/debug-database-table.ts +++ b/src/core/classes/database/debug-database-table.ts @@ -129,6 +129,15 @@ export class CoreDebugDatabaseTable< return this.target.hasAny(conditions); } + /** + * @inheritdoc + */ + hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise { + this.logger.log('hasAnyByPrimaryKey', primaryKey); + + return this.target.hasAnyByPrimaryKey(primaryKey); + } + /** * @inheritdoc */ diff --git a/src/core/classes/database/eager-database-table.ts b/src/core/classes/database/eager-database-table.ts index 97aac2f19..1a03e0fda 100644 --- a/src/core/classes/database/eager-database-table.ts +++ b/src/core/classes/database/eager-database-table.ts @@ -134,6 +134,13 @@ export class CoreEagerDatabaseTable< : Object.values(this.records).length > 0; } + /** + * @inheritdoc + */ + async hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise { + return this.serializePrimaryKey(primaryKey) in this.records; + } + /** * @inheritdoc */ diff --git a/src/core/classes/database/lazy-database-table.ts b/src/core/classes/database/lazy-database-table.ts index f29bbddb7..2e1aabcc5 100644 --- a/src/core/classes/database/lazy-database-table.ts +++ b/src/core/classes/database/lazy-database-table.ts @@ -125,6 +125,15 @@ export class CoreLazyDatabaseTable< return super.hasAny(conditions); } + /** + * @inheritdoc + */ + async hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise { + const record = this.records[this.serializePrimaryKey(primaryKey)] ?? null; + + return record !== null; + } + /** * @inheritdoc */ diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 0bfb96dc3..2ab8a0a30 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -60,6 +60,7 @@ import { CoreSpacerComponent } from './spacer/spacer'; import { CoreHorizontalScrollControlsComponent } from './horizontal-scroll-controls/horizontal-scroll-controls'; import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-with-spinner'; import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; +import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour'; @NgModule({ declarations: [ @@ -102,6 +103,7 @@ import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; CoreComboboxComponent, CoreSpacerComponent, CoreHorizontalScrollControlsComponent, + CoreSwipeNavigationTourComponent, ], imports: [ CommonModule, @@ -151,6 +153,7 @@ import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; CoreComboboxComponent, CoreSpacerComponent, CoreHorizontalScrollControlsComponent, + CoreSwipeNavigationTourComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/swipe-navigation-tour/core-swipe-navigation-tour.html b/src/core/components/swipe-navigation-tour/core-swipe-navigation-tour.html new file mode 100644 index 000000000..e8b94a568 --- /dev/null +++ b/src/core/components/swipe-navigation-tour/core-swipe-navigation-tour.html @@ -0,0 +1,5 @@ + +

{{ 'core.swipenavigationtourdescription' | translate }}

+ + {{ 'core.endonesteptour' | translate }} + diff --git a/src/core/components/swipe-navigation-tour/swipe-navigation-tour.scss b/src/core/components/swipe-navigation-tour/swipe-navigation-tour.scss new file mode 100644 index 000000000..f71e25d12 --- /dev/null +++ b/src/core/components/swipe-navigation-tour/swipe-navigation-tour.scss @@ -0,0 +1,12 @@ +:host { + max-width: 85vw; + + img { + max-width: 300px; + } + + p { + text-align: center; + } + +} diff --git a/src/core/components/swipe-navigation-tour/swipe-navigation-tour.ts b/src/core/components/swipe-navigation-tour/swipe-navigation-tour.ts new file mode 100644 index 000000000..fb5c59085 --- /dev/null +++ b/src/core/components/swipe-navigation-tour/swipe-navigation-tour.ts @@ -0,0 +1,35 @@ +// (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 { Component } from '@angular/core'; +import { CoreUserTours } from '@features/usertours/services/user-tours'; + +/** + * Component showing the User Tour for the Swipe Navigation feature. + */ +@Component({ + selector: 'core-swipe-navigation-tour', + templateUrl: 'core-swipe-navigation-tour.html', + styleUrls: ['swipe-navigation-tour.scss'], +}) +export class CoreSwipeNavigationTourComponent { + + /** + * Dismiss User Tour. + */ + async dismiss(): Promise { + await CoreUserTours.dismiss(); + } + +} diff --git a/src/core/directives/collapsible-header.ts b/src/core/directives/collapsible-header.ts index 0c74b1a39..3b26e56a1 100644 --- a/src/core/directives/collapsible-header.ts +++ b/src/core/directives/collapsible-header.ts @@ -190,6 +190,9 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest }; this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated)); + this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated)); + + onOutletUpdated(); onOutletUpdated(); @@ -311,6 +314,10 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest * @param content Content element. */ protected updateContent(content?: HTMLIonContentElement | null): void { + if (content === (this.content ?? null)) { + return; + } + if (this.content) { if (this.contentScrollListener) { this.content.removeEventListener('ionScroll', this.contentScrollListener); @@ -321,6 +328,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener); delete this.endContentScrollListener; } + delete this.content; } diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts index 9350f14eb..2767f4973 100644 --- a/src/core/directives/directives.module.ts +++ b/src/core/directives/directives.module.ts @@ -32,6 +32,7 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation'; import { CoreCollapsibleItemDirective } from './collapsible-item'; import { CoreCollapsibleFooterDirective } from './collapsible-footer'; import { CoreContentDirective } from './content'; +import { CoreOnAppearDirective } from './on-appear'; @NgModule({ declarations: [ @@ -46,6 +47,7 @@ import { CoreContentDirective } from './content'; CoreSupressEventsDirective, CoreUserLinkDirective, CoreAriaButtonClickDirective, + CoreOnAppearDirective, CoreOnResizeDirective, CoreDownloadFileDirective, CoreCollapsibleHeaderDirective, @@ -66,6 +68,7 @@ import { CoreContentDirective } from './content'; CoreSupressEventsDirective, CoreUserLinkDirective, CoreAriaButtonClickDirective, + CoreOnAppearDirective, CoreOnResizeDirective, CoreDownloadFileDirective, CoreCollapsibleHeaderDirective, diff --git a/src/core/directives/on-appear.ts b/src/core/directives/on-appear.ts new file mode 100644 index 000000000..52d7691b6 --- /dev/null +++ b/src/core/directives/on-appear.ts @@ -0,0 +1,58 @@ +// (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 { Directive, ElementRef, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { CoreDomUtils } from '@services/utils/dom'; + +/** + * Directive to listen when an element becomes visible. + */ +@Directive({ + selector: '[onAppear]', +}) +export class CoreOnAppearDirective implements OnInit, OnDestroy { + + @Output() onAppear = new EventEmitter(); + + private element: HTMLElement; + private interval?: number; + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.interval = window.setInterval(() => { + if (!CoreDomUtils.isElementVisible(this.element)) { + return; + } + + this.onAppear.emit(); + window.clearInterval(this.interval); + + delete this.interval; + }, 50); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.interval && window.clearInterval(this.interval); + } + +} diff --git a/src/core/directives/swipe-navigation.ts b/src/core/directives/swipe-navigation.ts index a90ae68b9..081a7a85f 100644 --- a/src/core/directives/swipe-navigation.ts +++ b/src/core/directives/swipe-navigation.ts @@ -14,6 +14,8 @@ 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'; +import { CoreUserTours } from '@features/usertours/services/user-tours'; import { Gesture, GestureDetail } from '@ionic/angular'; import { CoreScreen } from '@services/screen'; import { GestureController, Platform } from '@singletons'; @@ -50,7 +52,8 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { /** * @inheritdoc */ - ngAfterViewInit(): void { + async ngAfterViewInit(): Promise { + // Set up gesture listener const style = this.element.style; this.swipeGesture = GestureController.create({ el: this.element, @@ -71,6 +74,26 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { }, }); this.swipeGesture.enable(); + + // Show user tour. + const source = this.manager?.getSource(); + + if (!source) { + return; + } + + await source.waitForLoaded(); + + const items = source.getItems() ?? []; + + if (items.length < 2) { + return; + } + + await CoreUserTours.showIfPending({ + id: 'swipe-navigation', + component: CoreSwipeNavigationTourComponent, + }); } /** diff --git a/src/core/features/block/components/components.module.ts b/src/core/features/block/components/components.module.ts index ab8bf883c..62552af04 100644 --- a/src/core/features/block/components/components.module.ts +++ b/src/core/features/block/components/components.module.ts @@ -19,6 +19,7 @@ import { CoreBlockPreRenderedComponent } from './pre-rendered-block/pre-rendered import { CoreSharedModule } from '@/core/shared.module'; import { CoreBlockSideBlocksComponent } from './side-blocks/side-blocks'; import { CoreBlockSideBlocksButtonComponent } from './side-blocks-button/side-blocks-button'; +import { CoreBlockSideBlocksTourComponent } from './side-blocks-tour/side-blocks-tour'; @NgModule({ declarations: [ @@ -27,6 +28,7 @@ import { CoreBlockSideBlocksButtonComponent } from './side-blocks-button/side-bl CoreBlockPreRenderedComponent, CoreBlockSideBlocksComponent, CoreBlockSideBlocksButtonComponent, + CoreBlockSideBlocksTourComponent, ], imports: [ CoreSharedModule, @@ -37,6 +39,7 @@ import { CoreBlockSideBlocksButtonComponent } from './side-blocks-button/side-bl CoreBlockPreRenderedComponent, CoreBlockSideBlocksComponent, CoreBlockSideBlocksButtonComponent, + CoreBlockSideBlocksTourComponent, ], }) export class CoreBlockComponentsModule {} diff --git a/src/core/features/block/components/side-blocks-button/side-blocks-button.html b/src/core/features/block/components/side-blocks-button/side-blocks-button.html index 07dcf6632..13cd1b7fe 100644 --- a/src/core/features/block/components/side-blocks-button/side-blocks-button.html +++ b/src/core/features/block/components/side-blocks-button/side-blocks-button.html @@ -1,3 +1,4 @@ - + diff --git a/src/core/features/block/components/side-blocks-button/side-blocks-button.ts b/src/core/features/block/components/side-blocks-button/side-blocks-button.ts index bd59a030a..e8b369c29 100644 --- a/src/core/features/block/components/side-blocks-button/side-blocks-button.ts +++ b/src/core/features/block/components/side-blocks-button/side-blocks-button.ts @@ -12,8 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input } from '@angular/core'; +import { Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreBlockSideBlocksTourComponent } from '../side-blocks-tour/side-blocks-tour'; import { CoreBlockSideBlocksComponent } from '../side-blocks/side-blocks'; /** @@ -27,6 +29,7 @@ import { CoreBlockSideBlocksComponent } from '../side-blocks/side-blocks'; export class CoreBlockSideBlocksButtonComponent { @Input() courseId!: number; + @ViewChild('button', { read: ElementRef }) button?: ElementRef; /** * Open side blocks. @@ -40,4 +43,23 @@ export class CoreBlockSideBlocksButtonComponent { }); } + /** + * Show User Tour. + */ + async showTour(): Promise { + const nativeButton = this.button?.nativeElement.shadowRoot?.children[0] as HTMLElement; + + if (!nativeButton) { + return; + } + + await CoreUserTours.showIfPending({ + id: 'side-blocks-button', + component: CoreBlockSideBlocksTourComponent, + focus: nativeButton, + side: CoreUserToursSide.Start, + alignment: CoreUserToursAlignment.Center, + }); + } + } diff --git a/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html new file mode 100644 index 000000000..47596569a --- /dev/null +++ b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html @@ -0,0 +1,6 @@ +

{{ 'core.block.tour_navigation_dashboard_title' | translate }}

+ +

{{ 'core.block.tour_navigation_dashboard_content' | translate }}

+ + {{ 'core.endonesteptour' | translate }} + diff --git a/src/core/features/block/components/side-blocks-tour/side-blocks-tour.scss b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.scss new file mode 100644 index 000000000..b94fabe85 --- /dev/null +++ b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.scss @@ -0,0 +1,15 @@ +:host { + + h2 { + margin-top: 0; + } + + p { + text-align: center; + } + + ion-button { + margin: 0; + } + +} diff --git a/src/core/features/block/components/side-blocks-tour/side-blocks-tour.ts b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.ts new file mode 100644 index 000000000..2f5c2ada4 --- /dev/null +++ b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.ts @@ -0,0 +1,35 @@ +// (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 { Component } from '@angular/core'; +import { CoreUserTours } from '@features/usertours/services/user-tours'; + +/** + * Component showing the User Tour for the Side Blocks feature. + */ +@Component({ + selector: 'core-block-side-blocks-tour', + templateUrl: 'side-blocks-tour.html', + styleUrls: ['side-blocks-tour.scss'], +}) +export class CoreBlockSideBlocksTourComponent { + + /** + * Dismiss User Tour. + */ + async dismiss(): Promise { + await CoreUserTours.dismiss(); + } + +} diff --git a/src/core/features/block/lang.json b/src/core/features/block/lang.json index cc3f3c95a..b3398e07b 100644 --- a/src/core/features/block/lang.json +++ b/src/core/features/block/lang.json @@ -1,5 +1,7 @@ { "blocks": "Blocks", "noblocks": "No blocks found!", - "opendrawerblocks": "Open block drawer" + "opendrawerblocks": "Open block drawer", + "tour_navigation_dashboard_content": "This side panel can contain more features.", + "tour_navigation_dashboard_title": "Expand to explore" } diff --git a/src/core/features/course/components/components.module.ts b/src/core/features/course/components/components.module.ts index 5eee268bf..a8bbc04f1 100644 --- a/src/core/features/course/components/components.module.ts +++ b/src/core/features/course/components/components.module.ts @@ -28,6 +28,7 @@ import { CoreCourseModuleInfoComponent } from './module-info/module-info'; import { CoreCourseModuleManualCompletionComponent } from './module-manual-completion/module-manual-completion'; import { CoreCourseModuleNavigationComponent } from './module-navigation/module-navigation'; import { CoreCourseModuleSummaryComponent } from './module-summary/module-summary'; +import { CoreCourseCourseIndexTourComponent } from './course-index-tour/course-index-tour'; @NgModule({ declarations: [ @@ -39,6 +40,7 @@ import { CoreCourseModuleSummaryComponent } from './module-summary/module-summar CoreCourseModuleInfoComponent, CoreCourseModuleManualCompletionComponent, CoreCourseCourseIndexComponent, + CoreCourseCourseIndexTourComponent, CoreCourseTagAreaComponent, CoreCourseUnsupportedModuleComponent, CoreCourseModuleNavigationComponent, @@ -57,6 +59,7 @@ import { CoreCourseModuleSummaryComponent } from './module-summary/module-summar CoreCourseModuleInfoComponent, CoreCourseModuleManualCompletionComponent, CoreCourseCourseIndexComponent, + CoreCourseCourseIndexTourComponent, CoreCourseTagAreaComponent, CoreCourseUnsupportedModuleComponent, CoreCourseModuleNavigationComponent, diff --git a/src/core/features/course/components/course-format/course-format.html b/src/core/features/course/components/course-format/course-format.html index 73416f1c5..2d8cd3391 100644 --- a/src/core/features/course/components/course-format/course-format.html +++ b/src/core/features/course/components/course-format/course-format.html @@ -49,7 +49,8 @@ - + {{'core.course.courseindex' | translate }} diff --git a/src/core/features/course/components/course-format/course-format.ts b/src/core/features/course/components/course-format/course-format.ts index 207d2dc8e..ee933d3c3 100644 --- a/src/core/features/course/components/course-format/course-format.ts +++ b/src/core/features/course/components/course-format/course-format.ts @@ -23,6 +23,7 @@ import { QueryList, Type, ElementRef, + ViewChild, } from '@angular/core'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; @@ -44,6 +45,8 @@ import { CoreBlockHelper } from '@features/block/services/block-helper'; import { CoreNavigator } from '@services/navigator'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course'; +import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours'; +import { CoreCourseCourseIndexTourComponent } from '../course-index-tour/course-index-tour'; /** * Component to display course contents using a certain format. If the format isn't found, use default one. @@ -71,6 +74,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { @Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section. @ViewChildren(CoreDynamicComponent) dynamicComponents?: QueryList; + @ViewChild('courseIndexFab', { read: ElementRef }) courseIndexFab?: ElementRef; // All the possible component classes. courseFormatComponent?: Type; @@ -160,6 +164,25 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { }); } + /** + * Show Course Index User Tour. + */ + async showCourseIndexTour(): Promise { + const nativeButton = this.courseIndexFab?.nativeElement.shadowRoot?.children[0] as HTMLElement; + + if (!nativeButton) { + return; + } + + await CoreUserTours.showIfPending({ + id: 'course-index', + component: CoreCourseCourseIndexTourComponent, + focus: nativeButton, + side: CoreUserToursSide.Top, + alignment: CoreUserToursAlignment.End, + }); + } + /** * Detect changes on input properties. */ diff --git a/src/core/features/course/components/course-index-tour/course-index-tour.html b/src/core/features/course/components/course-index-tour/course-index-tour.html new file mode 100644 index 000000000..c519d3152 --- /dev/null +++ b/src/core/features/course/components/course-index-tour/course-index-tour.html @@ -0,0 +1,6 @@ +

{{ 'core.course.tour_navigation_course_index_student_title' | translate }}

+ +

{{ 'core.course.tour_navigation_course_index_student_content' | translate }}

+ + {{ 'core.endonesteptour' | translate }} + diff --git a/src/core/features/course/components/course-index-tour/course-index-tour.scss b/src/core/features/course/components/course-index-tour/course-index-tour.scss new file mode 100644 index 000000000..b94fabe85 --- /dev/null +++ b/src/core/features/course/components/course-index-tour/course-index-tour.scss @@ -0,0 +1,15 @@ +:host { + + h2 { + margin-top: 0; + } + + p { + text-align: center; + } + + ion-button { + margin: 0; + } + +} diff --git a/src/core/features/course/components/course-index-tour/course-index-tour.ts b/src/core/features/course/components/course-index-tour/course-index-tour.ts new file mode 100644 index 000000000..bcf3041e8 --- /dev/null +++ b/src/core/features/course/components/course-index-tour/course-index-tour.ts @@ -0,0 +1,35 @@ +// (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 { Component } from '@angular/core'; +import { CoreUserTours } from '@features/usertours/services/user-tours'; + +/** + * Component showing the User Tour for the Course Index feature. + */ +@Component({ + selector: 'core-course-course-index-tour', + templateUrl: 'course-index-tour.html', + styleUrls: ['course-index-tour.scss'], +}) +export class CoreCourseCourseIndexTourComponent { + + /** + * Dismiss the User Tour. + */ + async dismiss(): Promise { + await CoreUserTours.dismiss(); + } + +} diff --git a/src/core/features/course/lang.json b/src/core/features/course/lang.json index 4c1e75de6..1103a9902 100644 --- a/src/core/features/course/lang.json +++ b/src/core/features/course/lang.json @@ -54,6 +54,8 @@ "startdate": "Course start date", "thisweek": "This week", "todo": "To do", + "tour_navigation_course_index_student_content": "Browse through activities and track your progress.", + "tour_navigation_course_index_student_title": "Find your way around", "useactivityonbrowser": "You can still use it using your device's web browser.", "viewcourse": "View course", "warningmanualcompletionmodified": "The manual completion of an activity was modified on the site.", diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index 534890791..b954b83d1 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -37,6 +37,7 @@ import { CoreSiteHomeModule } from './sitehome/sitehome.module'; import { CoreSitePluginsModule } from './siteplugins/siteplugins.module'; import { CoreStylesModule } from './styles/styles.module'; import { CoreTagModule } from './tag/tag.module'; +import { CoreUserToursModule } from './usertours/user-tours.module'; import { CoreUserModule } from './user/user.module'; import { CoreViewerModule } from './viewer/viewer.module'; import { CoreXAPIModule } from './xapi/xapi.module'; @@ -66,6 +67,7 @@ import { CoreXAPIModule } from './xapi/xapi.module'; CoreSitePluginsModule, CoreTagModule, CoreStylesModule, + CoreUserToursModule, CoreUserModule, CoreViewerModule, CoreXAPIModule, diff --git a/src/core/features/mainmenu/components/components.module.ts b/src/core/features/mainmenu/components/components.module.ts index 613c1c790..c81a44b1d 100644 --- a/src/core/features/mainmenu/components/components.module.ts +++ b/src/core/features/mainmenu/components/components.module.ts @@ -17,11 +17,13 @@ import { CoreSharedModule } from '@/core/shared.module'; import { CoreMainMenuUserButtonComponent } from './user-menu-button/user-menu-button'; import { CoreMainMenuUserMenuComponent } from './user-menu/user-menu'; import { CoreLoginComponentsModule } from '@features/login/components/components.module'; +import { CoreMainMenuUserMenuTourComponent } from './user-menu-tour/user-menu-tour'; @NgModule({ declarations: [ CoreMainMenuUserButtonComponent, CoreMainMenuUserMenuComponent, + CoreMainMenuUserMenuTourComponent, ], imports: [ CoreSharedModule, @@ -30,6 +32,7 @@ import { CoreLoginComponentsModule } from '@features/login/components/components exports: [ CoreMainMenuUserButtonComponent, CoreMainMenuUserMenuComponent, + CoreMainMenuUserMenuTourComponent, ], }) export class CoreMainMenuComponentsModule {} diff --git a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html index 187ad5ef7..bd0e01204 100644 --- a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html +++ b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html @@ -1,3 +1,4 @@ + (ariaButtonClick)="openUserMenu($event)" (onAppear)="showTour()" role="button" tabindex="0" + [attr.aria-label]="'core.user.useraccount' | translate" #avatar> diff --git a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts index f1140b4ec..ebde9fb45 100644 --- a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts +++ b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts @@ -12,11 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; import { CoreSiteInfo } from '@classes/site'; +import { CoreUserTours, CoreUserToursStyle } from '@features/usertours/services/user-tours'; import { IonRouterOutlet } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMainMenuUserMenuTourComponent } from '../user-menu-tour/user-menu-tour'; import { CoreMainMenuUserMenuComponent } from '../user-menu/user-menu'; /** @@ -34,6 +36,8 @@ export class CoreMainMenuUserButtonComponent implements OnInit { siteInfo?: CoreSiteInfo; isMainScreen = false; + @ViewChild('avatar', { read: ElementRef }) avatar?: ElementRef; + constructor(protected routerOutlet: IonRouterOutlet) { const currentSite = CoreSites.getRequiredCurrentSite(); @@ -61,4 +65,20 @@ export class CoreMainMenuUserButtonComponent implements OnInit { }); } + /** + * Show User Tour. + */ + async showTour(): Promise { + if (!this.avatar) { + return; + } + + await CoreUserTours.showIfPending({ + id: 'user-menu', + component: CoreMainMenuUserMenuTourComponent, + focus: this.avatar.nativeElement, + style: CoreUserToursStyle.Overlay, + }); + } + } diff --git a/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.html b/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.html new file mode 100644 index 000000000..697f56fcb --- /dev/null +++ b/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.html @@ -0,0 +1,6 @@ + +

{{ 'core.mainmenu.usermenutourtitle' | translate }}

+

{{ 'core.mainmenu.usermenutourdescription' | translate }}

+ + {{ 'core.endonesteptour' | translate }} + diff --git a/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.scss b/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.scss new file mode 100644 index 000000000..faecdd58f --- /dev/null +++ b/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.scss @@ -0,0 +1,26 @@ +:host { + width: 100%; + height: 100%; + display: flex; + max-width: 85vw; + align-items: center; + flex-direction: column; + + img { + width: calc(100vw - var(--core-avatar-size) * 2 - 16px); + margin-top: 12px; + } + + p { + text-align: center; + } + + ion-button { + width: 100%; + } + +} + +:host-context([dir=rtl]) img { + transform: scaleX(-1); +} diff --git a/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.ts b/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.ts new file mode 100644 index 000000000..4d7c1a3d7 --- /dev/null +++ b/src/core/features/mainmenu/components/user-menu-tour/user-menu-tour.ts @@ -0,0 +1,35 @@ +// (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 { Component } from '@angular/core'; +import { CoreUserTours } from '@features/usertours/services/user-tours'; + +/** + * Component showing the User Tour for the User Menu feature. + */ +@Component({ + selector: 'core-mainmenu-user-menu-tour', + templateUrl: 'user-menu-tour.html', + styleUrls: ['user-menu-tour.scss'], +}) +export class CoreMainMenuUserMenuTourComponent { + + /** + * Dismiss the User Tour. + */ + async dismiss(): Promise { + await CoreUserTours.dismiss(); + } + +} diff --git a/src/core/features/mainmenu/lang.json b/src/core/features/mainmenu/lang.json index 9e9977545..b0ff77c61 100644 --- a/src/core/features/mainmenu/lang.json +++ b/src/core/features/mainmenu/lang.json @@ -1,5 +1,7 @@ { "home": "Home", "logout": "Log out", - "switchaccount": "Switch account" + "switchaccount": "Switch account", + "usermenutourdescription": "The place to check your grades, change your preferences or switch accounts.", + "usermenutourtitle": "Explore your personal area" } diff --git a/src/core/features/usertours/classes/focus-layout.ts b/src/core/features/usertours/classes/focus-layout.ts new file mode 100644 index 000000000..e53994efc --- /dev/null +++ b/src/core/features/usertours/classes/focus-layout.ts @@ -0,0 +1,50 @@ +// (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 { renderInlineStyles } from '@/core/utils/style-helpers'; + +/** + * Helper class to calculate layout styles for the focused area in a User Tour. + */ +export class CoreUserToursFocusLayout { + + inlineStyles!: string; + + private targetBoundingBox: DOMRect; + private targetComputedStyle: CSSStyleDeclaration; + + constructor(target: HTMLElement) { + this.targetBoundingBox = target.getBoundingClientRect(); + this.targetComputedStyle = window.getComputedStyle(target); + + this.calculateStyles(); + } + + /** + * Calculate styles. + */ + private calculateStyles(): void { + this.inlineStyles = renderInlineStyles({ + 'top': this.targetBoundingBox.top, + 'left': this.targetBoundingBox.left, + 'width': this.targetBoundingBox.width, + 'height': this.targetBoundingBox.height, + 'border-top-left-radius': this.targetComputedStyle.borderTopLeftRadius, + 'border-top-right-radius': this.targetComputedStyle.borderTopRightRadius, + 'border-bottom-left-radius': this.targetComputedStyle.borderBottomLeftRadius, + 'border-bottom-right-radius': this.targetComputedStyle.borderBottomRightRadius, + }); + } + +} diff --git a/src/core/features/usertours/classes/popover-layout.ts b/src/core/features/usertours/classes/popover-layout.ts new file mode 100644 index 000000000..e218f2be0 --- /dev/null +++ b/src/core/features/usertours/classes/popover-layout.ts @@ -0,0 +1,213 @@ +// (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 { CoreStyles, renderInlineStyles } from '@/core/utils/style-helpers'; +import { Platform } from '@singletons'; +import { CoreUserToursAlignment, CoreUserToursSide } from '../services/user-tours'; + +const ARROW_HEIGHT = 22; +const ARROW_WIDTH = 35; +const BORDER_RADIUS = 8; +const MARGIN = 16; + +/** + * Helper class to calculate layout styles for the popover wrapper in a User Tour. + */ +export class CoreUserToursPopoverLayout { + + wrapperStyles: CoreStyles; + wrapperInlineStyles!: string; + wrapperArrowStyles: CoreStyles; + wrapperArrowInlineStyles!: string; + + private targetBoundingBox: DOMRect; + private side: CoreUserToursSide; + private alignment: CoreUserToursAlignment; + + constructor(target: HTMLElement, side: CoreUserToursSide, alignment: CoreUserToursAlignment) { + this.targetBoundingBox = target.getBoundingClientRect(); + this.side = side; + this.alignment = alignment; + this.wrapperArrowStyles = {}; + this.wrapperStyles = {}; + + this.calculateStyles(); + } + + /** + * Calculate styles. + */ + private calculateStyles(): void { + const sideHandlers: Record void> = { + [CoreUserToursSide.Top]: this.calculateWrapperTopSideStyles, + [CoreUserToursSide.Bottom]: this.calculateWrapperBottomSideStyles, + [CoreUserToursSide.Right]: this.calculateWrapperRightSideStyles, + [CoreUserToursSide.Left]: this.calculateWrapperLeftSideStyles, + [CoreUserToursSide.Start]: Platform.isRTL ? this.calculateWrapperRightSideStyles : this.calculateWrapperLeftSideStyles, + [CoreUserToursSide.End]: Platform.isRTL ? this.calculateWrapperLeftSideStyles : this.calculateWrapperRightSideStyles, + }; + + sideHandlers[this.side].call(this); + + this.wrapperInlineStyles = renderInlineStyles(this.wrapperStyles); + this.wrapperArrowInlineStyles = renderInlineStyles(this.wrapperArrowStyles); + } + + /** + * Calculate wrapper styles for an horizontal alignment. + */ + private calculateWrapperHorizontalAlignmentStyles(): void { + const horizontalAlignmentHandlers: Record void> ={ + [CoreUserToursAlignment.Start]: Platform.isRTL + ? this.calculateWrapperRightAlignmentStyles + : this.calculateWrapperLeftAlignmentStyles, + [CoreUserToursAlignment.Center]: this.calculateWrapperCenterHorizontalAlignmentStyles, + [CoreUserToursAlignment.End]: Platform.isRTL + ? this.calculateWrapperLeftAlignmentStyles + : this.calculateWrapperRightAlignmentStyles, + }; + + horizontalAlignmentHandlers[this.alignment].call(this); + } + + /** + * Calculate wrapper styles for a vertical alignment. + */ + private calculateWrapperVerticalAlignmentStyles(): void { + const verticalAlignmentHandlers: Record void> ={ + [CoreUserToursAlignment.Start]: this.calculateWrapperTopAlignmentStyles, + [CoreUserToursAlignment.Center]: this.calculateWrapperCenterVerticalAlignmentStyles, + [CoreUserToursAlignment.End]: this.calculateWrapperBottomAlignmentStyles, + }; + + verticalAlignmentHandlers[this.alignment].call(this); + } + + /** + * Calculate wrapper arrow styles for an horizontal orientation. + */ + private calculateWrapperArrowHorizontalStyles(): void { + this.wrapperArrowStyles['border-width'] = `${ARROW_WIDTH / 2}px ${ARROW_HEIGHT}px`; + } + + /** + * Calculate wrapper arrow styles for a vertical orientation. + */ + private calculateWrapperArrowVerticalStyles(): void { + this.wrapperArrowStyles['border-width'] = `${ARROW_HEIGHT}px ${ARROW_WIDTH / 2}px`; + } + + /** + * Calculate wrapper styles for a top side placement. + */ + private calculateWrapperTopSideStyles(): void { + this.wrapperStyles.bottom = window.innerHeight - this.targetBoundingBox.y + ARROW_HEIGHT + MARGIN; + this.wrapperArrowStyles.bottom = -ARROW_HEIGHT*2; + this.wrapperArrowStyles['border-top-color'] = 'var(--popover-background)'; + + this.calculateWrapperArrowVerticalStyles(); + this.calculateWrapperHorizontalAlignmentStyles(); + } + + /** + * Calculate wrapper styles for a bottom side placement. + */ + private calculateWrapperBottomSideStyles(): void { + this.wrapperStyles.top = this.targetBoundingBox.y + this.targetBoundingBox.height + ARROW_HEIGHT + MARGIN; + this.wrapperArrowStyles.top = -ARROW_HEIGHT*2; + this.wrapperArrowStyles['border-bottom-color'] = 'var(--popover-background)'; + + this.calculateWrapperArrowVerticalStyles(); + this.calculateWrapperHorizontalAlignmentStyles(); + } + + /** + * Calculate wrapper styles for a right side placement. + */ + private calculateWrapperRightSideStyles(): void { + this.wrapperStyles.left = this.targetBoundingBox.x + this.targetBoundingBox.width + ARROW_HEIGHT + MARGIN; + this.wrapperArrowStyles.left = -ARROW_HEIGHT*2; + this.wrapperArrowStyles['border-right-color'] = 'var(--popover-background)'; + + this.calculateWrapperArrowHorizontalStyles(); + this.calculateWrapperVerticalAlignmentStyles(); + } + + /** + * Calculate wrapper styles for a left side placement. + */ + private calculateWrapperLeftSideStyles(): void { + this.wrapperStyles.right = window.innerWidth - this.targetBoundingBox.x + ARROW_HEIGHT + MARGIN; + this.wrapperArrowStyles.right = -ARROW_HEIGHT*2; + this.wrapperArrowStyles['border-left-color'] = 'var(--popover-background)'; + + this.calculateWrapperArrowHorizontalStyles(); + this.calculateWrapperVerticalAlignmentStyles(); + } + + /** + * Calculate wrapper styles for top alignment. + */ + private calculateWrapperTopAlignmentStyles() { + this.wrapperStyles.top = this.targetBoundingBox.y; + this.wrapperArrowStyles.top = BORDER_RADIUS; + } + + /** + * Calculate wrapper styles for bottom alignment. + */ + private calculateWrapperBottomAlignmentStyles(): void { + this.wrapperStyles.bottom = window.innerHeight - this.targetBoundingBox.y - this.targetBoundingBox.height; + this.wrapperArrowStyles.bottom = BORDER_RADIUS; + } + + /** + * Calculate wrapper styles for right alignment. + */ + private calculateWrapperRightAlignmentStyles() { + this.wrapperStyles.right = window.innerWidth - this.targetBoundingBox.x - this.targetBoundingBox.width; + this.wrapperArrowStyles.right = BORDER_RADIUS; + } + + /** + * Calculate wrapper styles for left alignment. + */ + private calculateWrapperLeftAlignmentStyles() { + this.wrapperStyles.left = this.targetBoundingBox.x; + this.wrapperArrowStyles.left = BORDER_RADIUS; + } + + /** + * Calculate wrapper styles for center horizontal alignment. + */ + private calculateWrapperCenterHorizontalAlignmentStyles() { + this.wrapperStyles.left = this.targetBoundingBox.x + this.targetBoundingBox.width / 2; + this.wrapperStyles.transform = 'translateX(-50%)'; + this.wrapperStyles['transform-origin'] = '0 50%'; + this.wrapperArrowStyles.left = '50%'; + this.wrapperArrowStyles.transform = 'translateX(-50%)'; + } + + /** + * Calculate wrapper styles for center vertical alignment. + */ + private calculateWrapperCenterVerticalAlignmentStyles() { + this.wrapperStyles.top = this.targetBoundingBox.y + this.targetBoundingBox.height / 2; + this.wrapperStyles.transform = 'translateY(-50%)'; + this.wrapperStyles['transform-origin'] = '50% 0'; + this.wrapperArrowStyles.top = '50%'; + this.wrapperArrowStyles.transform = 'translateY(-50%)'; + } + +} diff --git a/src/core/features/usertours/components/components.module.ts b/src/core/features/usertours/components/components.module.ts new file mode 100644 index 000000000..f2fd58527 --- /dev/null +++ b/src/core/features/usertours/components/components.module.ts @@ -0,0 +1,34 @@ +// (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 { NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreUserToursUserTourComponent } from './user-tour/user-tour'; + +/** + * User Tours components module. + */ +@NgModule({ + declarations: [ + CoreUserToursUserTourComponent, + ], + imports: [ + CoreSharedModule, + ], + exports: [ + CoreUserToursUserTourComponent, + ], +}) +export class CoreUserToursComponentsModule {} diff --git a/src/core/features/usertours/components/user-tour/core-user-tours-user-tour.html b/src/core/features/usertours/components/user-tour/core-user-tours-user-tour.html new file mode 100644 index 000000000..da742e058 --- /dev/null +++ b/src/core/features/usertours/components/user-tour/core-user-tours-user-tour.html @@ -0,0 +1,5 @@ +
+
+
+ +
diff --git a/src/core/features/usertours/components/user-tour/user-tour.scss b/src/core/features/usertours/components/user-tour/user-tour.scss new file mode 100644 index 000000000..3cb31e5bc --- /dev/null +++ b/src/core/features/usertours/components/user-tour/user-tour.scss @@ -0,0 +1,61 @@ +:host { + --popover-background: var(--ion-overlay-background-color, var(--ion-background-color, #fff)); + + z-index: 99; + width: 100%; + height: 100%; + display: none; + color: white; + + .user-tour-focus { + position: absolute; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.75); + } + + .user-tour-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.75); + } + + .user-tour-wrapper { + position: absolute; + } + + &.is-active { + display: block; + } + + &.is-popover .user-tour-wrapper { + color: var(--ion-text-color, #000); + background: var(--popover-background); + width: 70vw; + padding: 16px; + border-radius: 8px; + + .user-tour-wrapper-arrow { + position: absolute; + border-style: solid; + border-color: transparent; + } + + } + + &:not(.is-popover) .user-tour-wrapper { + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + +} + +:host-context(body.dark) { + --popover-background: var(--gray-700); +} diff --git a/src/core/features/usertours/components/user-tour/user-tour.ts b/src/core/features/usertours/components/user-tour/user-tour.ts new file mode 100644 index 000000000..5ac56633f --- /dev/null +++ b/src/core/features/usertours/components/user-tour/user-tour.ts @@ -0,0 +1,168 @@ +// (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 { AfterViewInit, Component, ElementRef, HostBinding, Input, ViewChild } from '@angular/core'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreUserToursFocusLayout } from '@features/usertours/classes/focus-layout'; +import { CoreUserToursPopoverLayout } from '@features/usertours/classes/popover-layout'; +import { + CoreUserTours, + CoreUserToursAlignment, + CoreUserToursSide, + CoreUserToursStyle, +} from '@features/usertours/services/user-tours'; +import { CoreDomUtils } from '@services/utils/dom'; +import { AngularFrameworkDelegate } from '@singletons'; +import { CoreComponentsRegistry } from '@singletons/components-registry'; + +const ANIMATION_DURATION = 200; + +/** + * User Tour wrapper component. + * + * User Tours content will be rendered within this component according to the configured style. + */ +@Component({ + selector: 'core-user-tours-user-tour', + templateUrl: 'core-user-tours-user-tour.html', + styleUrls: ['user-tour.scss'], +}) +export class CoreUserToursUserTourComponent implements AfterViewInit { + + @Input() container!: HTMLElement; + @Input() id!: string; + @Input() component!: unknown; + @Input() componentProps?: Record; + @Input() focus?: HTMLElement; + @Input() style?: CoreUserToursStyle; // When this is undefined in a tour with a focused element, popover style will be used. + @Input() side?: CoreUserToursSide; + @Input() alignment?: CoreUserToursAlignment; + @HostBinding('class.is-active') active = false; + @HostBinding('class.is-popover') popover = false; + @ViewChild('wrapper') wrapper?: ElementRef; + + focusStyles?: string; + popoverWrapperStyles?: string; + popoverWrapperArrowStyles?: string; + private element: HTMLElement; + private wrapperTransform = ''; + private wrapperElement = new CorePromisedValue(); + + constructor({ nativeElement: element }: ElementRef) { + this.element = element; + + CoreComponentsRegistry.register(element, this); + } + + /** + * @inheritdoc + */ + ngAfterViewInit(): void { + if (!this.wrapper) { + return; + } + + this.wrapperElement.resolve(this.wrapper.nativeElement); + } + + /** + * Present User Tour. + */ + async present(): Promise { + // Insert tour component and wait until it's ready. + const wrapper = await this.wrapperElement; + const tour = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {}); + + await CoreDomUtils.waitForImages(tour); + + this.calculateStyles(); + + // Show tour. + this.active = true; + + await this.playEnterAnimation(); + } + + /** + * Dismiss User Tour. + * + * @param acknowledge Whether to confirm that the user has seen the User Tour. + */ + async dismiss(acknowledge: boolean = true): Promise { + await this.playLeaveAnimation(); + + AngularFrameworkDelegate.removeViewFromDom(this.container, this.element); + + acknowledge && CoreUserTours.acknowledge(this.id); + } + + /** + * Calculate inline styles. + */ + private calculateStyles(): void { + if (!this.focus) { + return; + } + + // Calculate focus styles. + const focusLayout = new CoreUserToursFocusLayout(this.focus); + + this.focusStyles = focusLayout.inlineStyles; + + // Calculate popup styles. + if ((this.style ?? CoreUserToursStyle.Popover) === CoreUserToursStyle.Popover) { + if (!this.side || !this.alignment) { + throw new Error('Cannot create a popover user tour without side and alignment'); + } + + const popoverLayout = new CoreUserToursPopoverLayout(this.focus, this.side, this.alignment); + + this.popover = true; + this.popoverWrapperStyles = popoverLayout.wrapperInlineStyles; + this.popoverWrapperArrowStyles = popoverLayout.wrapperArrowInlineStyles; + this.wrapperTransform = `${popoverLayout.wrapperStyles.transform ?? ''}`; + } + } + + /** + * Play animation to show that the User Tour has started. + */ + private async playEnterAnimation(): Promise { + const animations = [ + this.element.animate({ opacity: ['0', '1'] }, { duration: ANIMATION_DURATION }), + this.wrapperElement.value?.animate( + { transform: [`scale(1.2) ${this.wrapperTransform}`, `scale(1) ${this.wrapperTransform}`] }, + { duration: ANIMATION_DURATION }, + ), + ]; + + await Promise.all(animations.map(animation => animation?.finished)); + } + + /** + * Play animation to show that the User Tour has endd. + */ + private async playLeaveAnimation(): Promise { + const animations = [ + this.element.animate({ opacity: ['1', '0'] }, { duration: ANIMATION_DURATION }), + this.wrapperElement.value?.animate( + { transform: [`scale(1) ${this.wrapperTransform}`, `scale(1.2) ${this.wrapperTransform}`] }, + { duration: ANIMATION_DURATION }, + ), + ]; + + await Promise.all(animations.map(animation => animation?.finished)); + } + +} diff --git a/src/core/features/usertours/services/database/user-tours.ts b/src/core/features/usertours/services/database/user-tours.ts new file mode 100644 index 000000000..c2252df0d --- /dev/null +++ b/src/core/features/usertours/services/database/user-tours.ts @@ -0,0 +1,48 @@ +// (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 { CoreAppSchema } from '@services/app'; + +/** + * Database variables for CoreUserTours service. + */ +export const USER_TOURS_TABLE_NAME = 'user_tours'; +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreUserTours', + version: 1, + tables: [ + { + name: USER_TOURS_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'acknowledgedTime', + type: 'INTEGER', + }, + ], + }, + ], +}; + +/** + * User Tours database entry. + */ +export type CoreUserToursDBEntry = { + id: string; + acknowledgedTime: number; +}; diff --git a/src/core/features/usertours/services/user-tours.ts b/src/core/features/usertours/services/user-tours.ts new file mode 100644 index 000000000..ff42e12f1 --- /dev/null +++ b/src/core/features/usertours/services/user-tours.ts @@ -0,0 +1,262 @@ +// (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 { CoreConstants } from '@/core/constants'; +import { asyncInstance } from '@/core/utils/async-instance'; +import { Injectable } from '@angular/core'; +import { CoreDatabaseTable } from '@classes/database/database-table'; +import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; +import { CoreApp } from '@services/app'; +import { CoreUtils } from '@services/utils/utils'; +import { AngularFrameworkDelegate, makeSingleton } from '@singletons'; +import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour'; +import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './database/user-tours'; + +/** + * Service to manage User Tours. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserToursService { + + protected table = asyncInstance>(); + protected tours: CoreUserToursUserTourComponent[] = []; + protected tourReadyCallbacks = new WeakMap void>(); + + /** + * Initialize database. + */ + async initializeDatabase(): Promise { + await CoreUtils.ignoreErrors(CoreApp.createTablesFromSchema(APP_SCHEMA)); + + this.table.setLazyConstructor(async () => { + const table = new CoreDatabaseTableProxy( + { cachingStrategy: CoreDatabaseCachingStrategy.Eager }, + CoreApp.getDB(), + USER_TOURS_TABLE_NAME, + ['id'], + ); + + await table.initialize(); + + return table; + }); + } + + /** + * Check whether a User Tour is pending or not. + * + * @param id User Tour id. + * @returns Whether the User Tour is pending or not. + */ + async isPending(id: string): Promise { + if (CoreConstants.CONFIG.disableUserTours || CoreConstants.CONFIG.disabledUserTours?.includes(id)) { + return false; + } + + const isAcknowledged = await this.table.hasAnyByPrimaryKey({ id }); + + return !isAcknowledged; + } + + /** + * Confirm that a User Tour has been seen by the user. + * + * @param id User Tour id. + */ + async acknowledge(id: string): Promise { + await this.table.insert({ id, acknowledgedTime: Date.now() }); + } + + /** + * Show a User Tour if it's pending. + * + * @param options User Tour options. + */ + async showIfPending(options: CoreUserToursBasicOptions): Promise; + async showIfPending(options: CoreUserToursPopoverFocusedOptions): Promise; + async showIfPending(options: CoreUserToursOverlayFocusedOptions): Promise; + async showIfPending(options: CoreUserToursOptions): Promise { + const isPending = await CoreUserTours.isPending(options.id); + + if (!isPending) { + return; + } + + return this.show(options); + } + + /** + * Show a User Tour. + * + * @param options User Tour options. + */ + protected async show(options: CoreUserToursBasicOptions): Promise; + protected async show(options: CoreUserToursPopoverFocusedOptions): Promise; + protected async show(options: CoreUserToursOverlayFocusedOptions): Promise; + protected async show(options: CoreUserToursOptions): Promise { + const { delay, ...componentOptions } = options; + + await CoreUtils.wait(delay ?? 200); + + const container = document.querySelector('ion-app') ?? document.body; + const element = await AngularFrameworkDelegate.attachViewToDom( + container, + CoreUserToursUserTourComponent, + { ...componentOptions, container }, + ); + const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent); + + this.tours.push(tour); + this.tours.length > 1 + ? await new Promise(resolve => this.tourReadyCallbacks.set(tour, resolve)) + : await tour.present(); + } + + /** + * Dismiss the active User Tour, if any. + * + * @param acknowledge Whether to acknowledge that the user has seen this User Tour or not. + */ + async dismiss(acknowledge: boolean = true): Promise { + if (this.tours.length === 0) { + return; + } + + const activeTour = this.tours.shift() as CoreUserToursUserTourComponent; + const nextTour = this.tours[0] as CoreUserToursUserTourComponent | undefined; + + await Promise.all([ + activeTour.dismiss(acknowledge), + nextTour?.present(), + ]); + + nextTour && this.tourReadyCallbacks.get(nextTour)?.(); + } + +} + +export const CoreUserTours = makeSingleton(CoreUserToursService); + +/** + * User Tour style. + */ +export const enum CoreUserToursStyle { + Overlay = 'overlay', + Popover = 'popover', +} + +/** + * User Tour side. + */ +export const enum CoreUserToursSide { + Top = 'top', + Bottom = 'bottom', + Right = 'right', + Left = 'left', + Start = 'start', + End = 'end', +} + +/** + * User Tour alignment. + */ +export const enum CoreUserToursAlignment { + Start = 'start', + Center = 'center', + End = 'end', +} + +/** + * Basic options to create a User Tour. + */ +export interface CoreUserToursBasicOptions { + + /** + * Unique identifier. + */ + id: string; + + /** + * User Tour component. + */ + component: unknown; + + /** + * Properties to pass to the User Tour component. + */ + componentProps?: Record; + + /** + * Milliseconds to wait until the User Tour is shown. + * + * Defaults to 200ms. + */ + delay?: number; + +} + +/** + * Options to create a focused User Tour. + */ +export interface CoreUserToursFocusedOptions extends CoreUserToursBasicOptions { + + /** + * Element to focus. + */ + focus: HTMLElement; + +} + +/** + * Options to create a focused User Tour using the Popover style. + */ +export interface CoreUserToursPopoverFocusedOptions extends CoreUserToursFocusedOptions { + + /** + * User Tour style. + */ + style?: CoreUserToursStyle.Popover; + + /** + * Position relative to the focused element. + */ + side: CoreUserToursSide; + + /** + * Alignment relative to the focused element. + */ + alignment: CoreUserToursAlignment; + +} + +/** + * Options to create a focused User Tour using the Overlay style. + */ +export interface CoreUserToursOverlayFocusedOptions extends CoreUserToursFocusedOptions { + + /** + * User Tour style. + */ + style: CoreUserToursStyle.Overlay; + +} + +/** + * Options to create a User Tour. + */ +export type CoreUserToursOptions = + CoreUserToursBasicOptions | + CoreUserToursPopoverFocusedOptions | + CoreUserToursOverlayFocusedOptions; diff --git a/src/core/features/usertours/user-tours.module.ts b/src/core/features/usertours/user-tours.module.ts new file mode 100644 index 000000000..411530abb --- /dev/null +++ b/src/core/features/usertours/user-tours.module.ts @@ -0,0 +1,37 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreUserToursComponentsModule } from './components/components.module'; +import { CoreUserTours } from './services/user-tours'; + +/** + * User Tours module. + */ +@NgModule({ + imports: [ + CoreUserToursComponentsModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: async () => { + await CoreUserTours.initializeDatabase(); + }, + }, + ], +}) +export class CoreUserToursModule {} diff --git a/src/core/lang.json b/src/core/lang.json index 2a72d84e7..8dd623b4e 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -302,6 +302,7 @@ "submit": "Submit", "success": "Success", "summary": "Summary", + "swipenavigationtourdescription": "Swipe left and right to navigate around.", "tablet": "Tablet", "teachers": "Teachers", "thereisdatatosync": "There are offline {{$a}} to be synchronised.", @@ -329,6 +330,7 @@ "usernotfullysetup": "User not fully set-up", "usernologin": "Authentication has been revoked for this account", "usersuspended": "Registration suspended", + "endonesteptour": "Got it", "users": "Users", "view": "View", "viewcode": "View code", diff --git a/src/core/services/config.ts b/src/core/services/config.ts index a76ec716b..012e7c7bd 100644 --- a/src/core/services/config.ts +++ b/src/core/services/config.ts @@ -23,7 +23,7 @@ import { CoreEvents } from '@singletons/events'; import { CoreDatabaseTable } from '@classes/database/database-table'; import { asyncInstance } from '../utils/async-instance'; import { CorePromisedValue } from '@classes/promised-value'; -import { CoreUtils } from './utils/utils'; +import { CoreBrowser } from '@singletons/browser'; declare module '@singletons/events' { @@ -195,11 +195,11 @@ export class CoreConfigProvider { * Load development config overrides. */ protected loadDevelopmentConfig(): void { - if (!CoreConstants.enableDevTools() || !CoreUtils.hasCookie('MoodleAppConfig')) { + if (!CoreConstants.enableDevTools() || !CoreBrowser.hasCookie('MoodleAppConfig')) { return; } - this.patchEnvironment(JSON.parse(CoreUtils.getCookie('MoodleAppConfig') ?? '{}')); + this.patchEnvironment(JSON.parse(CoreBrowser.getCookie('MoodleAppConfig') ?? '{}')); } /** diff --git a/src/core/services/db.ts b/src/core/services/db.ts index de01fd469..26721e2a7 100644 --- a/src/core/services/db.ts +++ b/src/core/services/db.ts @@ -16,9 +16,9 @@ import { Injectable } from '@angular/core'; import { SQLiteDB } from '@classes/sqlitedb'; import { SQLiteDBMock } from '@features/emulator/classes/sqlitedb'; +import { CoreBrowser } from '@singletons/browser'; import { makeSingleton, SQLite, Platform } from '@singletons'; import { CoreAppProvider } from './app'; -import { CoreUtils } from './utils/utils'; /** * This service allows interacting with the local database to store and retrieve data. @@ -36,7 +36,7 @@ export class CoreDbProvider { * @returns Whether queries should be logged. */ loggingEnabled(): boolean { - return CoreUtils.hasCookie('MoodleAppDBLoggingEnabled') || CoreAppProvider.isAutomated(); + return CoreBrowser.hasCookie('MoodleAppDBLoggingEnabled') || CoreAppProvider.isAutomated(); } /** diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index a91e89caf..390fe0c53 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -806,6 +806,24 @@ export class CoreDomUtilsProvider { return elementPoint > window.innerHeight || elementPoint < scrollTopPos; } + /** + * Check whether an element is visible or not. + * + * @param element Element. + */ + isElementVisible(element: HTMLElement): boolean { + if (element.clientWidth === 0 || element.clientHeight === 0) { + return false; + } + + const style = getComputedStyle(element); + if (style.opacity === '0' || style.display === 'none' || style.visibility === 'hidden') { + return false; + } + + return element.offsetParent !== null; + } + /** * Check if rich text editor is enabled. * diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 08150ed3f..ef080cc23 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -1765,34 +1765,6 @@ export class CoreUtilsProvider { return CoreApp.isIOS() && openFileAction == OpenFileAction.OPEN_WITH; } - /** - * Check whether the given cookie is set. - * - * @param name Cookie name. - * @returns Whether the cookie is set. - */ - hasCookie(name: string): boolean { - return new RegExp(`(\\s|;|^)${name}=`).test(document.cookie ?? ''); - } - - /** - * Read a cookie. - * - * @param name Cookie name. - * @return Cookie value. - */ - getCookie(name: string): string | null { - const cookies = (document.cookie ?? '').split(';').reduce((cookies, cookie) => { - const [name, value] = cookie.trim().split('='); - - cookies[name] = value; - - return cookies; - }, {}); - - return cookies[name] ?? null; - } - } export const CoreUtils = makeSingleton(CoreUtilsProvider); diff --git a/src/core/singletons/browser.ts b/src/core/singletons/browser.ts new file mode 100644 index 000000000..9ba90176b --- /dev/null +++ b/src/core/singletons/browser.ts @@ -0,0 +1,48 @@ +// (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. + +/** + * Helpers to interact with Browser APIs. + */ +export class CoreBrowser { + + /** + * Check whether the given cookie is set. + * + * @param name Cookie name. + * @returns Whether the cookie is set. + */ + static hasCookie(name: string): boolean { + return new RegExp(`(\\s|;|^)${name}=`).test(document.cookie ?? ''); + } + + /** + * Read a cookie. + * + * @param name Cookie name. + * @return Cookie value. + */ + static getCookie(name: string): string | null { + const cookies = (document.cookie ?? '').split(';').reduce((cookies, cookie) => { + const [name, value] = cookie.trim().split('='); + + cookies[name] = value; + + return cookies; + }, {}); + + return cookies[name] ?? null; + } + +} diff --git a/src/core/singletons/components-registry.ts b/src/core/singletons/components-registry.ts index db60b35a8..5198ee90b 100644 --- a/src/core/singletons/components-registry.ts +++ b/src/core/singletons/components-registry.ts @@ -47,6 +47,23 @@ export class CoreComponentsRegistry { : null; } + /** + * Get a component instances and fail if it cannot be resolved. + * + * @param element Root element. + * @param componentClass Component class. + * @returns Component instance. + */ + static require(element: Element, componentClass?: ComponentConstructor): T { + const instance = this.resolve(element, componentClass); + + if (!instance) { + throw new Error('Couldn\'t resolve component instance'); + } + + return instance; + } + /** * Waits all elements to be rendered. * diff --git a/src/core/singletons/index.ts b/src/core/singletons/index.ts index cb3a81e9a..0b57ec3fc 100644 --- a/src/core/singletons/index.ts +++ b/src/core/singletons/index.ts @@ -12,13 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AbstractType, ApplicationInitStatus, ApplicationRef, Injector, NgZone as NgZoneService, Type } from '@angular/core'; +import { + AbstractType, + ApplicationInitStatus, + ApplicationRef, + ComponentFactoryResolver as ComponentFactoryResolverService, + Injector, + NgZone as NgZoneService, + Type, +} from '@angular/core'; import { Router as RouterService } from '@angular/router'; import { HttpClient } from '@angular/common/http'; import { DomSanitizer as DomSanitizerService } from '@angular/platform-browser'; import { Platform as PlatformService, + AngularDelegate as AngularDelegateService, AlertController as AlertControllerService, LoadingController as LoadingControllerService, ModalController as ModalControllerService, @@ -58,11 +67,13 @@ import { Zip as ZipService } from '@ionic-native/zip/ngx'; import { TranslateService } from '@ngx-translate/core'; import { CoreApplicationInitStatus } from '@classes/application-init-status'; +import { asyncInstance } from '@/core/utils/async-instance'; +import { CorePromisedValue } from '@classes/promised-value'; /** * Injector instance used to resolve singletons. */ -let singletonsInjector: Injector | null = null; +const singletonsInjector = new CorePromisedValue(); /** * Helper to create a method that proxies calls to the underlying singleton instance. @@ -87,7 +98,7 @@ export type CoreSingletonProxy = Service & { * @param injector Module injector. */ export function setSingletonsInjector(injector: Injector): void { - singletonsInjector = injector; + singletonsInjector.resolve(injector); } /** @@ -127,11 +138,13 @@ export function makeSingleton( // eslint-disabl Object.defineProperty(singleton, 'instance', { get: () => { - if (!singletonsInjector) { + const injector = singletonsInjector.value; + + if (!injector) { throw new Error('Can\'t resolve a singleton instance without an injector'); } - const instance = singletonsInjector.get(injectionToken); + const instance = injector.get(injectionToken); singleton.setInstance(instance); @@ -194,7 +207,9 @@ export const NgZone = makeSingleton(NgZoneService); export const Http = makeSingleton(HttpClient); export const Platform = makeSingleton(PlatformService); export const ActionSheetController = makeSingleton(ActionSheetControllerService); +export const AngularDelegate = makeSingleton(AngularDelegateService); export const AlertController = makeSingleton(AlertControllerService); +export const ComponentFactoryResolver = makeSingleton(ComponentFactoryResolverService); export const LoadingController = makeSingleton(LoadingControllerService); export const ModalController = makeSingleton(ModalControllerService); export const PopoverController = makeSingleton(PopoverControllerService); @@ -208,3 +223,10 @@ export const DomSanitizer = makeSingleton(DomSanitizerService); // Convert external libraries injectables. export const Translate = makeSingleton(TranslateService); + +// Async singletons. +export const AngularFrameworkDelegate = asyncInstance(async () => { + const injector = await singletonsInjector; + + return AngularDelegate.create(ComponentFactoryResolver.instance, injector); +}); diff --git a/src/core/singletons/logger.ts b/src/core/singletons/logger.ts index be7209703..27c61fe23 100644 --- a/src/core/singletons/logger.ts +++ b/src/core/singletons/logger.ts @@ -17,6 +17,7 @@ import moment from 'moment'; import { CoreConstants } from '@/core/constants'; import { CoreTime } from './time'; +import { CoreBrowser } from '@singletons/browser'; /** * Method to warn that logs are disabled, called only once. @@ -67,7 +68,10 @@ export class CoreLogger { */ static getInstance(className: string): CoreLogger { // Disable log on production and testing. - if (CoreConstants.BUILD.isProduction || CoreConstants.BUILD.isTesting) { + if ( + !CoreBrowser.hasCookie('MoodleAppLoggingEnabled') && + (CoreConstants.BUILD.isProduction || CoreConstants.BUILD.isTesting) + ) { if (CoreConstants.BUILD.isProduction) { warnLogsDisabled(); } diff --git a/src/core/utils/async-instance.ts b/src/core/utils/async-instance.ts index f687e8a70..977a8b83e 100644 --- a/src/core/utils/async-instance.ts +++ b/src/core/utils/async-instance.ts @@ -54,6 +54,20 @@ function createAsyncInstanceWrapper(lazyConstructor?: () => T | Promise): promisedInstance.resolve(instance); }, + setLazyConstructor(constructor) { + if (!promisedInstance) { + lazyConstructor = constructor; + + return; + } + + if (!promisedInstance.isResolved()) { + // eslint-disable-next-line promise/catch-or-return + Promise + .resolve(constructor()) + .then(instance => promisedInstance?.isResolved() || promisedInstance?.resolve(instance)); + } + }, resetInstance() { if (!promisedInstance) { return; @@ -72,6 +86,7 @@ export interface AsyncInstanceWrapper { getInstance(): Promise; getProperty

(property: P): Promise; setInstance(instance: T): void; + setLazyConstructor(lazyConstructor: () => T | Promise): void; resetInstance(): void; } diff --git a/src/core/utils/style-helpers.ts b/src/core/utils/style-helpers.ts new file mode 100644 index 000000000..45ed8aa36 --- /dev/null +++ b/src/core/utils/style-helpers.ts @@ -0,0 +1,36 @@ +// (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. + +/** + * Element styles. + * + * Number styles are interpreted as pixels; any other values should be set as a string. + */ +export type CoreStyles = Record; + +/** + * Render the given styles to be used inline on an element. + * + * @param styles Styles. + * @returns Inline styles. + */ +export function renderInlineStyles(styles: CoreStyles): string { + return Object + .entries(styles) + .reduce((renderedStyles, [property, value]) => { + const propertyValue = typeof value === 'string' ? value : `${value}px`; + + return `${property}:${propertyValue};${renderedStyles}`; + }, ''); +} diff --git a/src/types/config.d.ts b/src/types/config.d.ts index ef01af18a..024c0dfb7 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -34,6 +34,8 @@ export interface EnvironmentConfig { languages: Record; databaseOptimizations?: Partial; databaseTableOptimizations?: Record>; + disableUserTours?: boolean; + disabledUserTours?: string[]; wsservice: string; demo_sites: Record; zoomlevels: Record;