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/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/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/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..ed328b406 --- /dev/null +++ b/src/core/features/usertours/services/user-tours.ts @@ -0,0 +1,257 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { asyncInstance } 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 { + 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/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/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}`; + }, ''); +}