MOBILE-3153 usertours: Implement User Tours

main
Noel De Martin 2022-03-08 17:39:22 +01:00
parent cd323719db
commit 58e6be64e4
20 changed files with 1019 additions and 6 deletions

View File

@ -139,7 +139,6 @@ const appConfig = {
'always',
],
'@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/unified-signatures': 'error',
'header/header': [
2,
'line',

View File

@ -137,6 +137,13 @@ export class CoreDatabaseTableProxy<
return this.target.hasAny(conditions);
}
/**
* @inheritdoc
*/
hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise<boolean> {
return this.target.hasAnyByPrimaryKey(primaryKey);
}
/**
* @inheritdoc
*/

View File

@ -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<boolean> {
try {
await this.getOneByPrimaryKey(primaryKey);
return true;
} catch (error) {
// Couldn't get the record.
return false;
}
}
/**
* Count records in table.
*

View File

@ -129,6 +129,15 @@ export class CoreDebugDatabaseTable<
return this.target.hasAny(conditions);
}
/**
* @inheritdoc
*/
hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise<boolean> {
this.logger.log('hasAnyByPrimaryKey', primaryKey);
return this.target.hasAnyByPrimaryKey(primaryKey);
}
/**
* @inheritdoc
*/

View File

@ -134,6 +134,13 @@ export class CoreEagerDatabaseTable<
: Object.values(this.records).length > 0;
}
/**
* @inheritdoc
*/
async hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise<boolean> {
return this.serializePrimaryKey(primaryKey) in this.records;
}
/**
* @inheritdoc
*/

View File

@ -125,6 +125,15 @@ export class CoreLazyDatabaseTable<
return super.hasAny(conditions);
}
/**
* @inheritdoc
*/
async hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise<boolean> {
const record = this.records[this.serializePrimaryKey(primaryKey)] ?? null;
return record !== null;
}
/**
* @inheritdoc
*/

View File

@ -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,

View File

@ -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,
});
}
}

View File

@ -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<CoreUserToursSide, () => 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<CoreUserToursAlignment, () => 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<CoreUserToursAlignment, () => 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%)';
}
}

View File

@ -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 {}

View File

@ -0,0 +1,5 @@
<div *ngIf="focusStyles" class="user-tour-focus" [style]="focusStyles"></div>
<div *ngIf="!focusStyles" class="user-tour-overlay"></div>
<div class="user-tour-wrapper" [style]="popoverWrapperStyles" #wrapper>
<span *ngIf="popover" class="user-tour-wrapper-arrow" [style]="popoverWrapperArrowStyles"></span>
</div>

View File

@ -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);
}

View File

@ -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<string, unknown>;
@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<HTMLElement>;
focusStyles?: string;
popoverWrapperStyles?: string;
popoverWrapperArrowStyles?: string;
private element: HTMLElement;
private wrapperTransform = '';
private wrapperElement = new CorePromisedValue<HTMLElement>();
constructor({ nativeElement: element }: ElementRef<HTMLElement>) {
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<void> {
// 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<void> {
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<void> {
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<void> {
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));
}
}

View File

@ -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;
};

View File

@ -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<CoreDatabaseTable<CoreUserToursDBEntry>>();
protected tours: CoreUserToursUserTourComponent[] = [];
protected tourReadyCallbacks = new WeakMap<CoreUserToursUserTourComponent, () => void>();
/**
* Initialize database.
*/
async initializeDatabase(): Promise<void> {
await CoreUtils.ignoreErrors(CoreApp.createTablesFromSchema(APP_SCHEMA));
this.table.setLazyConstructor(async () => {
const table = new CoreDatabaseTableProxy<CoreUserToursDBEntry>(
{ 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<boolean> {
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<void> {
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<void>;
async showIfPending(options: CoreUserToursPopoverFocusedOptions): Promise<void>;
async showIfPending(options: CoreUserToursOverlayFocusedOptions): Promise<void>;
async showIfPending(options: CoreUserToursOptions): Promise<void> {
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<void>;
protected async show(options: CoreUserToursPopoverFocusedOptions): Promise<void>;
protected async show(options: CoreUserToursOverlayFocusedOptions): Promise<void>;
protected async show(options: CoreUserToursOptions): Promise<void> {
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<void>(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<void> {
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<string, unknown>;
/**
* 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;

View File

@ -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 {}

View File

@ -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<T>(element: Element, componentClass?: ComponentConstructor<T>): 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.
*

View File

@ -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<Injector>();
/**
* Helper to create a method that proxies calls to the underlying singleton instance.
@ -87,7 +98,7 @@ export type CoreSingletonProxy<Service = unknown> = Service & {
* @param injector Module injector.
*/
export function setSingletonsInjector(injector: Injector): void {
singletonsInjector = injector;
singletonsInjector.resolve(injector);
}
/**
@ -127,11 +138,13 @@ export function makeSingleton<Service extends object = object>( // 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);
});

View File

@ -54,6 +54,20 @@ function createAsyncInstanceWrapper<T>(lazyConstructor?: () => T | Promise<T>):
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<T> {
getInstance(): Promise<T>;
getProperty<P extends keyof T>(property: P): Promise<T[P]>;
setInstance(instance: T): void;
setLazyConstructor(lazyConstructor: () => T | Promise<T>): void;
resetInstance(): void;
}

View File

@ -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<string, string | number>;
/**
* 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}`;
}, '');
}