diff --git a/package-lock.json b/package-lock.json index 8183fe042..229012d24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -390,6 +390,14 @@ } } }, + "@angular/animations": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-11.0.1.tgz", + "integrity": "sha512-RS2ZsO3yidn/dMAllR+V0EX5BOQLQDi5s2kvd4wANHYAkU/yVXWKl09nbe8LTwLVH+iOYX7AAcAUUokQPEEHxQ==", + "requires": { + "tslib": "^2.0.0" + } + }, "@angular/cli": { "version": "10.0.8", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-10.0.8.tgz", @@ -6514,7 +6522,7 @@ "integrity": "sha512-EYC5eQFVkoYXq39l7tYKE6lEjHJ04mvTmKXxGL7quHLdFPfJMNzru/UYpn92AOfpl3PQaZmou78C7EgmFOwFQQ==" }, "cordova-plugin-wkuserscript": { - "version": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git#aa77d0f98a3fb106f2e798e5adf5882f01a2c947", + "version": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git#6413f4bb3c2565f353e690b5c1450b69ad9e860e", "from": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git" }, "cordova-plugin-wkwebview-cookies": { diff --git a/package.json b/package.json index 22bee0290..d85aa2a26 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "ionic:build:before": "npx gulp" }, "dependencies": { + "@angular/animations": "^11.0.1", "@angular/common": "~10.0.0", "@angular/core": "~10.0.0", "@angular/forms": "~10.0.0", diff --git a/src/addons/privatefiles/pages/index/index.html b/src/addons/privatefiles/pages/index/index.html index cc6728c7b..435540fae 100644 --- a/src/addons/privatefiles/pages/index/index.html +++ b/src/addons/privatefiles/pages/index/index.html @@ -49,7 +49,7 @@ - + diff --git a/src/addons/privatefiles/services/privatefiles.ts b/src/addons/privatefiles/services/privatefiles.ts index e816bbd09..d0f3101d8 100644 --- a/src/addons/privatefiles/services/privatefiles.ts +++ b/src/addons/privatefiles/services/privatefiles.ts @@ -20,6 +20,8 @@ import { CoreWSExternalWarning } from '@services/ws'; import { CoreSite } from '@classes/site'; import { makeSingleton } from '@singletons/core.singletons'; +const ROOT_CACHE_KEY = 'mmaFiles:'; + /** * Service to handle my files and site files. */ @@ -32,8 +34,6 @@ export class AddonPrivateFilesProvider { static readonly PRIVATE_FILES_COMPONENT = 'mmaFilesMy'; static readonly SITE_FILES_COMPONENT = 'mmaFilesSite'; - protected readonly ROOT_CACHE_KEY = 'mmaFiles:'; - /** * Check if core_user_get_private_files_info WS call is available. * @@ -125,7 +125,7 @@ export class AddonPrivateFilesProvider { protected getFilesListCacheKey(params: AddonPrivateFilesGetFilesWSParams): string { const root = !params.component ? 'site' : 'my'; - return this.ROOT_CACHE_KEY + 'list:' + root + ':' + params.contextid + ':' + params.filepath; + return ROOT_CACHE_KEY + 'list:' + root + ':' + params.contextid + ':' + params.filepath; } /** @@ -194,7 +194,7 @@ export class AddonPrivateFilesProvider { * @return Cache key. */ protected getPrivateFilesInfoCommonCacheKey(): string { - return this.ROOT_CACHE_KEY + 'privateInfo'; + return ROOT_CACHE_KEY + 'privateInfo'; } /** @@ -474,7 +474,7 @@ export type AddonPrivateFilesGetFilesWSResult = { /** * Params of core_user_get_private_files_info WS. */ -export type AddonPrivateFilesGetUserInfoWSParams = { +type AddonPrivateFilesGetUserInfoWSParams = { userid?: number; // Id of the user, default to current user. }; @@ -492,6 +492,6 @@ export type AddonPrivateFilesGetUserInfoWSResult = { /** * Params of core_user_add_user_private_files WS. */ -export type AddonPrivateFilesAddUserPrivateFilesWSParams = { +type AddonPrivateFilesAddUserPrivateFilesWSParams = { draftid: number; // Draft area id. }; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0206d2dde..b21e7da51 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouteReuseStrategy } from '@angular/router'; import { HttpClient, HttpClientModule } from '@angular/common/http'; @@ -38,6 +39,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { entryComponents: [], imports: [ BrowserModule, + BrowserAnimationsModule, IonicModule.forRoot(), HttpClientModule, // HttpClient is used to make JSON requests. It fails for HEAD requests because there is no content. TranslateModule.forRoot({ diff --git a/src/core/classes/animations.ts b/src/core/classes/animations.ts new file mode 100644 index 000000000..510ef6d88 --- /dev/null +++ b/src/core/classes/animations.ts @@ -0,0 +1,65 @@ +// (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 { trigger, style, transition, animate, keyframes } from '@angular/animations'; + +export const coreShowHideAnimation = trigger('coreShowHideAnimation', [ + transition(':enter', [ + style({ opacity: 0 }), + animate('500ms ease-in-out', style({ opacity: 1 })), + ]), + transition(':leave', [ + style({ opacity: 1 }), + animate('500ms ease-in-out', style({ opacity: 0 })), + ]), +]); + +export const coreSlideInOut = trigger('coreSlideInOut', [ + // Enter animation. + transition('void => fromLeft', [ + style({ transform: 'translateX(0)', opacity: 1 }), + animate(300, keyframes([ + style({ opacity: 0, transform: 'translateX(-100%)', offset: 0 }), + style({ opacity: 1, transform: 'translateX(5%)', offset: 0.7 }), + style({ opacity: 1, transform: 'translateX(0)', offset: 1.0 }), + ])), + ]), + // Leave animation. + transition('fromLeft => void', [ + style({ transform: 'translateX(-100%)', opacity: 0 }), + animate(300, keyframes([ + style({ opacity: 1, transform: 'translateX(0)', offset: 0 }), + style({ opacity: 1, transform: 'translateX(5%)', offset: 0.3 }), + style({ opacity: 0, transform: 'translateX(-100%)', offset: 1.0 }), + ])), + ]), + // Enter animation. + transition('void => fromRight', [ + style({ transform: 'translateX(0)', opacity: 1 }), + animate(300, keyframes([ + style({ opacity: 0, transform: 'translateX(100%)', offset: 0 }), + style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.7 }), + style({ opacity: 1, transform: 'translateX(0)', offset: 1.0 }), + ])), + ]), + // Leave animation. + transition('fromRight => void', [ + style({ transform: 'translateX(-100%)', opacity: 0 }), + animate(300, keyframes([ + style({ opacity: 1, transform: 'translateX(0)', offset: 0 }), + style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.3 }), + style({ opacity: 0, transform: 'translateX(100%)', offset: 1.0 }), + ])), + ]), +]); diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 0137d6548..feb716d63 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -30,9 +30,15 @@ import { CoreRecaptchaModalComponent } from './recaptcha/recaptchamodal'; import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreEmptyBoxComponent } from './empty-box/empty-box'; import { CoreTabsComponent } from './tabs/tabs'; +import { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading'; +import { CoreProgressBarComponent } from './progress-bar/progress-bar'; +import { CoreContextMenuComponent } from './context-menu/context-menu'; +import { CoreContextMenuItemComponent } from './context-menu/context-menu-item'; +import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; +import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; @NgModule({ declarations: [ @@ -49,6 +55,12 @@ import { CorePipesModule } from '@pipes/pipes.module'; CoreShowPasswordComponent, CoreEmptyBoxComponent, CoreTabsComponent, + CoreInfiniteLoadingComponent, + CoreProgressBarComponent, + CoreContextMenuComponent, + CoreContextMenuItemComponent, + CoreContextMenuPopoverComponent, + CoreNavBarButtonsComponent, ], imports: [ CommonModule, @@ -71,6 +83,12 @@ import { CorePipesModule } from '@pipes/pipes.module'; CoreShowPasswordComponent, CoreEmptyBoxComponent, CoreTabsComponent, + CoreInfiniteLoadingComponent, + CoreProgressBarComponent, + CoreContextMenuComponent, + CoreContextMenuItemComponent, + CoreContextMenuPopoverComponent, + CoreNavBarButtonsComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/context-menu/context-menu-item.ts b/src/core/components/context-menu/context-menu-item.ts new file mode 100644 index 000000000..0fb926e2a --- /dev/null +++ b/src/core/components/context-menu/context-menu-item.ts @@ -0,0 +1,106 @@ +// (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, Input, Output, OnInit, OnDestroy, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; +import { CoreContextMenuComponent } from './context-menu'; + +/** + * This directive adds a item to the Context Menu popover. + * + * @description + * This directive defines and item to be added to the popover generated in CoreContextMenu. + * + * It is required to place this tag inside a core-context-menu tag. + * + * + * + * + */ +@Component({ + selector: 'core-context-menu-item', + template: '', +}) +export class CoreContextMenuItemComponent implements OnInit, OnDestroy, OnChanges { + + @Input() content?: string; // Content of the item. + @Input() iconDescription?: string; // Name of the icon to be shown on the left side of the item. + @Input() iconAction?: string; // Name of the icon to show on the right side of the item. Represents the action to do on click. + // If is "spinner" an spinner will be shown. + // If no icon or spinner is selected, no action or link will work. + // If href but no iconAction is provided arrow-right will be used. + @Input() iconSlash?: boolean; // Display a red slash over the icon. + @Input() ariaDescription?: string; // Aria label to add to iconDescription. + @Input() ariaAction?: string; // Aria label to add to iconAction. If not set, it will be equal to content. + @Input() href?: string; // Link to go if no action provided. + @Input() captureLink?: boolean | string; // Whether the link needs to be captured by the app. + @Input() autoLogin?: string; // Whether the link needs to be opened using auto-login. + @Input() closeOnClick = true; // Whether to close the popover when the item is clicked. + @Input() priority?: number; // Used to sort items. The highest priority, the highest position. + @Input() badge?: string; // A badge to show in the item. + @Input() badgeClass?: number; // A class to set in the badge. + @Input() hidden?: boolean; // Whether the item should be hidden. + @Output() action?: EventEmitter<() => void>; // Will emit an event when the item clicked. + @Output() onClosed?: EventEmitter<() => void>; // Will emit an event when the popover is closed because the item was clicked. + + protected hasAction = false; + protected destroyed = false; + + constructor( + protected ctxtMenu: CoreContextMenuComponent, + ) { + this.action = new EventEmitter(); + this.onClosed = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Initialize values. + this.priority = this.priority || 1; + this.hasAction = !!this.action && this.action.observers.length > 0; + this.ariaAction = this.ariaAction || this.content; + + if (this.hasAction) { + this.href = ''; + } + + // Navigation help if href provided. + this.captureLink = this.href && this.captureLink ? this.captureLink : false; + this.autoLogin = this.autoLogin || 'check'; + + if (!this.destroyed) { + this.ctxtMenu.addItem(this); + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.destroyed = true; + this.ctxtMenu.removeItem(this); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.hidden && !changes.hidden.firstChange) { + this.ctxtMenu.itemsChanged(); + } + } + +} diff --git a/src/core/components/context-menu/context-menu-popover.scss b/src/core/components/context-menu/context-menu-popover.scss new file mode 100644 index 000000000..4efe68f89 --- /dev/null +++ b/src/core/components/context-menu/context-menu-popover.scss @@ -0,0 +1,5 @@ +:host { + ion-list { + padding: 0; + } +} diff --git a/src/core/components/context-menu/context-menu-popover.ts b/src/core/components/context-menu/context-menu-popover.ts new file mode 100644 index 000000000..89e3861df --- /dev/null +++ b/src/core/components/context-menu/context-menu-popover.ts @@ -0,0 +1,77 @@ +// (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 { NavParams, PopoverController } from '@ionic/angular'; +import { CoreContextMenuItemComponent } from './context-menu-item'; + +/** + * Component to display a list of items received by param in a popover. + */ +@Component({ + selector: 'core-context-menu-popover', + templateUrl: 'core-context-menu-popover.html', + styleUrls: ['context-menu-popover.scss'], +}) +export class CoreContextMenuPopoverComponent { + + title: string; + uniqueId: string; + items: CoreContextMenuItemComponent[]; + + constructor( + navParams: NavParams, + protected popoverCtrl: PopoverController, + ) { + this.title = navParams.get('title'); + this.items = navParams.get('items') || []; + this.uniqueId = navParams.get('id'); + } + + /** + * Close the popover. + */ + closeMenu(item?: CoreContextMenuItemComponent): void { + this.popoverCtrl.dismiss(item); + } + + /** + * Function called when an item is clicked. + * + * @param event Click event. + * @param item Item clicked. + * @return Return true if success, false if error. + */ + itemClicked(event: Event, item: CoreContextMenuItemComponent): boolean { + if (!!item.action && item.action.observers.length > 0) { + event.preventDefault(); + event.stopPropagation(); + + if (item.iconAction == 'spinner') { + return false; + } + + if (item.closeOnClick) { + this.closeMenu(item); + } + + item.action.emit(this.closeMenu.bind(this, item)); + } else if (item.closeOnClick && (item.href || (!!item.onClosed && item.onClosed.observers.length > 0))) { + this.closeMenu(item); + } + + return true; + } + +} diff --git a/src/core/components/context-menu/context-menu.ts b/src/core/components/context-menu/context-menu.ts new file mode 100644 index 000000000..86c5940bd --- /dev/null +++ b/src/core/components/context-menu/context-menu.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 { Component, Input, OnInit, OnDestroy, ElementRef } from '@angular/core'; +import { Subject } from 'rxjs'; +import { auditTime } from 'rxjs/operators'; +import { PopoverController } from '@ionic/angular'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons/core.singletons'; +import { CoreContextMenuItemComponent } from './context-menu-item'; +import { CoreContextMenuPopoverComponent } from './context-menu-popover'; + +/** + * This component adds a button (usually in the navigation bar) that displays a context menu popover. + */ +@Component({ + selector: 'core-context-menu', + templateUrl: 'core-context-menu.html', +}) +export class CoreContextMenuComponent implements OnInit, OnDestroy { + + @Input() icon?: string; // Icon to be shown on the navigation bar. Default: Kebab menu icon. + @Input() title?: string; // Text to be shown on the top of the popover. + @Input('aria-label') ariaLabel?: string; // Aria label to be shown on the top of the popover. + + hideMenu = true; // It will be unhidden when items are added. + expanded = false; + protected items: CoreContextMenuItemComponent[] = []; + protected itemsMovedToParent: CoreContextMenuItemComponent[] = []; + protected itemsChangedStream: Subject; // Stream to update the hideMenu boolean when items change. + protected instanceId: string; + protected parentContextMenu?: CoreContextMenuComponent; + protected uniqueId: string; + + constructor( + protected popoverCtrl: PopoverController, + elementRef: ElementRef, + ) { + // Create the stream and subscribe to it. We ignore successive changes during 250ms. + this.itemsChangedStream = new Subject(); + this.itemsChangedStream.pipe(auditTime(250)); + this.itemsChangedStream.subscribe(() => { + // Hide the menu if all items are hidden. + this.hideMenu = !this.items.some((item) => !item.hidden); + + // Sort the items by priority. + this.items.sort((a, b) => (a.priority || 0) <= (b.priority || 0) ? 1 : -1); + }); + + // Calculate the unique ID. + this.uniqueId = 'core-context-menu-' + CoreUtils.instance.getUniqueId('CoreContextMenuComponent'); + + this.instanceId = CoreDomUtils.instance.storeInstanceByElement(elementRef.nativeElement, this); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.icon = this.icon || 'ellipsis-vertical'; + this.ariaLabel = this.ariaLabel || this.title || Translate.instance.instant('core.displayoptions'); + } + + /** + * Add a context menu item. + * + * @param item The item to add. + */ + addItem(item: CoreContextMenuItemComponent): void { + if (this.parentContextMenu) { + // All items were moved to the "parent" menu. Add the item in there. + this.parentContextMenu.addItem(item); + + if (this.itemsMovedToParent.indexOf(item) == -1) { + this.itemsMovedToParent.push(item); + } + } else if (this.items.indexOf(item) == -1) { + this.items.push(item); + this.itemsChanged(); + } + } + + /** + * Function called when the items change. + */ + itemsChanged(): void { + if (this.parentContextMenu) { + // All items were moved to the "parent" menu, call the function in there. + this.parentContextMenu.itemsChanged(); + } else { + this.itemsChangedStream.next(); + } + } + + /** + * Merge the current context menu with the one passed as parameter. All the items in this menu will be moved to the + * one passed as parameter. + * + * @param contextMenu The context menu where to move the items. + */ + mergeContextMenus(contextMenu: CoreContextMenuComponent): void { + this.parentContextMenu = contextMenu; + + // Add all the items to the other menu. + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + contextMenu.addItem(item); + this.itemsMovedToParent.push(item); + } + + // Remove all items from the current menu. + this.items = []; + this.itemsChanged(); + } + + /** + * Remove an item from the context menu. + * + * @param item The item to remove. + */ + removeItem(item: CoreContextMenuItemComponent): void { + if (this.parentContextMenu) { + // All items were moved to the "parent" menu. Remove the item from there. + this.parentContextMenu.removeItem(item); + + const index = this.itemsMovedToParent.indexOf(item); + if (index >= 0) { + this.itemsMovedToParent.splice(index, 1); + } + } else { + const index = this.items.indexOf(item); + if (index >= 0) { + this.items.splice(index, 1); + } + this.itemsChanged(); + } + } + + /** + * Remove the items that were merged to a parent context menu. + */ + removeMergedItems(): void { + if (this.parentContextMenu) { + for (let i = 0; i < this.itemsMovedToParent.length; i++) { + this.parentContextMenu.removeItem(this.itemsMovedToParent[i]); + } + } + } + + /** + * Restore the items that were merged to a parent context menu. + */ + restoreMergedItems(): void { + if (this.parentContextMenu) { + for (let i = 0; i < this.itemsMovedToParent.length; i++) { + this.parentContextMenu.addItem(this.itemsMovedToParent[i]); + } + } + } + + /** + * Show the context menu. + * + * @param event Event. + */ + async showContextMenu(event: MouseEvent): Promise { + if (!this.expanded) { + const popover = await this.popoverCtrl.create( + { + event, + component: CoreContextMenuPopoverComponent, + componentProps: { + title: this.title, + items: this.items, + }, + showBackdrop: true, + id: this.uniqueId, + }, + ); + await popover.present(); + this.expanded = true; + + const data = await popover.onDidDismiss(); + this.expanded = false; + + if (data.data) { + data.data.onClosed?.emit(); + } + + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + CoreDomUtils.instance.removeInstanceById(this.instanceId); + this.removeMergedItems(); + } + +} diff --git a/src/core/components/context-menu/core-context-menu-popover.html b/src/core/components/context-menu/core-context-menu-popover.html new file mode 100644 index 000000000..36cfcacdd --- /dev/null +++ b/src/core/components/context-menu/core-context-menu-popover.html @@ -0,0 +1,15 @@ + + {{title}} + + + + + + + + {{item.badge}} + + diff --git a/src/core/components/context-menu/core-context-menu.html b/src/core/components/context-menu/core-context-menu.html new file mode 100644 index 000000000..adac51104 --- /dev/null +++ b/src/core/components/context-menu/core-context-menu.html @@ -0,0 +1,6 @@ + + + + + diff --git a/src/core/components/download-refresh/core-download-refresh.html b/src/core/components/download-refresh/core-download-refresh.html index 633654ff2..c70f87a0e 100644 --- a/src/core/components/download-refresh/core-download-refresh.html +++ b/src/core/components/download-refresh/core-download-refresh.html @@ -1,20 +1,25 @@ - + + [@coreShowHideAnimation] [attr.aria-label]="(statusTranslatable || 'core.download') | translate"> - + (click)="download($event, true)" color="dark" [@coreShowHideAnimation] + attr.aria-label]="(statusTranslatable || 'core.refresh') | translate"> + - + + + - \ No newline at end of file + diff --git a/src/core/components/download-refresh/download-refresh.ts b/src/core/components/download-refresh/download-refresh.ts index 0b96d9102..820a4d93c 100644 --- a/src/core/components/download-refresh/download-refresh.ts +++ b/src/core/components/download-refresh/download-refresh.ts @@ -14,21 +14,24 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; import { CoreConstants } from '@/core/constants'; +import { coreShowHideAnimation } from '@classes/animations'; /** * Component to show a download button with refresh option, the spinner and the status of it. * * Usage: - * + * */ @Component({ selector: 'core-download-refresh', templateUrl: 'core-download-refresh.html', styleUrls: ['download-refresh.scss'], + animations: [coreShowHideAnimation], }) export class CoreDownloadRefreshComponent { @Input() status?: string; // Download status. + @Input() statusTranslatable?: string; // Download status translatable string. @Input() enabled = false; // Whether the download is enabled. @Input() loading = true; // Force loading status when is not downloading. @Input() canTrustDownload = false; // If false, refresh will be shown if downloaded. diff --git a/src/core/components/icon/icon.ts b/src/core/components/icon/icon.ts index 4d181a0a5..3774263f3 100644 --- a/src/core/components/icon/icon.ts +++ b/src/core/components/icon/icon.ts @@ -21,7 +21,7 @@ import { Component, Input, OnChanges, ElementRef, SimpleChange } from '@angular/ * * Check available icons at https://fontawesome.com/icons?d=gallery&m=free * - * @deprecated since 3.9.3. Please use instead. + * @deprecated since 3.9.3. Please use instead. */ @Component({ selector: 'core-icon', diff --git a/src/core/components/infinite-loading/core-infinite-loading.html b/src/core/components/infinite-loading/core-infinite-loading.html new file mode 100644 index 000000000..cb99cd201 --- /dev/null +++ b/src/core/components/infinite-loading/core-infinite-loading.html @@ -0,0 +1,29 @@ + +
+ + {{ 'core.loadmore' | translate }} + + + {{ 'core.tryagain' | translate }} + +
+
+ + + + + + +
+ + {{ 'core.loadmore' | translate }} + + + {{ 'core.tryagain' | translate }} + +
+
+ +
+ +
diff --git a/src/core/components/infinite-loading/infinite-loading.ts b/src/core/components/infinite-loading/infinite-loading.ts new file mode 100644 index 000000000..4be22c507 --- /dev/null +++ b/src/core/components/infinite-loading/infinite-loading.ts @@ -0,0 +1,158 @@ +// (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, Input, Output, EventEmitter, OnChanges, SimpleChange, Optional, ViewChild, ElementRef } from '@angular/core'; +import { IonContent, IonInfiniteScroll } from '@ionic/angular'; +import { CoreDomUtils } from '@services/utils/dom'; + +/** + * Component to show a infinite loading trigger and spinner while more data is being loaded. + * + * Usage: + * + */ +@Component({ + selector: 'core-infinite-loading', + templateUrl: 'core-infinite-loading.html', +}) +export class CoreInfiniteLoadingComponent implements OnChanges { + + @Input() enabled!: boolean; + @Input() error = false; + @Input() position: 'top' | 'bottom' = 'bottom'; + @Output() action: EventEmitter<() => void>; // Will emit an event when triggered. + + @ViewChild('topbutton') topButton?: ElementRef; + @ViewChild('infinitescroll') infiniteEl?: ElementRef; + @ViewChild('bottombutton') bottomButton?: ElementRef; + @ViewChild('spinnercontainer') spinnerContainer?: ElementRef; + @ViewChild(IonInfiniteScroll) infiniteScroll?: IonInfiniteScroll; + + loadingMore = false; // Hide button and avoid loading more. + + protected threshold = parseFloat('15%') / 100; + + constructor( + protected element: ElementRef, + @Optional() protected content: IonContent, + ) { + this.action = new EventEmitter(); + } + + /** + * Detect changes on input properties. + * + * @param changes Changes. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}): void { + if (changes.enabled && this.enabled && this.position == 'bottom') { + + // Infinite scroll enabled. If the list doesn't fill the full height, infinite scroll isn't triggered automatically. + this.checkScrollDistance(); + } + } + + /** + * Checks scroll distance to the beginning/end to load more items if needed. + * + * Previously, this function what firing an scroll event but now we have to calculate the distance + * like the Ionic component does. + */ + protected async checkScrollDistance(): Promise { + if (this.enabled) { + const scrollElement = await this.content.getScrollElement(); + + const infiniteHeight = this.element.nativeElement.getBoundingClientRect().height; + + const scrollTop = scrollElement.scrollTop; + const height = scrollElement.offsetHeight; + const threshold = height * this.threshold; + + const distanceFromInfinite = (this.position === 'bottom') + ? scrollElement.scrollHeight - infiniteHeight - scrollTop - threshold - height + : scrollTop - infiniteHeight - threshold; + + if (distanceFromInfinite < 0 && !this.loadingMore && this.enabled) { + this.loadMore(); + } + } + } + + + /** + * Load More items calling the action provided. + */ + loadMore(): void { + if (this.loadingMore) { + return; + } + + this.loadingMore = true; + this.action.emit(this.complete.bind(this)); + } + + /** + * Complete loading. + */ + complete(): void { + if (this.position == 'top') { + // Wait a bit before allowing loading more, otherwise it could be re-triggered automatically when it shouldn't. + setTimeout(this.completeLoadMore.bind(this), 400); + } else { + this.completeLoadMore(); + } + } + + /** + * Complete loading. + */ + protected async completeLoadMore(): Promise { + this.loadingMore = false; + await this.infiniteScroll?.complete(); + + // More items loaded. If the list doesn't fill the full height, infinite scroll isn't triggered automatically. + this.checkScrollDistance(); + } + + /** + * Get the height of the element. + * + * @return Height. + * @todo erase is not needed: I'm depreacating it because if not needed or getBoundingClientRect has the same result, it should + * be erased, also with getElementHeight + * @deprecated + */ + getHeight(): number { + // return this.element.nativeElement.getBoundingClientRect().height; + + return (this.position == 'top' ? this.getElementHeight(this.topButton): this.getElementHeight(this.bottomButton)) + + this.getElementHeight(this.infiniteEl) + + this.getElementHeight(this.spinnerContainer); + } + + /** + * Get the height of an element. + * + * @param element Element ref. + * @return Height. + */ + protected getElementHeight(element?: ElementRef): number { + if (element && element.nativeElement) { + return CoreDomUtils.instance.getElementHeight(element.nativeElement, true, true, true); + } + + return 0; + } + +} diff --git a/src/core/components/loading/core-loading.html b/src/core/components/loading/core-loading.html index 986f383f9..2bffacab4 100644 --- a/src/core/components/loading/core-loading.html +++ b/src/core/components/loading/core-loading.html @@ -1,10 +1,10 @@ -
+

{{message}}

-
+
- +
diff --git a/src/core/components/loading/loading.ts b/src/core/components/loading/loading.ts index 7e2557888..df7b9ef2c 100644 --- a/src/core/components/loading/loading.ts +++ b/src/core/components/loading/loading.ts @@ -17,6 +17,7 @@ import { Component, Input, OnInit, OnChanges, SimpleChange, ViewChild, ElementRe import { CoreEventLoadingChangedData, CoreEvents } from '@singletons/events'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons/core.singletons'; +import { coreShowHideAnimation } from '@classes/animations'; /** * Component to show a loading spinner and message while data is being loaded. @@ -42,7 +43,7 @@ import { Translate } from '@singletons/core.singletons'; selector: 'core-loading', templateUrl: 'core-loading.html', styleUrls: ['loading.scss'], - // @todo animations: [coreShowHideAnimation], + animations: [coreShowHideAnimation], }) export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit { diff --git a/src/core/components/navbar-buttons/navbar-buttons.scss b/src/core/components/navbar-buttons/navbar-buttons.scss new file mode 100644 index 000000000..5d9e00932 --- /dev/null +++ b/src/core/components/navbar-buttons/navbar-buttons.scss @@ -0,0 +1,3 @@ +:host { + display: none !important; +} diff --git a/src/core/components/navbar-buttons/navbar-buttons.ts b/src/core/components/navbar-buttons/navbar-buttons.ts new file mode 100644 index 000000000..7697224a4 --- /dev/null +++ b/src/core/components/navbar-buttons/navbar-buttons.ts @@ -0,0 +1,269 @@ +// (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, Input, OnInit, OnDestroy, ElementRef } from '@angular/core'; +import { CoreLogger } from '@singletons/logger'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreContextMenuComponent } from '../context-menu/context-menu'; + +const BUTTON_HIDDEN_CLASS = 'core-navbar-button-hidden'; + +/** + * Component to add buttons to the app's header without having to place them inside the header itself. This is meant for + * pages that are loaded inside a sub ion-nav, so they don't have a header. + * + * If this component indicates a position (start/end), the buttons will only be added if the header has some buttons in that + * position. If no start/end is specified, then the buttons will be added to the first found in the header. + * + * If this component has a "prepend" attribute, the buttons will be added before other existing buttons in the header. + * + * You can use the [hidden] input to hide all the inner buttons if a certain condition is met. + * + * IMPORTANT: Do not use *ngIf in the buttons inside this component, it can cause problems. Please use [hidden] instead. + * + * Example usage: + * + * + * + * + * + * + */ +@Component({ + selector: 'core-navbar-buttons', + template: '', + styleUrls: ['navbar-buttons.scss'], +}) +export class CoreNavBarButtonsComponent implements OnInit, OnDestroy { + + // If the hidden input is true, hide all buttons. + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('hidden') set hidden(value: boolean) { + if (typeof value == 'string' && value == '') { + value = true; + } + this.allButtonsHidden = value; + this.showHideAllElements(); + } + + protected element: HTMLElement; + protected allButtonsHidden = false; + protected forceHidden = false; + protected logger: CoreLogger; + protected movedChildren?: Node[]; + protected instanceId: string; + protected mergedContextMenu?: CoreContextMenuComponent; + + constructor(element: ElementRef) { + this.element = element.nativeElement; + this.logger = CoreLogger.getInstance('CoreNavBarButtonsComponent'); + this.instanceId = CoreDomUtils.instance.storeInstanceByElement(this.element, this); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + try { + const header = await this.searchHeader(); + if (header) { + // Search the right buttons container (start, end or any). + let selector = 'ion-buttons'; + + let slot = this.element.getAttribute('slot'); + // Take the slot from the parent if it has. + if (!slot && this.element.parentElement) { + slot = this.element.parentElement.getAttribute('slot'); + } + if (slot) { + selector += '[slot="' + slot + '"]'; + } + + const buttonsContainer = header.querySelector(selector); + if (buttonsContainer) { + this.mergeContextMenus(buttonsContainer); + + const prepend = this.element.hasAttribute('prepend'); + + this.movedChildren = CoreDomUtils.instance.moveChildren(this.element, buttonsContainer, prepend); + this.showHideAllElements(); + + } else { + this.logger.warn('The header was found, but it didn\'t have the right ion-buttons.', selector); + } + } + } catch (error) { + // Header not found. + this.logger.warn(error); + } + } + + /** + * Force or unforce hiding all buttons. If this is true, it will override the "hidden" input. + * + * @param value The value to set. + */ + forceHide(value: boolean): void { + this.forceHidden = value; + + this.showHideAllElements(); + } + + /** + * If both button containers have a context menu, merge them into a single one. + * + * @param buttonsContainer The container where the buttons will be moved. + * @todo + */ + protected mergeContextMenus(buttonsContainer: HTMLElement): void { + // Check if both button containers have a context menu. + const mainContextMenu = buttonsContainer.querySelector('core-context-menu'); + if (!mainContextMenu) { + return; + } + + const secondaryContextMenu = this.element.querySelector('core-context-menu'); + if (!secondaryContextMenu) { + return; + } + + // Both containers have a context menu. Merge them to prevent having 2 menus at the same time. + const mainContextMenuInstance: CoreContextMenuComponent = CoreDomUtils.instance.getInstanceByElement(mainContextMenu); + const secondaryContextMenuInstance: CoreContextMenuComponent = + CoreDomUtils.instance.getInstanceByElement(secondaryContextMenu); + + // Check that both context menus belong to the same core-tab. We shouldn't merge menus from different tabs. + if (mainContextMenuInstance && secondaryContextMenuInstance) { + this.mergedContextMenu = secondaryContextMenuInstance; + + this.mergedContextMenu.mergeContextMenus(mainContextMenuInstance); + + // Remove the empty context menu from the DOM. + secondaryContextMenu.parentElement?.removeChild(secondaryContextMenu); + } + } + + /** + * Search the ion-header where the buttons should be added. + * + * @param retries Number of retries so far. + * @return Promise resolved with the header element. + */ + protected async searchHeader(retries: number = 0): Promise { + let parentPage: HTMLElement = this.element; + + while (parentPage) { + if (!parentPage.parentElement) { + // No parent, stop. + break; + } + + // Get the next parent page. + parentPage = CoreDomUtils.instance.closest(parentPage.parentElement, '.ion-page'); + if (parentPage) { + // Check if the page has a header. If it doesn't, search the next parent page. + const header = this.searchHeaderInPage(parentPage); + if (header && getComputedStyle(header, null).display != 'none') { + return header; + } + } + } + + // Header not found. + if (retries < 5) { + // If the component or any of its parent is inside a ng-content or similar it can be detached when it's initialized. + // Try again after a while. + return new Promise((resolve, reject): void => { + setTimeout(() => { + // eslint-disable-next-line promise/catch-or-return + this.searchHeader(retries + 1).then(resolve, reject); + }, 200); + }); + } + + // We've waited enough time, reject. + throw Error('Header not found.'); + } + + /** + * Search ion-header inside a page. The header should be a direct child. + * + * @param page Page to search in. + * @return Header element. Undefined if not found. + */ + protected searchHeaderInPage(page: HTMLElement): HTMLElement | undefined { + for (let i = 0; i < page.children.length; i++) { + const child = page.children[i]; + if (child.tagName == 'ION-HEADER') { + return child; + } + } + } + + /** + * Show or hide all the elements. + */ + protected showHideAllElements(): void { + // Show or hide all moved children. + if (this.movedChildren) { + this.movedChildren.forEach((child: Node) => { + this.showHideElement(child); + }); + } + + // Show or hide all the context menu items that were merged to another context menu. + if (this.mergedContextMenu) { + if (this.forceHidden || this.allButtonsHidden) { + this.mergedContextMenu.removeMergedItems(); + } else { + this.mergedContextMenu.restoreMergedItems(); + } + } + } + + /** + * Show or hide an element. + * + * @param element Element to show or hide. + */ + protected showHideElement(element: Node): void { + // Check if it's an HTML Element + if (element instanceof Element) { + element.classList.toggle(BUTTON_HIDDEN_CLASS, !!this.forceHidden || !!this.allButtonsHidden); + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + CoreDomUtils.instance.removeInstanceById(this.instanceId); + + // This component was destroyed, remove all the buttons that were moved. + // The buttons can be moved outside of the current page, that's why we need to manually destroy them. + // There's no need to destroy context menu items that were merged because they weren't moved from their DOM position. + if (this.movedChildren) { + this.movedChildren.forEach((child) => { + if (child.parentElement) { + child.parentElement.removeChild(child); + } + }); + } + + if (this.mergedContextMenu) { + this.mergedContextMenu.removeMergedItems(); + } + } + +} diff --git a/src/core/components/progress-bar/core-progress-bar.html b/src/core/components/progress-bar/core-progress-bar.html new file mode 100644 index 000000000..6edeb841e --- /dev/null +++ b/src/core/components/progress-bar/core-progress-bar.html @@ -0,0 +1,5 @@ + + + +
{{ 'core.percentagenumber' | translate: {$a: text} }}
+
diff --git a/src/core/components/progress-bar/progress-bar.scss b/src/core/components/progress-bar/progress-bar.scss new file mode 100644 index 000000000..e82f88fa1 --- /dev/null +++ b/src/core/components/progress-bar/progress-bar.scss @@ -0,0 +1,33 @@ +:host { + display: flex; + + .core-progress-text { + line-height: 40px; + font-size: 1rem; + color: var(--text-color); + width: 55px; + text-align: center; + } + + progress { + -webkit-appearance: none; + appearance: none; + height: var(--height); + margin: 16px 0; + padding: 0; + display: block; + width: calc(100% - 55px); + + &[value]::-webkit-progress-bar { + background-color: var(--background); + border-radius: 0; + border: 0; + box-shadow: none; + } + + &[value]::-webkit-progress-value { + background-color: var(--color); + border-radius: 0; + } + } +} diff --git a/src/core/components/progress-bar/progress-bar.ts b/src/core/components/progress-bar/progress-bar.ts new file mode 100644 index 000000000..39267d2f0 --- /dev/null +++ b/src/core/components/progress-bar/progress-bar.ts @@ -0,0 +1,71 @@ +// (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, Input, OnChanges, SimpleChange, ChangeDetectionStrategy } from '@angular/core'; +import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; + +/** + * Component to show a progress bar and its value. + * + * Example usage: + * + */ +@Component({ + selector: 'core-progress-bar', + templateUrl: 'core-progress-bar.html', + styleUrls: ['progress-bar.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreProgressBarComponent implements OnChanges { + + @Input() progress!: number | string; // Percentage from 0 to 100. + @Input() text?: string; // Percentage in text to be shown at the right. If not defined, progress will be used. + width?: SafeStyle; + protected textSupplied = false; + + constructor(private sanitizer: DomSanitizer) { } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.text && typeof changes.text.currentValue != 'undefined') { + // User provided a custom text, don't use default. + this.textSupplied = true; + } + + if (changes.progress) { + // Progress has changed. + if (typeof this.progress == 'string') { + this.progress = parseInt(this.progress, 10); + } + + if (this.progress < 0 || isNaN(this.progress)) { + this.progress = -1; + } + + if (this.progress != -1) { + // Remove decimals. + this.progress = Math.floor(this.progress); + + if (!this.textSupplied) { + this.text = String(this.progress); + } + + this.width = this.sanitizer.bypassSecurityTrustStyle(this.progress + '%'); + } + } + } + +} diff --git a/src/core/components/recaptcha/core-recaptchamodal.html b/src/core/components/recaptcha/core-recaptchamodal.html index e49d8989b..604fefa7c 100644 --- a/src/core/components/recaptcha/core-recaptchamodal.html +++ b/src/core/components/recaptcha/core-recaptchamodal.html @@ -4,11 +4,11 @@ - + - \ No newline at end of file + diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index 731d8f3e6..fd8cbfd53 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -1,9 +1,9 @@ - + - + {{ tab.title | translate}} {{ tab.badge }} - + - + diff --git a/src/core/components/tabs/tabs.ts b/src/core/components/tabs/tabs.ts index 734139814..d5f79b184 100644 --- a/src/core/components/tabs/tabs.ts +++ b/src/core/components/tabs/tabs.ts @@ -33,6 +33,9 @@ import { CoreConstants } from '@/core/constants'; import { CoreUtils } from '@services/utils/utils'; import { NavigationOptions } from '@ionic/angular/providers/nav-controller'; import { Params } from '@angular/router'; +import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; +import { CoreDomUtils } from '@/core/services/utils/dom'; +import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils'; /** * This component displays some top scrollable tabs that will autohide on vertical scroll. @@ -105,6 +108,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe protected isInTransition = false; // Weather Slides is in transition. protected slidesSwiper: any; protected slidesSwiperLoaded = false; + protected stackEventsSubscription?: Subscription; constructor( protected element: ElementRef, @@ -151,6 +155,21 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe return; } + this.stackEventsSubscription = this.ionTabs!.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => { + if (this.isCurrentView) { + const content = stackEvent.enteringView.element.querySelector('ion-content'); + + this.showHideNavBarButtons(stackEvent.enteringView.element.tagName); + if (content) { + const scroll = await content.getScrollElement(); + content.scrollEvents = true; + content.addEventListener('ionScroll', (e: CustomEvent): void => { + this.showHideTabs(e, scroll); + }); + } + } + }); + this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar'); this.tabsElement = this.element.nativeElement.querySelector('ion-tabs'); @@ -576,19 +595,29 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe this.selectedIndex = index; this.ionChange.emit(selectedTab); - - const content = this.ionTabs!.outlet.nativeEl.querySelector('ion-content'); - - if (content) { - const scroll = await content.getScrollElement(); - content.scrollEvents = true; - content.addEventListener('ionScroll', (e: CustomEvent): void => { - this.showHideTabs(e, scroll); - }); - } } } + /** + * Get all child core-navbar-buttons and show or hide depending on the page state. + * We need to use querySelectorAll because ContentChildren doesn't work with ng-template. + * https://github.com/angular/angular/issues/14842 + * + * @param activatedPageName Activated page name. + */ + protected showHideNavBarButtons(activatedPageName: string): void { + const elements = this.ionTabs!.outlet.nativeEl.querySelectorAll('core-navbar-buttons'); + const domUtils = CoreDomUtils.instance; + elements.forEach((element) => { + const instance: CoreNavBarButtonsComponent = domUtils.getInstanceByElement(element); + + if (instance) { + const pagetagName = element.closest('.ion-page')?.tagName; + instance.forceHide(activatedPageName != pagetagName); + } + }); + } + /** * Adapt tabs to a window resize. */ @@ -607,6 +636,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe if (this.resizeFunction) { window.removeEventListener('resize', this.resizeFunction); } + this.stackEventsSubscription?.unsubscribe(); } } diff --git a/src/core/constants.ts b/src/core/constants.ts index 087a16e77..b4bd8c42a 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -71,6 +71,12 @@ export class CoreConstants { static readonly OUTDATED = 'outdated'; static readonly NOT_DOWNLOADABLE = 'notdownloadable'; + static readonly DOWNLOADED_ICON = 'cloud-done'; + static readonly DOWNLOADING_ICON = 'spinner'; + static readonly NOT_DOWNLOADED_ICON = 'cloud-download'; + static readonly OUTDATED_ICON = 'fas-redo-alt'; + static readonly NOT_DOWNLOADABLE_ICON = ''; + // Constants from Moodle's resourcelib. static readonly RESOURCELIB_DISPLAY_AUTO = 0; // Try the best way. static readonly RESOURCELIB_DISPLAY_EMBED = 1; // Display using object tag. diff --git a/src/core/directives/fa-icon.ts b/src/core/directives/fa-icon.ts index 38298a46b..0d9da145a 100644 --- a/src/core/directives/fa-icon.ts +++ b/src/core/directives/fa-icon.ts @@ -23,7 +23,7 @@ import { CoreConstants } from '@/core/constants'; * * Example usage: * - * + * */ @Directive({ selector: 'ion-icon[name]', diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index da1baf562..846165404 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -194,8 +194,8 @@ export class CoreFormatTextDirective implements OnChanges { anchor.classList.add('core-image-viewer-icon'); anchor.setAttribute('aria-label', label); - // Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed. - anchor.innerHTML = ''; + // @todo Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed. + anchor.innerHTML = ''; anchor.addEventListener('click', (e: Event) => { e.preventDefault(); diff --git a/src/core/features/course/course.module.ts b/src/core/features/course/course.module.ts new file mode 100644 index 000000000..cd23709c2 --- /dev/null +++ b/src/core/features/course/course.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 { NgModule } from '@angular/core'; + +import { CORE_SITE_SCHEMAS } from '@/core/services/sites'; + +import { + SITE_SCHEMA as COURSE_SITE_SCHEMA, + OFFLINE_SITE_SCHEMA as COURSE_OFFLINE_SITE_SCHEMA, +} from './services/course-db'; + +@NgModule({ + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ + COURSE_SITE_SCHEMA, + COURSE_OFFLINE_SITE_SCHEMA, + ], + multi: true, + }, + ], +}) +export class CoreCourseModule { +} diff --git a/src/core/features/course/lang/en.json b/src/core/features/course/lang/en.json new file mode 100644 index 000000000..2a74a13a0 --- /dev/null +++ b/src/core/features/course/lang/en.json @@ -0,0 +1,36 @@ +{ + "activitydisabled": "Your organisation has disabled this activity in the mobile app.", + "activitynotyetviewableremoteaddon": "Your organisation installed a plugin that is not yet supported.", + "activitynotyetviewablesiteupgradeneeded": "Your organisation's Moodle installation needs to be updated.", + "allsections": "All sections", + "askadmintosupport": "Contact the site administrator and tell them you want to use this activity with the Moodle Mobile app.", + "availablespace": " You currently have about {{available}} free space.", + "cannotdeletewhiledownloading": "Files cannot be deleted while the activity is being downloaded. Please wait for the download to finish.", + "confirmdeletemodulefiles": "Are you sure you want to delete these files?", + "confirmdeletestoreddata": "Are you sure you want to delete the stored data?", + "confirmdownload": "You are about to download {{size}}.{{availableSpace}} Are you sure you want to continue?", + "confirmdownloadunknownsize": "It was not possible to calculate the size of the download.{{availableSpace}} Are you sure you want to continue?", + "confirmdownloadzerosize": "You are about to start downloading.{{availableSpace}} Are you sure you want to continue?", + "confirmpartialdownloadsize": "You are about to download at least {{size}}.{{availableSpace}} Are you sure you want to continue?", + "confirmlimiteddownload": "You are not currently connected to Wi-Fi. ", + "contents": "Contents", + "couldnotloadsectioncontent": "Could not load the section content. Please try again later.", + "couldnotloadsections": "Could not load the sections. Please try again later.", + "coursesummary": "Course summary", + "downloadcourse": "Download course", + "errordownloadingcourse": "Error downloading course.", + "errordownloadingsection": "Error downloading section.", + "errorgetmodule": "Error getting activity data.", + "hiddenfromstudents": "Hidden from students", + "hiddenoncoursepage": "Available but not shown on course page", + "insufficientavailablespace": "You are trying to download {{size}}. This will leave your device with insufficient space to operate normally. Please clear some storage space first.", + "insufficientavailablequota": "Your device could not allocate space to save this download. It may be reserving space for app and system updates. Please clear some storage space first.", + "manualcompletionnotsynced": "Manual completion not synchronised.", + "nocontentavailable": "No content available at the moment.", + "overriddennotice": "Your final grade from this activity was manually adjusted.", + "refreshcourse": "Refresh course", + "sections": "Sections", + "useactivityonbrowser": "You can still use it using your device's web browser.", + "warningmanualcompletionmodified": "The manual completion of an activity was modified on the site.", + "warningofflinemanualcompletiondeleted": "Some offline manual completion of course '{{name}}' has been deleted. {{error}}" +} \ No newline at end of file diff --git a/src/core/features/course/services/course-db.ts b/src/core/features/course/services/course-db.ts new file mode 100644 index 000000000..db0305c68 --- /dev/null +++ b/src/core/features/course/services/course-db.ts @@ -0,0 +1,111 @@ +// (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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreCourse service. + */ +export const COURSE_STATUS_TABLE = 'course_status'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreCourseProvider', + version: 1, + tables: [ + { + name: COURSE_STATUS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'status', + type: 'TEXT', + notNull: true, + }, + { + name: 'previous', + type: 'TEXT', + }, + { + name: 'updated', + type: 'INTEGER', + }, + { + name: 'downloadTime', + type: 'INTEGER', + }, + { + name: 'previousDownloadTime', + type: 'INTEGER', + }, + ], + }, + ], +}; + +/** + * Database variables for CoreCourseOffline service. + */ +export const MANUAL_COMPLETION_TABLE = 'course_manual_completion'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreCourseOfflineProvider', + version: 1, + tables: [ + { + name: MANUAL_COMPLETION_TABLE, + columns: [ + { + name: 'cmid', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'completed', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'coursename', + type: 'TEXT', + }, + { + name: 'timecompleted', + type: 'INTEGER', + }, + ], + }, + ], +}; + +export type CoreCourseStatusDBRecord = { + id: number; + status: string; + previous: string; + updated: number; + downloadTime: number; + previousDownloadTime: number; +}; + +export type CoreCourseManualCompletionDBRecord = { + cmid: number; + completed: number; + courseid: number; + coursename: string; + timecompleted: number; +}; diff --git a/src/core/features/course/services/course-offline.ts b/src/core/features/course/services/course-offline.ts new file mode 100644 index 000000000..0db107fe1 --- /dev/null +++ b/src/core/features/course/services/course-offline.ts @@ -0,0 +1,117 @@ +// (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 { Injectable } from '@angular/core'; +import { makeSingleton } from '@singletons/core.singletons'; +import { CoreSites } from '@services/sites'; +import { CoreCourseManualCompletionDBRecord, MANUAL_COMPLETION_TABLE } from './course-db'; +import { CoreStatusWithWarningsWSResponse } from '@services/ws'; + +/** + * Service to handle offline data for courses. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCourseOfflineProvider { + + /** + * Delete a manual completion stored. + * + * @param cmId The module ID to remove the completion. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteManualCompletion(cmId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(MANUAL_COMPLETION_TABLE, { cmid: cmId }); + } + + /** + * Get all offline manual completions for a certain course. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of completions. + */ + async getAllManualCompletions(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(MANUAL_COMPLETION_TABLE); + } + + /** + * Get all offline manual completions for a certain course. + * + * @param courseId Course ID the module belongs to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of completions. + */ + async getCourseManualCompletions(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(MANUAL_COMPLETION_TABLE, { courseid: courseId }); + } + + /** + * Get the offline manual completion for a certain module. + * + * @param cmId The module ID to remove the completion. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the completion, rejected if failure or not found. + */ + async getManualCompletion(cmId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecord(MANUAL_COMPLETION_TABLE, { cmid: cmId }); + } + + /** + * Offline version for manually marking a module as completed. + * + * @param cmId The module ID to store the completion. + * @param completed Whether the module is completed or not. + * @param courseId Course ID the module belongs to. + * @param courseName Course name. Recommended, it is used to display a better warning message. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when completion is successfully stored. + */ + async markCompletedManually( + cmId: number, + completed: boolean, + courseId: number, + courseName?: string, + siteId?: string, + ): Promise { + + // Store the offline data. + const site = await CoreSites.instance.getSite(siteId); + const entry: CoreCourseManualCompletionDBRecord = { + cmid: cmId, + completed: completed ? 1 : 0, + courseid: courseId, + coursename: courseName || '', + timecompleted: Date.now(), + }; + await site.getDb().insertRecord(MANUAL_COMPLETION_TABLE, entry); + + return ({ + status: true, + offline: true, + }); + } + +} + +export class CoreCourseOffline extends makeSingleton(CoreCourseOfflineProvider) { } diff --git a/src/core/features/course/services/course.helper.ts b/src/core/features/course/services/course.helper.ts new file mode 100644 index 000000000..c9ebe1572 --- /dev/null +++ b/src/core/features/course/services/course.helper.ts @@ -0,0 +1,990 @@ +// (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 { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreSites } from '@services/sites'; +import { CoreCourse, CoreCourseSection } from './course'; +import { CoreConstants } from '@/core/constants'; +import { CoreLogger } from '@singletons/logger'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreFilepool } from '@services/filepool'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { + CoreCourseBasicData, + CoreCourseGetCoursesData, + CoreCourses, + CoreCourseSearchedData, + CoreEnrolledCourseBasicData, + CoreEnrolledCourseData, +} from '@features/courses/services/courses'; +import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses.helper'; +import { CoreArray } from '@singletons/array'; +import { CoreLoginHelper, CoreLoginHelperProvider } from '@features/login/services/login.helper'; +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { CoreCourseOffline } from './course-offline'; + +/** + * Prefetch info of a module. + */ +export type CoreCourseModulePrefetchInfo = { + /** + * Downloaded size. + */ + size?: number; + + /** + * Downloadable size in a readable format. + */ + sizeReadable?: string; + + /** + * Module status. + */ + status?: string; + + /** + * Icon's name of the module status. + */ + statusIcon?: string; + + /** + * Time when the module was last downloaded. + */ + downloadTime?: number; + + /** + * Download time in a readable format. + */ + downloadTimeReadable?: string; +}; + +/** + * Progress of downloading a list of courses. + */ +export type CoreCourseCoursesProgress = { + /** + * Number of courses downloaded so far. + */ + count: number; + + /** + * Toal of courses to download. + */ + total: number; + + /** + * Whether the download has been successful so far. + */ + success: boolean; + + /** + * Last downloaded course. + */ + courseId?: number; +}; + +export type CorePrefetchStatusInfo = { + status: string; // Status of the prefetch. + statusTranslatable: string; // Status translatable string. + icon: string; // Icon based on the status. + loading: boolean; // If it's a loading status. + badge?: string; // Progress badge string if any. +}; + +/** + * Helper to gather some common course functions. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCourseHelperProvider { + + protected courseDwnPromises: { [s: string]: { [id: number]: Promise } } = {}; + protected logger: CoreLogger; + + constructor() { + + this.logger = CoreLogger.getInstance('CoreCourseHelperProvider'); + } + + /** + * This function treats every module on the sections provided to load the handler data, treat completion + * and navigate to a module page if required. It also returns if sections has content. + * + * @param sections List of sections to treat modules. + * @param courseId Course ID of the modules. + * @param completionStatus List of completion status. + * @param courseName Course name. Recommended if completionStatus is supplied. + * @param forCoursePage Whether the data will be used to render the course page. + * @return Whether the sections have content. + */ + addHandlerDataForModules(): void { + // @todo params and logic + } + + /** + * Calculate the status of a section. + * + * @param section Section to calculate its status. It can't be "All sections". + * @param courseId Course ID the section belongs to. + * @param refresh True if it shouldn't use module status cache (slower). + * @param checkUpdates Whether to use the WS to check updates. Defaults to true. + * @return Promise resolved when the status is calculated. + */ + calculateSectionStatus(): void { + // @todo params and logic + } + + /** + * Calculate the status of a list of sections, setting attributes to determine the icons/data to be shown. + * + * @param sections Sections to calculate their status. + * @param courseId Course ID the sections belong to. + * @param refresh True if it shouldn't use module status cache (slower). + * @param checkUpdates Whether to use the WS to check updates. Defaults to true. + * @return Promise resolved when the states are calculated. + */ + calculateSectionsStatus(): void { + // @todo params and logic + } + + /** + * Show a confirm and prefetch a course. It will retrieve the sections and the course options if not provided. + * This function will set the icon to "spinner" when starting and it will also set it back to the initial icon if the + * user cancels. All the other updates of the icon should be made when CoreEvents.COURSE_STATUS_CHANGED is received. + * + * @param data An object where to store the course icon and title: "prefetchCourseIcon", "title" and "downloadSucceeded". + * @param course Course to prefetch. + * @param sections List of course sections. + * @param courseHandlers List of course handlers. + * @param menuHandlers List of course menu handlers. + * @return Promise resolved when the download finishes, rejected if an error occurs or the user cancels. + */ + confirmAndPrefetchCourse(): void { + // @todo params and logic + } + + /** + * Confirm and prefetches a list of courses. + * + * @param courses List of courses to download. + * @param onProgress Function to call everytime a course is downloaded. + * @return Resolved when downloaded, rejected if error or canceled. + */ + async confirmAndPrefetchCourses( + courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[], + onProgress?: (data: CoreCourseCoursesProgress) => void, + ): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + // Confirm the download without checking size because it could take a while. + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.areyousure')); + + const total = courses.length; + let count = 0; + + const promises = courses.map((course) => { + const subPromises: Promise[] = []; + let sections: CoreCourseSection[]; + let handlers: any; + let menuHandlers: any; + let success = true; + + // Get the sections and the handlers. + subPromises.push(CoreCourse.instance.getSections(course.id, false, true).then((courseSections) => { + sections = courseSections; + + return; + })); + + /** + * @todo + subPromises.push(this.courseOptionsDelegate.getHandlersToDisplay(this.injector, course).then((cHandlers: any) => { + handlers = cHandlers; + })); + subPromises.push(this.courseOptionsDelegate.getMenuHandlersToDisplay(this.injector, course).then((mHandlers: any) => { + menuHandlers = mHandlers; + })); + */ + + return Promise.all(subPromises).then(() => this.prefetchCourse(course, sections, handlers, menuHandlers, siteId)) + .catch((error) => { + success = false; + + throw error; + }).finally(() => { + // Course downloaded or failed, notify the progress. + count++; + if (onProgress) { + onProgress({ count: count, total: total, courseId: course.id, success: success }); + } + }); + }); + + if (onProgress) { + // Notify the start of the download. + onProgress({ count: 0, total: total, success: true }); + } + + return CoreUtils.instance.allPromises(promises); + } + + /** + * Show confirmation dialog and then remove a module files. + * + * @param module Module to remove the files. + * @param courseId Course ID the module belongs to. + * @param done Function to call when done. It will close the context menu. + * @return Promise resolved when done. + * @todo module type. + */ + async confirmAndRemoveFiles(module: any, courseId: number, done?: () => void): Promise { + let modal: CoreIonLoadingElement | undefined; + + try { + + await CoreDomUtils.instance.showDeleteConfirm('core.course.confirmdeletestoreddata'); + + modal = await CoreDomUtils.instance.showModalLoading(); + + await this.removeModuleStoredData(module, courseId); + + done && done(); + + } catch (error) { + if (error) { + CoreDomUtils.instance.showErrorModal(error); + } + } finally { + modal?.dismiss(); + } + } + + /** + * Calculate the size to download a section and show a confirm modal if needed. + * + * @param courseId Course ID the section belongs to. + * @param section Section. If not provided, all sections. + * @param sections List of sections. Used when downloading all the sections. + * @param alwaysConfirm True to show a confirm even if the size isn't high, false otherwise. + * @return Promise resolved if the user confirms or there's no need to confirm. + */ + confirmDownloadSizeSection(): void { + // @todo params and logic + } + + /** + * Helper function to prefetch a module, showing a confirmation modal if the size is big. + * This function is meant to be called from a context menu option. It will also modify some data like the prefetch icon. + * + * @param instance The component instance that has the context menu. It should have prefetchStatusIcon and isDestroyed. + * @param module Module to be prefetched + * @param courseId Course ID the module belongs to. + * @param done Function to call when done. It will close the context menu. + * @return Promise resolved when done. + */ + contextMenuPrefetch(): void { + // @todo params and logic + } + + /** + * Determine the status of a list of courses. + * + * @param courses Courses + * @return Promise resolved with the status. + */ + async determineCoursesStatus(courses: CoreCourseBasicData[]): Promise { + // Get the status of each course. + const promises: Promise[] = []; + const siteId = CoreSites.instance.getCurrentSiteId(); + + courses.forEach((course) => { + promises.push(CoreCourse.instance.getCourseStatus(course.id, siteId)); + }); + + const statuses = await Promise.all(promises); + + // Now determine the status of the whole list. + let status = statuses[0]; + const filepool = CoreFilepool.instance; + for (let i = 1; i < statuses.length; i++) { + status = filepool.determinePackagesStatus(status, statuses[i]); + } + + return status; + } + + /** + * Convenience function to open a module main file, downloading the package if needed. + * This is meant for modules like mod_resource. + * + * @param module The module to download. + * @param courseId The course ID of the module. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files of the module. If not provided, use module.contents. + * @param siteId The site ID. If not defined, current site. + * @return Resolved on success. + */ + downloadModuleAndOpenFile(): void { + // @todo params and logic + } + + /** + * Convenience function to download a module that has a main file and return the local file's path and other info. + * This is meant for modules like mod_resource. + * + * @param module The module to download. + * @param courseId The course ID of the module. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files of the module. If not provided, use module.contents. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved when done. + */ + downloadModuleWithMainFileIfNeeded(): void { + // @todo params and logic + } + + /** + * Convenience function to download a module that has a main file and return the local file's path and other info. + * This is meant for modules like mod_resource. + * + * @param module The module to download. + * @param courseId The course ID of the module. + * @param fixedUrl Main file's fixed URL. + * @param files List of files of the module. + * @param status The package status. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected downloadModuleWithMainFile(): void { + // @todo params and logic + } + + /** + * Convenience function to download a module. + * + * @param module The module to download. + * @param courseId The course ID of the module. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files of the module. If not provided, use module.contents. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved when done. + */ + downloadModule(): void { + // @todo params and logic + } + + /** + * Fill the Context Menu for a certain module. + * + * @param instance The component instance that has the context menu. + * @param module Module to be prefetched + * @param courseId Course ID the module belongs to. + * @param invalidateCache Invalidates the cache first. + * @param component Component of the module. + * @return Promise resolved when done. + */ + fillContextMenu(): void { + // @todo params and logic + } + + /** + * Get a course. It will first check the user courses, and fallback to another WS if not enrolled. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the course. + */ + async getCourse( + courseId: number, + siteId?: string, + ): Promise<{ enrolled: boolean; course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData }> { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + let course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData; + + // Try with enrolled courses first. + try { + course = await CoreCourses.instance.getUserCourse(courseId, false, siteId); + + return ({ enrolled: true, course: course }); + } catch { + // Not enrolled or an error happened. Try to use another WebService. + } + + const available = await CoreCourses.instance.isGetCoursesByFieldAvailableInSite(siteId); + + if (available) { + course = await CoreCourses.instance.getCourseByField('id', courseId, siteId); + } else { + course = await CoreCourses.instance.getCourse(courseId, siteId); + } + + return ({ enrolled: false, course: course }); + } + + /** + * Get a course, wait for any course format plugin to load, and open the course page. It basically chains the functions + * getCourse and openCourse. + * + * @param courseId Course ID. + * @param params Other params to pass to the course page. + * @param siteId Site ID. If not defined, current site. + */ + async getAndOpenCourse(courseId: number, params?: Params, siteId?: string): Promise { + const modal = await CoreDomUtils.instance.showModalLoading(); + + let course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData | { id: number }; + + try { + const data = await this.getCourse(courseId, siteId); + + course = data.course; + } catch { + // Cannot get course, return a "fake". + course = { id: courseId }; + } + + modal?.dismiss(); + + return this.openCourse(course, params, siteId); + } + + /** + * Check if the course has a block with that name. + * + * @param courseId Course ID. + * @param name Block name to search. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if the block exists or false otherwise. + * @since 3.3 + */ + async hasABlockNamed(courseId: number, name: string, siteId?: string): Promise { + try { + const blocks = await CoreCourse.instance.getCourseBlocks(courseId, siteId); + + return blocks.some((block) => block.name == name); + } catch { + return false; + } + } + + /** + * Initialize the prefetch icon for selected courses. + * + * @param courses Courses array to get info from. + * @param prefetch Prefetch information. + * @param minCourses Min course to show icon. + * @return Resolved with the prefetch information updated when done. + */ + async initPrefetchCoursesIcons( + courses: CoreCourseBasicData[], + prefetch: CorePrefetchStatusInfo, + minCourses: number = 2, + ): Promise { + if (!courses || courses.length < minCourses) { + // Not enough courses. + prefetch.icon = ''; + + return prefetch; + } + + const status = await this.determineCoursesStatus(courses); + + prefetch = this.getCourseStatusIconAndTitleFromStatus(status); + + if (prefetch.loading) { + // It seems all courses are being downloaded, show a download button instead. + prefetch.icon = CoreConstants.NOT_DOWNLOADED_ICON; + } + + return prefetch; + } + + /** + * Load offline completion into a list of sections. + * This should be used in 3.6 sites or higher, where the course contents already include the completion. + * + * @param courseId The course to get the completion. + * @param sections List of sections of the course. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async loadOfflineCompletion(courseId: number, sections: any[], siteId?: string): Promise { + const offlineCompletions = await CoreCourseOffline.instance.getCourseManualCompletions(courseId, siteId); + + if (!offlineCompletions || !offlineCompletions.length) { + // No offline completion. + return; + } + + const totalOffline = offlineCompletions.length; + let loaded = 0; + const offlineCompletionsMap = CoreUtils.instance.arrayToObject(offlineCompletions, 'cmid'); + // Load the offline data in the modules. + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + if (!section.modules || !section.modules.length) { + // Section has no modules, ignore it. + continue; + } + + for (let j = 0; j < section.modules.length; j++) { + const module = section.modules[j]; + const offlineCompletion = offlineCompletionsMap[module.id]; + + if (offlineCompletion && typeof module.completiondata != 'undefined' && + offlineCompletion.timecompleted >= module.completiondata.timecompleted * 1000) { + // The module has offline completion. Load it. + module.completiondata.state = offlineCompletion.completed; + module.completiondata.offline = true; + + // If all completions have been loaded, stop. + loaded++; + if (loaded == totalOffline) { + break; + } + } + } + } + } + + /** + * Prefetch all the courses in the array. + * + * @param courses Courses array to prefetch. + * @param prefetch Prefetch information to be updated. + * @return Promise resolved when done. + */ + async prefetchCourses( + courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[], + prefetch: CorePrefetchStatusInfo, + ): Promise { + prefetch.loading = true; + prefetch.icon = CoreConstants.DOWNLOADING_ICON; + prefetch.badge = ''; + + try { + await this.confirmAndPrefetchCourses(courses, (progress) => { + prefetch.badge = progress.count + ' / ' + progress.total; + }); + prefetch.icon = CoreConstants.OUTDATED_ICON; + } finally { + prefetch.loading = false; + prefetch.badge = ''; + } + } + + /** + * Get a course download promise (if any). + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Download promise, undefined if not found. + */ + getCourseDownloadPromise(courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + return this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][courseId]; + } + + /** + * Get a course status icon and the langkey to use as a title. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the icon name and the title key. + */ + async getCourseStatusIconAndTitle(courseId: number, siteId?: string): Promise { + const status = await CoreCourse.instance.getCourseStatus(courseId, siteId); + + return this.getCourseStatusIconAndTitleFromStatus(status); + } + + /** + * Get a course status icon and the langkey to use as a title from status. + * + * @param status Course status. + * @return Title and icon name. + */ + getCourseStatusIconAndTitleFromStatus(status: string): CorePrefetchStatusInfo { + const prefetchStatus: CorePrefetchStatusInfo = { + status: status, + icon: this.getPrefetchStatusIcon(status, false), + statusTranslatable: '', + loading: false, + }; + + if (status == CoreConstants.DOWNLOADED) { + // Always show refresh icon, we cannot know if there's anything new in course options. + prefetchStatus.statusTranslatable = 'core.course.refreshcourse'; + } else if (status == CoreConstants.DOWNLOADING) { + prefetchStatus.statusTranslatable = 'core.downloading'; + prefetchStatus.loading = true; + } else { + prefetchStatus.statusTranslatable = 'core.course.downloadcourse'; + } + + return prefetchStatus; + } + + /** + * Get the icon given the status and if trust the download status. + * + * @param status Status constant. + * @param trustDownload True to show download success, false to show an outdated status when downloaded. + * @return Icon name. + */ + getPrefetchStatusIcon(status: string, trustDownload: boolean = false): string { + if (status == CoreConstants.NOT_DOWNLOADED) { + return CoreConstants.NOT_DOWNLOADED_ICON; + } + if (status == CoreConstants.OUTDATED || (status == CoreConstants.DOWNLOADED && !trustDownload)) { + return CoreConstants.OUTDATED_ICON; + } + if (status == CoreConstants.DOWNLOADED && trustDownload) { + return CoreConstants.DOWNLOADED_ICON; + } + if (status == CoreConstants.DOWNLOADING) { + return CoreConstants.DOWNLOADING_ICON; + } + + return CoreConstants.DOWNLOADING_ICON; + } + + /** + * Get the course ID from a module instance ID, showing an error message if it can't be retrieved. + * + * @param id Instance ID. + * @param module Name of the module. E.g. 'glossary'. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the module's course ID. + * @todo module type. + */ + async getModuleCourseIdByInstance(id: number, module: any, siteId?: string): Promise { + try { + const cm = await CoreCourse.instance.getModuleBasicInfoByInstance(id, module, siteId); + + return cm.course; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + + throw error; + } + } + + /** + * Get prefetch info for a module. + * + * @param module Module to get the info from. + * @param courseId Course ID the section belongs to. + * @param invalidateCache Invalidates the cache first. + * @param component Component of the module. + * @return Promise resolved with the info. + */ + getModulePrefetchInfo(): void { + // @todo params and logic + } + + /** + * Get the download ID of a section. It's used to interact with CoreCourseModulePrefetchDelegate. + * + * @param section Section. + * @return Section download ID. + * @todo section type. + */ + getSectionDownloadId(section: any): string { + return 'Section-' + section.id; + } + + /** + * Navigate to a module using instance ID and module name. + * + * @param instanceId Activity instance ID. + * @param modName Module name of the activity. + * @param siteId Site ID. If not defined, current site. + * @param courseId Course ID. If not defined we'll try to retrieve it from the site. + * @param sectionId Section the module belongs to. If not defined we'll try to retrieve it from the site. + * @param useModNameToGetModule If true, the app will retrieve all modules of this type with a single WS call. This reduces the + * number of WS calls, but it isn't recommended for modules that can return a lot of contents. + * @param modParams Params to pass to the module + * @param navCtrl NavController for adding new pages to the current history. Optional for legacy support, but + * generates a warning if omitted. + * @return Promise resolved when done. + */ + navigateToModuleByInstance(): void { + // @todo params and logic + } + + /** + * Navigate to a module. + * + * @param moduleId Module's ID. + * @param siteId Site ID. If not defined, current site. + * @param courseId Course ID. If not defined we'll try to retrieve it from the site. + * @param sectionId Section the module belongs to. If not defined we'll try to retrieve it from the site. + * @param modName If set, the app will retrieve all modules of this type with a single WS call. This reduces the + * number of WS calls, but it isn't recommended for modules that can return a lot of contents. + * @param modParams Params to pass to the module + * @param navCtrl NavController for adding new pages to the current history. Optional for legacy support, but + * generates a warning if omitted. + * @return Promise resolved when done. + */ + navigateToModule(): void { + // @todo params and logic + } + + /** + * Open a module. + * + * @param navCtrl The NavController to use. + * @param module The module to open. + * @param courseId The course ID of the module. + * @param sectionId The section ID of the module. + * @param modParams Params to pass to the module + * @param True if module can be opened, false otherwise. + */ + openModule(): void { + // @todo params and logic + } + + /** + * Prefetch all the activities in a course and also the course addons. + * + * @param course The course to prefetch. + * @param sections List of course sections. + * @param courseHandlers List of course options handlers. + * @param courseMenuHandlers List of course menu handlers. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the download finishes. + */ + async prefetchCourse( + course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + sections: CoreCourseSection[], + courseHandlers: any[], // @todo CoreCourseOptionsHandlerToDisplay[], + courseMenuHandlers: any[], // @todo CoreCourseOptionsMenuHandlerToDisplay[], + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][course.id]) { + // There's already a download ongoing for this course, return the promise. + return this.courseDwnPromises[siteId][course.id]; + } else if (!this.courseDwnPromises[siteId]) { + this.courseDwnPromises[siteId] = {}; + } + + // First of all, mark the course as being downloaded. + this.courseDwnPromises[siteId][course.id] = CoreCourse.instance.setCourseStatus( + course.id, + CoreConstants.DOWNLOADING, + siteId, + ).then(async () => { + + const promises: Promise[] = []; + + // Prefetch all the sections. If the first section is "All sections", use it. Otherwise, use a fake "All sections". + /* + * @todo + let allSectionsSection = sections[0]; + if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) { + allSectionsSection = { id: CoreCourseProvider.ALL_SECTIONS_ID }; + } + promises.push(this.prefetchSection(allSectionsSection, course.id, sections)); + + // Prefetch course options. + courseHandlers.forEach((handler) => { + if (handler.prefetch) { + promises.push(handler.prefetch(course)); + } + }); + courseMenuHandlers.forEach((handler) => { + if (handler.prefetch) { + promises.push(handler.prefetch(course)); + } + });*/ + + // Prefetch other data needed to render the course. + if (CoreCourses.instance.isGetCoursesByFieldAvailable()) { + promises.push(CoreCourses.instance.getCoursesByField('id', course.id)); + } + + const sectionWithModules = sections.find((section) => section.modules && section.modules.length > 0); + if (!sectionWithModules || typeof sectionWithModules.modules[0].completion == 'undefined') { + promises.push(CoreCourse.instance.getActivitiesCompletionStatus(course.id)); + } + + // @todo promises.push(this.filterHelper.getFilters('course', course.id)); + + return CoreUtils.instance.allPromises(promises); + }).then(() => + // Download success, mark the course as downloaded. + CoreCourse.instance.setCourseStatus(course.id, CoreConstants.DOWNLOADED, siteId)).catch(async (error) => { + // Error, restore previous status. + await CoreCourse.instance.setCoursePreviousStatus(course.id, siteId); + + throw error; + }).finally(() => { + delete this.courseDwnPromises[siteId!][course.id]; + }); + + return this.courseDwnPromises[siteId][course.id]; + } + + /** + * Helper function to prefetch a module, showing a confirmation modal if the size is big + * and invalidating contents if refreshing. + * + * @param handler Prefetch handler to use. Must implement 'prefetch' and 'invalidateContent'. + * @param module Module to download. + * @param size Object containing size to download (in bytes) and a boolean to indicate if its totally calculated. + * @param courseId Course ID of the module. + * @param refresh True if refreshing, false otherwise. + * @return Promise resolved when downloaded. + */ + prefetchModule(): void { + // @todo params and logic + } + + /** + * Prefetch one section or all the sections. + * If the section is "All sections" it will prefetch all the sections. + * + * @param section Section. + * @param courseId Course ID the section belongs to. + * @param sections List of sections. Used when downloading all the sections. + * @return Promise resolved when the prefetch is finished. + */ + async prefetchSection(): Promise { + // @todo params and logic + } + + /** + * Prefetch a certain section if it needs to be prefetched. + * If the section is "All sections" it will be ignored. + * + * @param section Section to prefetch. + * @param courseId Course ID the section belongs to. + * @return Promise resolved when the section is prefetched. + */ + protected prefetchSingleSectionIfNeeded(): void { + // @todo params and logic + } + + /** + * Start or restore the prefetch of a section. + * If the section is "All sections" it will be ignored. + * + * @param section Section to download. + * @param result Result of CoreCourseModulePrefetchDelegate.getModulesStatus for this section. + * @param courseId Course ID the section belongs to. + * @return Promise resolved when the section has been prefetched. + */ + protected prefetchSingleSection(): void { + // @todo params and logic + } + + /** + * Check if a section has content. + * + * @param section Section to check. + * @return Whether the section has content. + * @todo section type. + */ + sectionHasContent(section: any): boolean { + if (section.hiddenbynumsections) { + return false; + } + + return (typeof section.availabilityinfo != 'undefined' && section.availabilityinfo != '') || + section.summary != '' || (section.modules && section.modules.length > 0); + } + + /** + * Wait for any course format plugin to load, and open the course page. + * + * If the plugin's promise is resolved, the course page will be opened. If it is rejected, they will see an error. + * If the promise for the plugin is still in progress when the user tries to open the course, a loader + * will be displayed until it is complete, before the course page is opened. If the promise is already complete, + * they will see the result immediately. + * + * @param course Course to open + * @param params Params to pass to the course page. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + openCourse(course: CoreEnrolledCourseBasicData | { id: number }, params?: Params, siteId?: string): Promise { + if (!siteId || siteId == CoreSites.instance.getCurrentSiteId()) { + // Current site, we can open the course. + return CoreCourse.instance.openCourse(course, params); + } else { + // We need to load the site first. + params = params || {}; + Object.assign(params, { course: course }); + + return CoreLoginHelper.instance.redirect(CoreLoginHelperProvider.OPEN_COURSE, params, siteId); + } + } + + /** + * Delete course files. + * + * @param courseId Course id. + * @return Promise to be resolved once the course files are deleted. + */ + async deleteCourseFiles(courseId: number): Promise { + const sections = await CoreCourse.instance.getSections(courseId); + const modules = CoreArray.flatten(sections.map((section) => section.modules)); + + await Promise.all( + modules.map((module) => this.removeModuleStoredData(module, courseId)), + ); + + await CoreCourse.instance.setCourseStatus(courseId, CoreConstants.NOT_DOWNLOADED); + } + + /** + * Remove module stored data. + * + * @param module Module to remove the files. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + // @todo remove when done. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async removeModuleStoredData(module: any, courseId: number): Promise { + const promises: Promise[] = []; + + // @todo + // promises.push(this.prefetchDelegate.removeModuleFiles(module, courseId)); + + // @todo + // const handler = this.prefetchDelegate.getPrefetchHandlerFor(module); + // if (handler) { + // promises.push(CoreSites.instance.getCurrentSite().deleteComponentFromCache(handler.component, module.id)); + // } + + await Promise.all(promises); + } + +} + +export class CoreCourseHelper extends makeSingleton(CoreCourseHelperProvider) {} diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts new file mode 100644 index 000000000..3a167c60a --- /dev/null +++ b/src/core/features/course/services/course.ts @@ -0,0 +1,1505 @@ +// (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 { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; + +import { CoreApp } from '@services/app'; +import { CoreEvents } from '@singletons/events'; +import { CoreLogger } from '@singletons/logger'; +import { CoreSitesCommonWSOptions, CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSiteWSPreSets, CoreSite } from '@classes/site'; +import { CoreConstants } from '@/core/constants'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile } from '@services/ws'; + +import { CoreCourseStatusDBRecord, COURSE_STATUS_TABLE } from './course-db'; +import { CoreCourseOffline } from './course-offline'; +import { CoreError } from '@classes/errors/error'; +import { + CoreCourses, + CoreCoursesProvider, +} from '../../courses/services/courses'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreWSError } from '@classes/errors/wserror'; + +const ROOT_CACHE_KEY = 'mmCourse:'; + +/** + * Service that provides some features regarding a course. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCourseProvider { + + static readonly ALL_SECTIONS_ID = -2; + static readonly STEALTH_MODULES_SECTION_ID = -1; + static readonly ACCESS_GUEST = 'courses_access_guest'; + static readonly ACCESS_DEFAULT = 'courses_access_default'; + static readonly ALL_COURSES_CLEARED = -1; + + static readonly COMPLETION_TRACKING_NONE = 0; + static readonly COMPLETION_TRACKING_MANUAL = 1; + static readonly COMPLETION_TRACKING_AUTOMATIC = 2; + + static readonly COMPLETION_INCOMPLETE = 0; + static readonly COMPLETION_COMPLETE = 1; + static readonly COMPLETION_COMPLETE_PASS = 2; + static readonly COMPLETION_COMPLETE_FAIL = 3; + + static readonly COMPONENT = 'CoreCourse'; + + protected readonly CORE_MODULES = [ + 'assign', 'assignment', 'book', 'chat', 'choice', 'data', 'database', 'date', 'external-tool', + 'feedback', 'file', 'folder', 'forum', 'glossary', 'ims', 'imscp', 'label', 'lesson', 'lti', 'page', 'quiz', + 'resource', 'scorm', 'survey', 'url', 'wiki', 'workshop', 'h5pactivity', + ]; + + protected logger: CoreLogger; + + constructor() { + // @todo + // protected courseFormatDelegate: CoreCourseFormatDelegate, + // protected sitePluginsProvider: CoreSitePluginsProvider, + // protected pushNotificationsProvider: CorePushNotificationsProvider, + this.logger = CoreLogger.getInstance('CoreCourseProvider'); + } + + /** + * Check if the get course blocks WS is available in current site. + * + * @param site Site to check. If not defined, current site. + * @return Whether it's available. + * @since 3.7 + */ + canGetCourseBlocks(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.isVersionGreaterEqualThan('3.7') && site.wsAvailable('core_block_get_course_blocks'); + } + + /** + * Check whether the site supports requesting stealth modules. + * + * @param site Site. If not defined, current site. + * @return Whether the site supports requesting stealth modules. + * @since 3.4.6, 3.5.3, 3.6 + */ + canRequestStealthModules(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.isVersionGreaterEqualThan(['3.4.6', '3.5.3']); + } + + /** + * Check if module completion could have changed. If it could have, trigger event. This function must be used, + * for example, after calling a "module_view" WS since it can change the module completion. + * + * @param courseId Course ID. + * @param completion Completion status of the module. + * @todo Add completion type. + */ + checkModuleCompletion(courseId: number, completion: any): void { + if (completion && completion.tracking === 2 && completion.state === 0) { + this.invalidateSections(courseId).finally(() => { + CoreEvents.trigger(CoreEvents.COMPLETION_MODULE_VIEWED, { courseId: courseId }); + }); + } + } + + /** + * Clear all courses status in a site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when all status are cleared. + */ + async clearAllCoursesStatus(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + this.logger.debug('Clear all course status for site ' + site.id); + + await site.getDb().deleteRecords(COURSE_STATUS_TABLE); + this.triggerCourseStatusChanged(CoreCourseProvider.ALL_COURSES_CLEARED, CoreConstants.NOT_DOWNLOADED, site.id); + } + + /** + * Check if the current view in a NavController is a certain course initial page. + * + * @param navCtrl NavController. + * @param courseId Course ID. + * @return Whether the current view is a certain course. + */ + currentViewIsCourse(): boolean { + // @ todo add params and logic. + return false; + } + + /** + * Get completion status of all the activities in a course for a certain user. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user. + * @param forceCache True if it should return cached data. Has priority over ignoreCache. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param includeOffline True if it should load offline data in the completion status. + * @return Promise resolved with the completion statuses: object where the key is module ID. + */ + async getActivitiesCompletionStatus( + courseId: number, + siteId?: string, + userId?: number, + forceCache: boolean = false, + ignoreCache: boolean = false, + includeOffline: boolean = true, + ): Promise> { + + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + this.logger.debug(`Getting completion status for user ${userId} in course ${courseId}`); + + const params: CoreCompletionGetActivitiesCompletionStatusWSParams = { + courseid: courseId, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getActivitiesCompletionCacheKey(courseId, userId), + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + + const data = await site.read( + 'core_completion_get_activities_completion_status', + params, + preSets, + ); + + if (!data || !data.statuses) { + throw Error('WS core_completion_get_activities_completion_status failed'); + } + + const completionStatus = CoreUtils.instance.arrayToObject(data.statuses, 'cmid'); + if (!includeOffline) { + return completionStatus; + } + + try { + // Now get the offline completion (if any). + const offlineCompletions = await CoreCourseOffline.instance.getCourseManualCompletions(courseId, site.id); + + offlineCompletions.forEach((offlineCompletion) => { + + if (offlineCompletion && typeof completionStatus[offlineCompletion.cmid] != 'undefined') { + const onlineCompletion = completionStatus[offlineCompletion.cmid]; + + // If the activity uses manual completion, override the value with the offline one. + if (onlineCompletion.tracking === 1) { + onlineCompletion.state = offlineCompletion.completed; + onlineCompletion.offline = true; + } + } + }); + + return completionStatus; + } catch { + // Ignore errors. + return completionStatus; + } + } + + /** + * Get cache key for activities completion WS calls. + * + * @param courseId Course ID. + * @param userId User ID. + * @return Cache key. + */ + protected getActivitiesCompletionCacheKey(courseId: number, userId: number): string { + return ROOT_CACHE_KEY + 'activitiescompletion:' + courseId + ':' + userId; + } + + /** + * Get course blocks. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of blocks. + * @since 3.7 + */ + async getCourseBlocks(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreBlockGetCourseBlocksWSParams = { + courseid: courseId, + returncontents: true, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCourseBlocksCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const result = await site.read('core_block_get_course_blocks', params, preSets); + + return result.blocks || []; + } + + /** + * Get cache key for course blocks WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getCourseBlocksCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'courseblocks:' + courseId; + } + + /** + * Get the data stored for a course. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the data. + */ + async getCourseStatusData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const entry: CoreCourseStatusDBRecord = await site.getDb().getRecord(COURSE_STATUS_TABLE, { id: courseId }); + if (!entry) { + throw Error('No entry found on course status table'); + } + + return entry; + } + + /** + * Get a course status. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the status. + */ + async getCourseStatus(courseId: number, siteId?: string): Promise { + try { + const entry = await this.getCourseStatusData(courseId, siteId); + + return entry.status || CoreConstants.NOT_DOWNLOADED; + } catch { + return CoreConstants.NOT_DOWNLOADED; + } + } + + /** + * Obtain ids of downloaded courses. + * + * @param siteId Site id. + * @return Resolves with an array containing downloaded course ids. + */ + async getDownloadedCourseIds(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const entries: CoreCourseStatusDBRecord[] = await site.getDb().getRecordsList( + COURSE_STATUS_TABLE, + 'status', + [ + CoreConstants.DOWNLOADED, + CoreConstants.DOWNLOADING, + CoreConstants.OUTDATED, + ], + ); + + return entries.map((entry) => entry.id); + } + + /** + * Get a module from Moodle. + * + * @param moduleId The module ID. + * @param courseId The course ID. Recommended to speed up the process and minimize data usage. + * @param sectionId The section ID. + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @param modName If set, the app will retrieve all modules of this type with a single WS call. This reduces the + * number of WS calls, but it isn't recommended for modules that can return a lot of contents. + * @return Promise resolved with the module. + */ + async getModule( + moduleId: number, + courseId?: number, + sectionId?: number, + preferCache: boolean = false, + ignoreCache: boolean = false, + siteId?: string, + modName?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Helper function to do the WS request without processing the result. + const doRequest = async ( + site: CoreSite, + moduleId: number, + modName: string | undefined, + includeStealth: boolean, + preferCache: boolean, + ): Promise => { + const params: CoreCourseGetContentsParams = { + courseid: courseId!, + options: [], + }; + const preSets: CoreSiteWSPreSets = { + omitExpires: preferCache, + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + if (includeStealth) { + params.options!.push({ + name: 'includestealthmodules', + value: true, + }); + } + + // If modName is set, retrieve all modules of that type. Otherwise get only the module. + if (modName) { + params.options!.push({ + name: 'modname', + value: modName, + }); + preSets.cacheKey = this.getModuleByModNameCacheKey(modName); + } else { + params.options!.push({ + name: 'cmid', + value: moduleId, + }); + preSets.cacheKey = this.getModuleCacheKey(moduleId); + } + + if (!preferCache && ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + try { + const sections: CoreCourseSection[] = await site.read('core_course_get_contents', params, preSets); + + return sections; + } catch { + // The module might still be cached by a request with different parameters. + if (!ignoreCache && !CoreApp.instance.isOnline()) { + if (includeStealth) { + // Older versions didn't include the includestealthmodules option. + return doRequest(site, moduleId, modName, false, true); + } else if (modName) { + // Falback to the request for the given moduleId only. + return doRequest(site, moduleId, undefined, this.canRequestStealthModules(site), true); + } + } + + throw Error('WS core_course_get_contents failed, cache ignored'); + } + }; + + if (!courseId) { + // No courseId passed, try to retrieve it. + const module = await this.getModuleBasicInfo(moduleId, siteId); + courseId = module.course; + } + + let sections: CoreCourseSection[]; + try { + const site = await CoreSites.instance.getSite(siteId); + // We have courseId, we can use core_course_get_contents for compatibility. + this.logger.debug(`Getting module ${moduleId} in course ${courseId}`); + + sections = await doRequest(site, moduleId, modName, this.canRequestStealthModules(site), preferCache); + } catch { + // Error getting the module. Try to get all contents (without filtering by module). + const preSets: CoreSiteWSPreSets = { + omitExpires: preferCache, + }; + + if (!preferCache && ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + sections = await this.getSections(courseId, false, false, preSets, siteId); + } + + let foundModule: CoreCourseModule | undefined; + + const foundSection = sections.some((section) => { + if (sectionId != null && + !isNaN(sectionId) && + section.id != CoreCourseProvider.STEALTH_MODULES_SECTION_ID && + sectionId != section.id + ) { + return false; + } + + foundModule = section.modules.find((module) => module.id == moduleId); + + return !!foundModule; + }); + + if (foundSection && foundModule) { + foundModule.course = courseId; + + return foundModule; + } + + throw Error('Module not found'); + } + + /** + * Gets a module basic info by module ID. + * + * @param moduleId Module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the module's info. + */ + async getModuleBasicInfo(moduleId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCourseGetCourseModuleWSParams = { + cmid: moduleId, + }; + const preSets = { + cacheKey: this.getModuleCacheKey(moduleId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const response = await site.read('core_course_get_course_module', params, preSets); + + if (response.warnings && response.warnings.length) { + throw new CoreWSError(response.warnings[0]); + } else if (response.cm) { + return response.cm; + } + + throw Error('WS core_course_get_course_module failed.'); + } + + /** + * Gets a module basic grade info by module ID. + * + * If the user does not have permision to manage the activity false is returned. + * + * @param moduleId Module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the module's grade info. + */ + async getModuleBasicGradeInfo(moduleId: number, siteId?: string): Promise { + const info = await this.getModuleBasicInfo(moduleId, siteId); + + const grade: CoreCourseModuleGradeInfo = { + advancedgrading: info.advancedgrading, + grade: info.grade, + gradecat: info.gradecat, + gradepass: info.gradepass, + outcomes: info.outcomes, + scale: info.scale, + }; + + if ( + typeof grade.grade != 'undefined' || + typeof grade.advancedgrading != 'undefined' || + typeof grade.outcomes != 'undefined' + ) { + return grade; + } + + return false; + } + + /** + * Gets a module basic info by instance. + * + * @param id Instance ID. + * @param module Name of the module. E.g. 'glossary'. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the module's info. + */ + async getModuleBasicInfoByInstance(id: number, module: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCourseGetCourseModuleByInstanceWSParams = { + instance: id, + module: module, + }; + const preSets = { + cacheKey: this.getModuleBasicInfoByInstanceCacheKey(id, module), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const response: CoreCourseGetCourseModuleWSResponse = + await site.read('core_course_get_course_module_by_instance', params, preSets); + + if (response.warnings && response.warnings.length) { + throw new CoreWSError(response.warnings[0]); + } else if (response.cm) { + return response.cm; + } + + throw Error('WS core_course_get_course_module_by_instance failed'); + } + + /** + * Get cache key for get module by instance WS calls. + * + * @param id Instance ID. + * @param module Name of the module. E.g. 'glossary'. + * @return Cache key. + */ + protected getModuleBasicInfoByInstanceCacheKey(id: number, module: string): string { + return ROOT_CACHE_KEY + 'moduleByInstance:' + module + ':' + id; + } + + /** + * Get cache key for module WS calls. + * + * @param moduleId Module ID. + * @return Cache key. + */ + protected getModuleCacheKey(moduleId: number): string { + return ROOT_CACHE_KEY + 'module:' + moduleId; + } + + /** + * Get cache key for module by modname WS calls. + * + * @param modName Name of the module. + * @return Cache key. + */ + protected getModuleByModNameCacheKey(modName: string): string { + return ROOT_CACHE_KEY + 'module:modName:' + modName; + } + + /** + * Returns the source to a module icon. + * + * @param moduleName The module name. + * @param modicon The mod icon string to use in case we are not using a core activity. + * @return The IMG src. + */ + getModuleIconSrc(moduleName: string, modicon?: string): string { + // @TODO: Check modicon url theme to apply other theme icons. + + // Use default icon on core themes. + if (this.CORE_MODULES.indexOf(moduleName) < 0) { + if (modicon) { + return modicon; + } + + moduleName = 'external-tool'; + } + + return 'assets/img/mod/' + moduleName + '.svg'; + } + + /** + * Get the section ID a module belongs to. + * + * @param moduleId The module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the section ID. + */ + async getModuleSectionId(moduleId: number, siteId?: string): Promise { + // Try to get the section using getModuleBasicInfo. + const module = await this.getModuleBasicInfo(moduleId, siteId); + + return module.section; + } + + /** + * Return a specific section. + * + * @param courseId The course ID. + * @param sectionId The section ID. + * @param excludeModules Do not return modules, return only the sections structure. + * @param excludeContents Do not return module contents (i.e: files inside a resource). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the section. + */ + async getSection( + courseId: number, + sectionId: number, + excludeModules?: boolean, + excludeContents?: boolean, + siteId?: string, + ): Promise { + + if (sectionId < 0) { + throw new CoreError('Invalid section ID'); + } + + const sections = await this.getSections(courseId, excludeModules, excludeContents, undefined, siteId); + const section = sections.find((section) => section.id == sectionId); + + if (section) { + return section; + } + + throw new CoreError('Unknown section'); + } + + /** + * Get the course sections. + * + * @param courseId The course ID. + * @param excludeModules Do not return modules, return only the sections structure. + * @param excludeContents Do not return module contents (i.e: files inside a resource). + * @param preSets Presets to use. + * @param siteId Site ID. If not defined, current site. + * @param includeStealthModules Whether to include stealth modules. Defaults to true. + * @return The reject contains the error message, else contains the sections. + */ + async getSections( + courseId: number, + excludeModules: boolean = false, + excludeContents: boolean = false, + preSets?: CoreSiteWSPreSets, + siteId?: string, + includeStealthModules: boolean = true, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + preSets = preSets || {}; + preSets.cacheKey = this.getSectionsCacheKey(courseId); + preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_RARELY; + + const params: CoreCourseGetContentsParams = { + courseid: courseId, + options: [ + { + name: 'excludemodules', + value: excludeModules, + }, + { + name: 'excludecontents', + value: excludeContents, + }, + ], + }; + if (this.canRequestStealthModules(site)) { + params.options!.push({ + name: 'includestealthmodules', + value: includeStealthModules, + }); + } + + let sections: CoreCourseSection[]; + try { + sections = await site.read('core_course_get_contents', params, preSets); + } catch { + // Error getting the data, it could fail because we added a new parameter and the call isn't cached. + // Retry without the new parameter and forcing cache. + preSets.omitExpires = true; + params.options!.splice(-1, 1); + sections = await site.read('core_course_get_contents', params, preSets); + } + + const siteHomeId = site.getSiteHomeId(); + let showSections = true; + if (courseId == siteHomeId) { + const storedNumSections = site.getStoredConfig('numsections'); + showSections = typeof storedNumSections != 'undefined' && !!storedNumSections; + } + + if (typeof showSections != 'undefined' && !showSections && sections.length > 0) { + // Get only the last section (Main menu block section). + sections.pop(); + } + + return sections; + } + + /** + * Get cache key for section WS call. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getSectionsCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'sections:' + courseId; + } + + /** + * Given a list of sections, returns the list of modules in the sections. + * + * @param sections Sections. + * @return Modules. + */ + getSectionsModules(sections: CoreCourseSection[]): CoreCourseModule[] { + if (!sections || !sections.length) { + return []; + } + + return sections.reduce((previous: CoreCourseModule[], section) => previous.concat(section.modules || []), []); + } + + /** + * Invalidates course blocks WS call. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCourseBlocks(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCourseBlocksCacheKey(courseId)); + } + + /** + * Invalidates module WS call. + * + * @param moduleId Module ID. + * @param siteId Site ID. If not defined, current site. + * @param modName Module name. E.g. 'label', 'url', ... + * @return Promise resolved when the data is invalidated. + */ + async invalidateModule(moduleId: number, siteId?: string, modName?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const promises: Promise[] = []; + if (modName) { + promises.push(site.invalidateWsCacheForKey(this.getModuleByModNameCacheKey(modName))); + } + promises.push(site.invalidateWsCacheForKey(this.getModuleCacheKey(moduleId))); + + await Promise.all(promises); + } + + /** + * Invalidates module WS call. + * + * @param id Instance ID. + * @param module Name of the module. E.g. 'glossary'. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateModuleByInstance(id: number, module: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getModuleBasicInfoByInstanceCacheKey(id, module)); + } + + /** + * Invalidates sections WS call. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSections(courseId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + const promises: Promise[] = []; + const siteHomeId = site.getSiteHomeId(); + userId = userId || site.getUserId(); + promises.push(site.invalidateWsCacheForKey(this.getSectionsCacheKey(courseId))); + promises.push(site.invalidateWsCacheForKey(this.getActivitiesCompletionCacheKey(courseId, userId))); + if (courseId == siteHomeId) { + promises.push(site.invalidateConfig()); + } + + await Promise.all(promises); + } + + /** + * Load module contents into module.contents if they aren't loaded already. + * + * @param module Module to load the contents. + * @param courseId The course ID. Recommended to speed up the process and minimize data usage. + * @param sectionId The section ID. + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @param modName If set, the app will retrieve all modules of this type with a single WS call. This reduces the + * number of WS calls, but it isn't recommended for modules that can return a lot of contents. + * @return Promise resolved when loaded. + */ + async loadModuleContents( + module: CoreCourseModule & CoreCourseModuleBasicInfo, + courseId?: number, + sectionId?: number, + preferCache?: boolean, + ignoreCache?: boolean, + siteId?: string, + modName?: string, + ): Promise { + + if (!ignoreCache && module.contents && module.contents.length) { + // Already loaded. + return; + } + + const mod = await this.getModule(module.id, courseId, sectionId, preferCache, ignoreCache, siteId, modName); + module.contents = mod.contents; + } + + /** + * Report a course and section as being viewed. + * + * @param courseId Course ID. + * @param sectionNumber Section number. + * @param siteId Site ID. If not defined, current site. + * @param name Name of the course. + * @return Promise resolved when the WS call is successful. + * @todo use logHelper. Remove eslint disable when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async logView(courseId: number, sectionNumber?: number, siteId?: string, name?: string): Promise { + const params: CoreCourseViewCourseWSParams = { + courseid: courseId, + }; + const wsName = 'core_course_view_course'; + + if (typeof sectionNumber != 'undefined') { + params.sectionnumber = sectionNumber; + } + + const site = await CoreSites.instance.getSite(siteId); + // @todo + // this.pushNotificationsProvider.logViewEvent(courseId, name, 'course', wsName, { sectionnumber: sectionNumber }, siteId); + const response: CoreStatusWithWarningsWSResponse = await site.write(wsName, params); + + if (!response.status) { + throw Error('WS core_course_view_course failed.'); + } else { + CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, { + courseId: courseId, + action: CoreCoursesProvider.ACTION_VIEW, + }, site.getId()); + } + } + + /** + * Offline version for manually marking a module as completed. + * + * @param cmId The module ID. + * @param completed Whether the module is completed or not. + * @param courseId Course ID the module belongs to. + * @param courseName Course name. Recommended, it is used to display a better warning message. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when completion is successfully sent or stored. + */ + async markCompletedManually( + cmId: number, + completed: boolean, + courseId: number, + courseName?: string, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Convenience function to store a completion to be synchronized later. + const storeOffline = (): Promise => + CoreCourseOffline.instance.markCompletedManually(cmId, completed, courseId, courseName, siteId); + + // The offline function requires a courseId and it could be missing because it's a calculated field. + if (!CoreApp.instance.isOnline() && courseId) { + // App is offline, store the action. + return storeOffline(); + } + + // Try to send it to server. + try { + const result = await this.markCompletedManuallyOnline(cmId, completed, siteId); + + // Data sent to server, if there is some offline data delete it now. + try { + await CoreCourseOffline.instance.deleteManualCompletion(cmId, siteId); + } catch { + // Ignore errors, shouldn't happen. + } + + return result; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error) || !courseId) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } else { + // Couldn't connect to server, store it offline. + return storeOffline(); + } + } + } + + /** + * Offline version for manually marking a module as completed. + * + * @param cmId The module ID. + * @param completed Whether the module is completed or not. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when completion is successfully sent. + */ + async markCompletedManuallyOnline( + cmId: number, + completed: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCompletionUpdateActivityCompletionStatusManuallyWSParams = { + cmid: cmId, + completed: completed, + }; + + return site.write('core_completion_update_activity_completion_status_manually', params); + } + + /** + * Check if a module has a view page. E.g. labels don't have a view page. + * + * @param module The module object. + * @return Whether the module has a view page. + */ + moduleHasView(module: CoreCourseModuleSummary | CoreCourseModule): boolean { + return !!module.url; + } + + /** + * Wait for any course format plugin to load, and open the course page. + * + * If the plugin's promise is resolved, the course page will be opened. If it is rejected, they will see an error. + * If the promise for the plugin is still in progress when the user tries to open the course, a loader + * will be displayed until it is complete, before the course page is opened. If the promise is already complete, + * they will see the result immediately. + * + * This function must be in here instead of course helper to prevent circular dependencies. + * + * @param course Course to open + * @param params Other params to pass to the course page. + * @return Promise resolved when done. + */ + async openCourse( + course: { id: number ; format?: string }, + params?: Params, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // @todo const loading = await CoreDomUtils.instance.showModalLoading(); + + // Wait for site plugins to be fetched. + // @todo await this.sitePluginsProvider.waitFetchPlugins(); + + if (typeof course.format == 'undefined') { + // This block can be replaced by a call to CourseHelper.getCourse(), but it is circular dependant. + const coursesProvider = CoreCourses.instance; + try { + course = await coursesProvider.getUserCourse(course.id, true); + } catch (error) { + // Not enrolled or an error happened. Try to use another WebService. + const available = coursesProvider.isGetCoursesByFieldAvailableInSite(); + try { + if (available) { + course = await coursesProvider.getCourseByField('id', course.id); + } else { + course = await coursesProvider.getCourse(course.id); + } + } catch (error) { + // Ignore errors. + } + } + } + + /* @todo + if (!this.sitePluginsProvider.sitePluginPromiseExists('format_' + course.format)) { + // No custom format plugin. We don't need to wait for anything. + await this.courseFormatDelegate.openCourse(course, params); + loading.dismiss(); + + return; + } */ + + // This course uses a custom format plugin, wait for the format plugin to finish loading. + try { + /* @todo await this.sitePluginsProvider.sitePluginLoaded('format_' + course.format); + // The format loaded successfully, but the handlers wont be registered until all site plugins have loaded. + if (this.sitePluginsProvider.sitePluginsFinishedLoading) { + return this.courseFormatDelegate.openCourse(course, params); + }*/ + + // Wait for plugins to be loaded. + const deferred = CoreUtils.instance.promiseDefer(); + + const observer = CoreEvents.on(CoreEvents.SITE_PLUGINS_LOADED, () => { + observer && observer.off(); + + /* @todo this.courseFormatDelegate.openCourse(course, params).then((response) => { + deferred.resolve(response); + }).catch((error) => { + deferred.reject(error); + });*/ + }); + + return deferred.promise; + } catch (error) { + // The site plugin failed to load. The user needs to restart the app to try loading it again. + const message = Translate.instance.instant('core.courses.errorloadplugins'); + const reload = Translate.instance.instant('core.courses.reload'); + const ignore = Translate.instance.instant('core.courses.ignore'); + + await CoreDomUtils.instance.showConfirm(message, '', reload, ignore); + window.location.reload(); + } + } + + /** + * Select a certain tab in the course. Please use currentViewIsCourse() first to verify user is viewing the course. + * + * @param name Name of the tab. If not provided, course contents. + * @param params Other params. + */ + selectCourseTab(name?: string, params?: Params): void { + params = params || {}; + params.name = name || ''; + + CoreEvents.trigger(CoreEvents.SELECT_COURSE_TAB, params); + } + + /** + * Change the course status, setting it to the previous status. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the status is changed. Resolve param: new status. + */ + async setCoursePreviousStatus(courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + this.logger.debug(`Set previous status for course ${courseId} in site ${siteId}`); + + const site = await CoreSites.instance.getSite(siteId); + const db = site.getDb(); + const entry = await this.getCourseStatusData(courseId, siteId); + + this.logger.debug(`Set previous status '${entry.status}' for course ${courseId}`); + + const newData = { + id: courseId, + status: entry.previous || CoreConstants.NOT_DOWNLOADED, + updated: Date.now(), + // Going back from downloading to previous status, restore previous download time. + downloadTime: entry.status == CoreConstants.DOWNLOADING ? entry.previousDownloadTime : entry.downloadTime, + }; + + await db.updateRecords(COURSE_STATUS_TABLE, newData, { id: courseId }); + // Success updating, trigger event. + this.triggerCourseStatusChanged(courseId, newData.status, siteId); + + return newData.status; + } + + /** + * Store course status. + * + * @param courseId Course ID. + * @param status New course status. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the status is stored. + */ + async setCourseStatus(courseId: number, status: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + this.logger.debug(`Set status '${status}' for course ${courseId} in site ${siteId}`); + + const site = await CoreSites.instance.getSite(siteId); + let downloadTime = 0; + let previousDownloadTime = 0; + let previousStatus = ''; + + if (status == CoreConstants.DOWNLOADING) { + // Set download time if course is now downloading. + downloadTime = CoreTimeUtils.instance.timestamp(); + } + + try { + const entry = await this.getCourseStatusData(courseId, siteId); + if (typeof downloadTime == 'undefined') { + // Keep previous download time. + downloadTime = entry.downloadTime; + previousDownloadTime = entry.previousDownloadTime; + } else { + // The downloadTime will be updated, store current time as previous. + previousDownloadTime = entry.downloadTime; + } + previousStatus = entry.status; + } catch { + // New entry. + } + + if (previousStatus != status) { + // Status has changed, update it. + const data: CoreCourseStatusDBRecord = { + id: courseId, + status: status, + previous: previousStatus, + updated: new Date().getTime(), + downloadTime: downloadTime, + previousDownloadTime: previousDownloadTime, + }; + + await site.getDb().insertRecord(COURSE_STATUS_TABLE, data); + } + + // Success inserting, trigger event. + this.triggerCourseStatusChanged(courseId, status, siteId); + } + + /** + * Translate a module name to current language. + * + * @param moduleName The module name. + * @return Translated name. + */ + translateModuleName(moduleName: string): string { + if (this.CORE_MODULES.indexOf(moduleName) < 0) { + moduleName = 'external-tool'; + } + + const langKey = 'core.mod_' + moduleName; + const translated = Translate.instance.instant(langKey); + + return translated !== langKey ? translated : moduleName; + } + + /** + * Trigger COURSE_STATUS_CHANGED with the right data. + * + * @param courseId Course ID. + * @param status New course status. + * @param siteId Site ID. If not defined, current site. + */ + protected triggerCourseStatusChanged(courseId: number, status: string, siteId?: string): void { + CoreEvents.trigger(CoreEvents.COURSE_STATUS_CHANGED, { + courseId: courseId, + status: status, + }, siteId); + } + +} + +/** + * Common options used by modules when calling a WS through CoreSite. + */ +export type CoreCourseCommonModWSOptions = CoreSitesCommonWSOptions & { + cmId?: number; // Module ID. +}; + +/** + * Data returned by course_summary_exporter. + */ +export type CoreCourseSummary = { + id: number; // Id. + fullname: string; // Fullname. + shortname: string; // Shortname. + idnumber: string; // Idnumber. + summary: string; // @since 3.3. Summary. + summaryformat: number; // @since 3.3. Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + startdate: number; // @since 3.3. Startdate. + enddate: number; // @since 3.3. Enddate. + visible: boolean; // @since 3.8. Visible. + fullnamedisplay: string; // @since 3.3. Fullnamedisplay. + viewurl: string; // Viewurl. + courseimage: string; // @since 3.6. Courseimage. + progress?: number; // @since 3.6. Progress. + hasprogress: boolean; // @since 3.6. Hasprogress. + isfavourite: boolean; // @since 3.6. Isfavourite. + hidden: boolean; // @since 3.6. Hidden. + timeaccess?: number; // @since 3.6. Timeaccess. + showshortname: boolean; // @since 3.6. Showshortname. + coursecategory: string; // @since 3.7. Coursecategory. +}; + +/** + * Data returned by course_module_summary_exporter. + */ +export type CoreCourseModuleSummary = { + id: number; // Id. + name: string; // Name. + url?: string; // Url. + iconurl: string; // Iconurl. +}; + +/** + * Params of core_completion_get_activities_completion_status WS. + */ +type CoreCompletionGetActivitiesCompletionStatusWSParams = { + courseid: number; // Course ID. + userid: number; // User ID. +}; + +/** + * Data returned by core_completion_get_activities_completion_status WS. + */ +export type CoreCourseCompletionActivityStatusWSResponse = { + statuses: CoreCourseCompletionActivityStatus[]; // List of activities status. + warnings?: CoreStatusWithWarningsWSResponse[]; +}; + +/** + * Activity status. + */ +export type CoreCourseCompletionActivityStatus = { + cmid: number; // Comment ID. + modname: string; // Activity module name. + instance: number; // Instance ID. + state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail. + timecompleted: number; // Timestamp for completed activity. + tracking: number; // Type of tracking: 0 means none, 1 manual, 2 automatic. + overrideby?: number; // The user id who has overriden the status, or null. + valueused?: boolean; // Whether the completion status affects the availability of another activity. + offline?: boolean; // Whether the completions is offline and not yet synced. +}; + +/** + * Params of core_block_get_course_blocks WS. + */ +type CoreBlockGetCourseBlocksWSParams = { + courseid: number; // Course id. + returncontents?: boolean; // Whether to return the block contents. +}; + +/** + * Data returned by core_block_get_course_blocks WS. + */ +export type CoreCourseBlocksWSResponse = { + blocks: CoreCourseBlock[]; // List of blocks in the course. + warnings?: CoreStatusWithWarningsWSResponse[]; +}; + +/** + * Block data type. + */ +export type CoreCourseBlock = { + instanceid: number; // Block instance id. + name: string; // Block name. + region: string; // Block region. + positionid: number; // Position id. + collapsible: boolean; // Whether the block is collapsible. + dockable: boolean; // Whether the block is dockable. + weight?: number; // Used to order blocks within a region. + visible?: boolean; // Whether the block is visible. + contents?: { + title: string; // Block title. + content: string; // Block contents. + contentformat: number; // Content format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + footer: string; // Block footer. + files: CoreWSExternalFile[]; + }; // Block contents (if required). + configs?: { // Block instance and plugin configuration settings. + name: string; // Name. + value: string; // JSON encoded representation of the config value. + type: string; // Type (instance or plugin). + }[]; +}; + +/** + * Params of core_course_get_contents WS. + */ +export type CoreCourseGetContentsParams = { + courseid: number; // Course id. + options?: { // Options, used since Moodle 2.9. + /** + * The expected keys (value format) are: + * + * excludemodules (bool) Do not return modules, return only the sections structure + * excludecontents (bool) Do not return module contents (i.e: files inside a resource) + * includestealthmodules (bool) Return stealth modules for students in a special + * section (with id -1) + * sectionid (int) Return only this section + * sectionnumber (int) Return only this section with number (order) + * cmid (int) Return only this module information (among the whole sections structure) + * modname (string) Return only modules with this name "label, forum, etc..." + * modid (int) Return only the module with this id (to be used with modname. + */ + name: string; + value: string | number | boolean; // The value of the option, this param is personaly validated in the external function. + }[]; +}; + +/** + * Data returned by core_course_get_contents WS. + */ +export type CoreCourseSection = { + id: number; // Section ID. + name: string; // Section name. + visible?: number; // Is the section visible. + summary: string; // Section description. + summaryformat: number; // Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + section?: number; // Section number inside the course. + hiddenbynumsections?: number; // Whether is a section hidden in the course format. + uservisible?: boolean; // Is the section visible for the user?. + availabilityinfo?: string; // Availability information. + modules: CoreCourseModule[]; +}; + +/** + * Params of core_course_get_course_module WS. + */ +type CoreCourseGetCourseModuleWSParams = { + cmid: number; // The course module id. +}; + +/** + * Params of core_course_get_course_module_by_instance WS. + */ +type CoreCourseGetCourseModuleByInstanceWSParams = { + module: string; // The module name. + instance: number; // The module instance id. +}; + +/** + * Data returned by core_course_get_course_module and core_course_get_course_module_by_instance WS. + */ +export type CoreCourseGetCourseModuleWSResponse = { + cm: CoreCourseModuleBasicInfo; + warnings?: CoreStatusWithWarningsWSResponse[]; +}; + + +/** + * Course module type. + */ +export type CoreCourseModule = { // List of module. + id: number; // Activity id. + course?: number; // The course id. + url?: string; // Activity url. + name: string; // Activity module name. + instance?: number; // Instance id. + contextid?: number; // Activity context id. + description?: string; // Activity description. + visible?: number; // Is the module visible. + uservisible?: boolean; // Is the module visible for the user?. + availabilityinfo?: string; // Availability information. + visibleoncoursepage?: number; // Is the module visible on course page. + modicon: string; // Activity icon url. + modname: string; // Activity module type. + modplural: string; // Activity module plural name. + availability?: string; // Module availability settings. + indent: number; // Number of identation in the site. + onclick?: string; // Onclick action. + afterlink?: string; // After link info to be displayed. + customdata?: string; // Custom data (JSON encoded). + noviewlink?: boolean; // Whether the module has no view page. + completion?: number; // Type of completion tracking: 0 means none, 1 manual, 2 automatic. + completiondata?: { // Module completion data. + state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail. + timecompleted: number; // Timestamp for completion status. + overrideby: number; // The user id who has overriden the status. + valueused?: boolean; // Whether the completion status affects the availability of another activity. + }; + contents: { + type: string; // A file or a folder or external link. + filename: string; // Filename. + filepath: string; // Filepath. + filesize: number; // Filesize. + fileurl?: string; // Downloadable file url. + content?: string; // Raw content, will be used when type is content. + timecreated: number; // Time created. + timemodified: number; // Time modified. + sortorder: number; // Content sort order. + mimetype?: string; // File mime type. + isexternalfile?: boolean; // Whether is an external file. + repositorytype?: string; // The repository type for external files. + userid: number; // User who added this content to moodle. + author: string; // Content owner. + license: string; // Content license. + tags?: { // Tags. + id: number; // Tag id. + name: string; // Tag name. + rawname: string; // The raw, unnormalised name for the tag as entered by users. + isstandard: boolean; // Whether this tag is standard. + tagcollid: number; // Tag collection id. + taginstanceid: number; // Tag instance id. + taginstancecontextid: number; // Context the tag instance belongs to. + itemid: number; // Id of the record tagged. + ordering: number; // Tag ordering. + flag: number; // Whether the tag is flagged as inappropriate. + }[]; + }[]; + contentsinfo?: { // Contents summary information. + filescount: number; // Total number of files. + filessize: number; // Total files size. + lastmodified: number; // Last time files were modified. + mimetypes: string[]; // Files mime types. + repositorytype?: string; // The repository type for the main file. + }; +}; + +/** + * Course module basic info type. + */ +export type CoreCourseModuleGradeInfo = { + grade?: number; // Grade (max value or scale id). + scale?: string; // Scale items (if used). + gradepass?: string; // Grade to pass (float). + gradecat?: number; // Grade category. + advancedgrading?: { // Advanced grading settings. + area: string; // Gradable area name. + method: string; // Grading method. + }[]; + outcomes?: { // Outcomes information. + id: string; // Outcome id. + name: string; // Outcome full name. + scale: string; // Scale items. + }[]; +}; + +/** + * Course module basic info type. + */ +export type CoreCourseModuleBasicInfo = CoreCourseModuleGradeInfo & { + id: number; // The course module id. + course: number; // The course id. + module: number; // The module type id. + name: string; // The activity name. + modname: string; // The module component name (forum, assign, etc..). + instance: number; // The activity instance id. + section: number; // The module section id. + sectionnum: number; // The module section number. + groupmode: number; // Group mode. + groupingid: number; // Grouping id. + completion: number; // If completion is enabled. + idnumber?: string; // Module id number. + added?: number; // Time added. + score?: number; // Score. + indent?: number; // Indentation. + visible?: number; // If visible. + visibleoncoursepage?: number; // If visible on course page. + visibleold?: number; // Visible old. + completiongradeitemnumber?: number; // Completion grade item. + completionview?: number; // Completion view setting. + completionexpected?: number; // Completion time expected. + showdescription?: number; // If the description is showed. + availability?: string; // Availability settings. +}; + +/** + * Params of core_course_view_course WS. + */ +type CoreCourseViewCourseWSParams = { + courseid: number; // Id of the course. + sectionnumber?: number; // Section number. +}; + +/** + * Params of core_completion_update_activity_completion_status_manually WS. + */ +type CoreCompletionUpdateActivityCompletionStatusManuallyWSParams = { + cmid: number; // Course module id. + completed: boolean; // Activity completed or not. +}; + +export class CoreCourse extends makeSingleton(CoreCourseProvider) {} diff --git a/src/core/features/courses/components/components.module.ts b/src/core/features/courses/components/components.module.ts new file mode 100644 index 000000000..6b9eae348 --- /dev/null +++ b/src/core/features/courses/components/components.module.ts @@ -0,0 +1,56 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormsModule } from '@angular/forms'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; + +import { CoreCoursesCourseListItemComponent } from './course-list-item/course-list-item'; +import { CoreCoursesCourseProgressComponent } from './course-progress/course-progress'; +import { CoreCoursesCourseOptionsMenuComponent } from './course-options-menu/course-options-menu'; +import { CoreCoursesSelfEnrolPasswordComponent } from './self-enrol-password/self-enrol-password'; + +@NgModule({ + declarations: [ + CoreCoursesCourseListItemComponent, + CoreCoursesCourseProgressComponent, + CoreCoursesCourseOptionsMenuComponent, + CoreCoursesSelfEnrolPasswordComponent, + ], + imports: [ + CommonModule, + IonicModule, + FormsModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + ], + exports: [ + CoreCoursesCourseListItemComponent, + CoreCoursesCourseProgressComponent, + CoreCoursesCourseOptionsMenuComponent, + CoreCoursesSelfEnrolPasswordComponent, + ], + entryComponents: [ + CoreCoursesCourseOptionsMenuComponent, + ], +}) +export class CoreCoursesComponentsModule {} diff --git a/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html b/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html new file mode 100644 index 000000000..ccf64943c --- /dev/null +++ b/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html @@ -0,0 +1,34 @@ + + + + + + +

+ + + + | + + + + +

+

+ + +

+

+ +

+
+ + + +
diff --git a/src/core/features/courses/components/course-list-item/course-list-item.scss b/src/core/features/courses/components/course-list-item/course-list-item.scss new file mode 100644 index 000000000..dc76218e8 --- /dev/null +++ b/src/core/features/courses/components/course-list-item/course-list-item.scss @@ -0,0 +1,48 @@ +:host { + .course-icon { + color: white; + background: var(--gray-light); + padding: 8px; + font-size: 24px; + border-radius: 50%; + margin-inline-end: 16px; + -webkit-transition: all 50ms ease-in-out; + transition: all 50ms ease-in-out; + } + + ion-icon[course-color="0"] { + color: var(--core-course-color-0); + } + ion-icon[course-color="1"] { + color: var(--core-course-color-1); + } + ion-icon[course-color="2"] { + color: var(--core-course-color-2); + } + ion-icon[course-color="3"] { + color: var(--core-course-color-3); + } + ion-icon[course-color="4"] { + color: var(--core-course-color-4); + } + ion-icon[course-color="5"] { + color: var(--core-course-color-5); + } + ion-icon[course-color="6"] { + color: var(--core-course-color-6); + } + ion-icon[course-color="7"] { + color: var(--core-course-color-7); + } + ion-icon[course-color="8"] { + color: var(--core-course-color-8); + } + ion-icon[course-color="9"] { + color: var(--core-course-color-9); + } + + ion-avatar { + -webkit-transition: all 50ms ease-in-out; + transition: all 50ms ease-in-out; + } +} diff --git a/src/core/features/courses/components/course-list-item/course-list-item.ts b/src/core/features/courses/components/course-list-item/course-list-item.ts new file mode 100644 index 000000000..70030f056 --- /dev/null +++ b/src/core/features/courses/components/course-list-item/course-list-item.ts @@ -0,0 +1,115 @@ +// (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, Input, OnInit } from '@angular/core'; +import { NavController } from '@ionic/angular'; +import { CoreCourses, CoreCourseSearchedData } from '../../services/courses'; +import { CoreCoursesHelper, CoreCourseWithImageAndColor } from '../../services/courses.helper'; + +/** + * This directive is meant to display an item for a list of courses. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-courses-course-list-item', + templateUrl: 'core-courses-course-list-item.html', + styleUrls: ['course-list-item.scss'], +}) +export class CoreCoursesCourseListItemComponent implements OnInit { + + @Input() course!: CoreCourseSearchedData & CoreCourseWithImageAndColor & { + completionusertracked?: boolean; // If the user is completion tracked. + progress?: number; // Progress percentage. + }; // The course to render. + + icons: CoreCoursesEnrolmentIcons[] = []; + isEnrolled = false; + + constructor( + protected navCtrl: NavController, + ) { + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + CoreCoursesHelper.instance.loadCourseColorAndImage(this.course); + + // Check if the user is enrolled in the course. + try { + const course = await CoreCourses.instance.getUserCourse(this.course.id); + this.course.progress = course.progress; + this.course.completionusertracked = course.completionusertracked; + + this.isEnrolled = true; + } catch { + this.isEnrolled = false; + this.icons = []; + + this.course.enrollmentmethods.forEach((instance) => { + if (instance === 'self') { + this.icons.push({ + label: 'core.courses.selfenrolment', + icon: 'fas-key', + }); + } else if (instance === 'guest') { + this.icons.push({ + label: 'core.courses.allowguests', + icon: 'fas-unlock', + }); + } else if (instance === 'paypal') { + this.icons.push({ + label: 'core.courses.paypalaccepted', + icon: 'fab-paypal', + }); + } + }); + + if (this.icons.length == 0) { + this.icons.push({ + label: 'core.courses.notenrollable', + icon: 'fas-lock', + }); + } + } + } + + /** + * Open a course. + * + * @param course The course to open. + */ + openCourse(): void { + /* if (this.isEnrolled) { + CoreCourseHelper.instance.openCourse(this.course); + } else { + this.navCtrl.navigateForward('/courses/preview', { queryParams: { course: this.course } }); + } */ + // @todo while opencourse function is not completed, open preview page. + this.navCtrl.navigateForward('/courses/preview', { queryParams: { course: this.course } }); + } + +} + +/** + * Enrolment icons to show on the list with a label. + */ +export type CoreCoursesEnrolmentIcons = { + label: string; + icon: string; +}; diff --git a/src/core/features/courses/components/course-options-menu/core-courses-course-options-menu.html b/src/core/features/courses/components/course-options-menu/core-courses-course-options-menu.html new file mode 100644 index 000000000..95e5294e3 --- /dev/null +++ b/src/core/features/courses/components/course-options-menu/core-courses-course-options-menu.html @@ -0,0 +1,26 @@ + + + +

{{ prefetch.statusTranslatable | translate }}

+
+ + +

{{ 'addon.storagemanager.deletecourse' | translate }}

+
+ + +

{{ 'core.courses.hidecourse' | translate }}

+
+ + +

{{ 'core.courses.show' | translate }}

+
+ + +

{{ 'core.courses.addtofavourites' | translate }}

+
+ + +

{{ 'core.courses.removefromfavourites' | translate }}

+
+ diff --git a/src/core/features/courses/components/course-options-menu/course-options-menu.ts b/src/core/features/courses/components/course-options-menu/course-options-menu.ts new file mode 100644 index 000000000..db2e6ca64 --- /dev/null +++ b/src/core/features/courses/components/course-options-menu/course-options-menu.ts @@ -0,0 +1,59 @@ +// (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, OnInit } from '@angular/core'; +import { NavParams, PopoverController } from '@ionic/angular'; +import { CoreCourses } from '../../services/courses'; +import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '../../services/courses.helper'; +import { CorePrefetchStatusInfo } from '@features/course/services/course.helper'; + +/** + * This component is meant to display a popover with the course options. + */ +@Component({ + selector: 'core-courses-course-options-menu', + templateUrl: 'core-courses-course-options-menu.html', +}) +export class CoreCoursesCourseOptionsMenuComponent implements OnInit { + + course!: CoreEnrolledCourseDataWithExtraInfoAndOptions; // The course. + prefetch!: CorePrefetchStatusInfo; // The prefecth info. + + downloadCourseEnabled = false; + + constructor( + navParams: NavParams, + protected popoverController: PopoverController, + ) { + this.course = navParams.get('course') || {}; + this.prefetch = navParams.get('prefetch') || {}; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + } + + /** + * Do an action over the course. + * + * @param action Action name to take. + */ + action(action: string): void { + this.popoverController.dismiss(action); + } + +} diff --git a/src/core/features/courses/components/course-progress/core-courses-course-progress.html b/src/core/features/courses/components/course-progress/core-courses-course-progress.html new file mode 100644 index 000000000..84acd224b --- /dev/null +++ b/src/core/features/courses/components/course-progress/core-courses-course-progress.html @@ -0,0 +1,57 @@ + +
+ +
+ + +

+ + + + | + + + + +

+

+ + +

+
+ +
+ +
+ +
+ + + + + + + +
+
+ + + + +
diff --git a/src/core/features/courses/components/course-progress/course-progress.scss b/src/core/features/courses/components/course-progress/course-progress.scss new file mode 100644 index 000000000..67b5b9470 --- /dev/null +++ b/src/core/features/courses/components/course-progress/course-progress.scss @@ -0,0 +1,163 @@ +:host { + ion-card { + display: flex; + flex-direction: column; + align-self: stretch; + height: calc(100% - 20px); + + &[course-color="0"] .core-course-thumb { + background: var(--core-course-color-0); + } + &[course-color="1"] .core-course-thumb { + background: var(--core-course-color-1); + } + &[course-color="2"] .core-course-thumb { + background: var(--core-course-color-2); + } + &[course-color="3"] .core-course-thumb { + background: var(--core-course-color-3); + } + &[course-color="4"] .core-course-thumb { + background: var(--core-course-color-4); + } + &[course-color="5"] .core-course-thumb { + background: var(--core-course-color-5); + } + &[course-color="6"] .core-course-thumb { + background: var(--core-course-color-6); + } + &[course-color="7"] .core-course-thumb { + background: var(--core-course-color-7); + } + &[course-color="8"] .core-course-thumb { + background: var(--core-course-color-8); + } + &[course-color="9"] .core-course-thumb { + background: var(--core-course-color-9); + } + + .core-course-thumb { + padding-top: 40%; + width: 100%; + overflow: hidden; + cursor: pointer; + pointer-events: auto; + position: relative; + background-position: center; + background-size: cover; + -webkit-transition: all 50ms ease-in-out; + transition: all 50ms ease-in-out; + + &.core-course-color-img { + background: white; + } + + img { + position: absolute; + top: 0; + bottom: 0; + margin: auto; + } + } + + .core-course-additional-info { + margin-bottom: 8px; + } + + .core-course-header { + padding-top: 8px; + padding-bottom: 8px; + .core-course-title { + margin: 5px 0; + flex-grow: 1; + + h2 ion-icon { + margin-right: 4px; + color: var(--core-star-color); + } + } + + &.core-course-more-than-title { + padding-bottom: 0; + } + + .core-button-spinner { + margin: 0; + } + .core-button-spinner ion-spinner { + vertical-align: top; // the better option for most scenarios + vertical-align: -webkit-baseline-middle; // the best for those that support it + } + + .core-button-spinner .core-icon-downloaded { + font-size: 28.8px; + margin-top: 8px; + vertical-align: top; + } + + .item-button[icon-only] { + min-width: 50px; + width: 50px; + } + } + } + + button { + z-index: 1; + } +} + +// @todo +:host-context(.core-horizontal-scroll) { + /*@include horizontal_scroll_item(80%, 250px, 300px);*/ + + ion-card { + .core-course-thumb { + padding-top: 30%; + } + + .core-course-link { + /*@include padding(4px, 0px, 4px, 8px);*/ + .core-course-additional-info { + font-size: 1.2rem; + } + + .core-course-title { + margin: 3px 0; + + h2 { + font-size: 1.5rem; + ion-icon { + margin-right: 2px; + } + } + + &.core-course-with-buttons { + max-width: calc(100% - 40px); + } + } + .core-button-spinner { + min-height: 40px; + min-width: 40px; + + ion-spinner { + width: 20px; + height: 20px; + } + } + .item-button[icon-only] { + min-width: 40px; + width: 40px; + font-size: 1.5rem; + padding: 8px; + } + + } + } +} + +:host-context(body.version-3-1) { + .core-course-thumb{ + display: none; + } +} diff --git a/src/core/features/courses/components/course-progress/course-progress.ts b/src/core/features/courses/components/course-progress/course-progress.ts new file mode 100644 index 000000000..446902294 --- /dev/null +++ b/src/core/features/courses/components/course-progress/course-progress.ts @@ -0,0 +1,283 @@ +// (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, Input, OnInit, OnDestroy } from '@angular/core'; +import { PopoverController } from '@ionic/angular'; +import { CoreEventCourseStatusChanged, CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +// import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreCourses } from '@features/courses/services/courses'; +import { CoreCourse, CoreCourseProvider } from '@features/course/services/course'; +import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course.helper'; +import { Translate } from '@singletons/core.singletons'; +import { CoreConstants } from '@/core/constants'; +import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '../../services/courses.helper'; +import { CoreCoursesCourseOptionsMenuComponent } from '../course-options-menu/course-options-menu'; + +/** + * This component is meant to display a course for a list of courses with progress. + * + * Example usage: + * + * + * + */ +@Component({ + selector: 'core-courses-course-progress', + templateUrl: 'core-courses-course-progress.html', + styleUrls: ['course-progress.scss'], +}) +export class CoreCoursesCourseProgressComponent implements OnInit, OnDestroy { + + @Input() course!: CoreEnrolledCourseDataWithExtraInfoAndOptions; // The course to render. + @Input() showAll = false; // If true, will show all actions, options, star and progress. + @Input() showDownload = true; // If true, will show download button. Only works if the options menu is not shown. + + courseStatus = CoreConstants.NOT_DOWNLOADED; + isDownloading = false; + prefetchCourseData: CorePrefetchStatusInfo = { + icon: '', + statusTranslatable: 'core.loading', + status: '', + loading: true, + }; + + showSpinner = false; + downloadCourseEnabled = false; + courseOptionMenuEnabled = false; + + protected isDestroyed = false; + protected courseStatusObserver?: CoreEventObserver; + protected siteUpdatedObserver?: CoreEventObserver; + + constructor( + protected popoverCtrl: PopoverController, + ) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + + if (this.downloadCourseEnabled) { + this.initPrefetchCourse(); + } + + // This field is only available from 3.6 onwards. + this.courseOptionMenuEnabled = this.showAll && typeof this.course.isfavourite != 'undefined'; + + // Refresh the enabled flag if site is updated. + this.siteUpdatedObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + const wasEnabled = this.downloadCourseEnabled; + + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + + if (!wasEnabled && this.downloadCourseEnabled) { + // Download course is enabled now, initialize it. + this.initPrefetchCourse(); + } + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Initialize prefetch course. + */ + async initPrefetchCourse(): Promise { + if (typeof this.courseStatusObserver != 'undefined') { + // Already initialized. + return; + } + + // Listen for status change in course. + this.courseStatusObserver = CoreEvents.on(CoreEvents.COURSE_STATUS_CHANGED, (data: CoreEventCourseStatusChanged) => { + if (data.courseId == this.course.id || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) { + this.updateCourseStatus(data.status); + } + }, CoreSites.instance.getCurrentSiteId()); + + // Determine course prefetch icon. + const status = await CoreCourse.instance.getCourseStatus(this.course.id); + + this.prefetchCourseData = CoreCourseHelper.instance.getCourseStatusIconAndTitleFromStatus(status); + this.courseStatus = status; + + if (this.prefetchCourseData.loading) { + // Course is being downloaded. Get the download promise. + const promise = CoreCourseHelper.instance.getCourseDownloadPromise(this.course.id); + if (promise) { + // There is a download promise. If it fails, show an error. + promise.catch((error) => { + if (!this.isDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }); + } else { + // No download, this probably means that the app was closed while downloading. Set previous status. + CoreCourse.instance.setCoursePreviousStatus(this.course.id); + } + } + + } + + /** + * Open a course. + */ + openCourse(): void { + CoreCourseHelper.instance.openCourse(this.course); + } + + /** + * Prefetch the course. + * + * @param e Click event. + */ + prefetchCourse(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + /* @ todo try { + CoreCourseHelper.instance.confirmAndPrefetchCourse(this.prefetchCourseData, this.course); + } catch (error) { + if (!this.isDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }*/ + } + + /** + * Delete the course. + */ + async deleteCourse(): Promise { + try { + await CoreDomUtils.instance.showDeleteConfirm('core.course.confirmdeletestoreddata'); + } catch (error) { + if (CoreDomUtils.instance.isCanceledError(error)) { + throw error; + } + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + await CoreCourseHelper.instance.deleteCourseFiles(this.course.id); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, Translate.instance.instant('core.errordeletefile')); + } finally { + modal.dismiss(); + } + } + + /** + * Update the course status icon and title. + * + * @param status Status to show. + */ + protected updateCourseStatus(status: string): void { + this.prefetchCourseData = CoreCourseHelper.instance.getCourseStatusIconAndTitleFromStatus(status); + + this.courseStatus = status; + } + + /** + * Show the context menu. + * + * @param e Click Event. + * @todo + */ + async showCourseOptionsMenu(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + const popover = await this.popoverCtrl.create({ + component: CoreCoursesCourseOptionsMenuComponent, + componentProps: { + course: this.course, + courseStatus: this.courseStatus, + prefetch: this.prefetchCourseData, + }, + event: e, + }); + popover.present(); + + const action = await popover.onDidDismiss(); + + if (action.data) { + switch (action.data) { + case 'download': + if (!this.prefetchCourseData.loading) { + this.prefetchCourse(e); + } + break; + case 'delete': + if (this.courseStatus == 'downloaded' || this.courseStatus == 'outdated') { + this.deleteCourse(); + } + break; + case 'hide': + this.setCourseHidden(true); + break; + case 'show': + this.setCourseHidden(false); + break; + case 'favourite': + this.setCourseFavourite(true); + break; + case 'unfavourite': + this.setCourseFavourite(false); + break; + default: + break; + } + } + + } + + /** + * Hide/Unhide the course from the course list. + * + * @param hide True to hide and false to show. + * @todo + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected setCourseHidden(hide: boolean): void { + return; + } + + /** + * Favourite/Unfavourite the course from the course list. + * + * @param favourite True to favourite and false to unfavourite. + * @todo + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected setCourseFavourite(favourite: boolean): void { + return; + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + + this.siteUpdatedObserver?.off(); + this.courseStatusObserver?.off(); + } + +} diff --git a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html new file mode 100644 index 000000000..3012c1939 --- /dev/null +++ b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html @@ -0,0 +1,34 @@ + + + + + + {{ 'core.courses.selfenrolment' | translate }} + + + + + + + + + +
+ + + + + + +
+ {{ 'core.courses.enrolme' | translate }} +
+
+
diff --git a/src/core/features/courses/components/self-enrol-password/self-enrol-password.ts b/src/core/features/courses/components/self-enrol-password/self-enrol-password.ts new file mode 100644 index 000000000..d44874761 --- /dev/null +++ b/src/core/features/courses/components/self-enrol-password/self-enrol-password.ts @@ -0,0 +1,63 @@ +// (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, ViewChild, ElementRef } from '@angular/core'; +import { ModalController, NavParams } from '@ionic/angular'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; + +/** + * Modal that displays a form to enter a password to self enrol in a course. + */ +@Component({ + selector: 'page-core-courses-self-enrol-password', + templateUrl: 'self-enrol-password.html', +}) +export class CoreCoursesSelfEnrolPasswordComponent { + + @ViewChild('enrolPasswordForm') formElement!: ElementRef; + password = ''; + + constructor( + protected modalCtrl: ModalController, + navParams: NavParams, + ) { + this.password = navParams.get('password') || ''; + } + + /** + * Close help modal. + */ + close(): void { + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + + this.modalCtrl.dismiss(); + } + + /** + * Submit password. + * + * @param e Event. + * @param password Password to submit. + */ + submitPassword(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId()); + + this.modalCtrl.dismiss(this.password); + } + +} diff --git a/src/core/features/courses/courses.module.ts b/src/core/features/courses/courses.module.ts index 570eb5569..fd53773b2 100644 --- a/src/core/features/courses/courses.module.ts +++ b/src/core/features/courses/courses.module.ts @@ -13,24 +13,86 @@ // limitations under the License. import { NgModule } from '@angular/core'; -import { Routes } from '@angular/router'; +import { RouterModule, Routes } from '@angular/router'; import { CoreHomeRoutingModule } from '../mainmenu/pages/home/home-routing.module'; import { CoreHomeDelegate } from '../mainmenu/services/home.delegate'; import { CoreDashboardHomeHandler } from './services/handlers/dashboard.home'; +import { CoreCoursesMyCoursesHomeHandler } from './services/handlers/my-courses.home'; -const routes: Routes = [ +const homeRoutes: Routes = [ { path: 'dashboard', loadChildren: () => import('@features/courses/pages/dashboard/dashboard.page.module').then(m => m.CoreCoursesDashboardPageModule), }, + { + path: 'courses/my', + loadChildren: () => + import('@features/courses/pages/my-courses/my-courses.page.module') + .then(m => m.CoreCoursesMyCoursesPageModule), + }, +]; + +const routes: Routes = [ + { + path: 'courses', + children: [ + { + path: '', + redirectTo: 'my', + pathMatch: 'full', + }, + { + path: 'categories', + redirectTo: 'categories/root', // Fake "id". + pathMatch: 'full', + }, + { + path: 'categories/:id', + loadChildren: () => + import('@features/courses/pages/categories/categories.page.module') + .then(m => m.CoreCoursesCategoriesPageModule), + }, + { + path: 'all', + loadChildren: () => + import('@features/courses/pages/available-courses/available-courses.page.module') + .then(m => m.CoreCoursesAvailableCoursesPageModule), + }, + { + path: 'search', + loadChildren: () => + import('@features/courses/pages/search/search.page.module') + .then(m => m.CoreCoursesSearchPageModule), + }, + { + path: 'my', + loadChildren: () => + import('@features/courses/pages/my-courses/my-courses.page.module') + .then(m => m.CoreCoursesMyCoursesPageModule), + }, + { + path: 'preview', + loadChildren: () => + import('@features/courses/pages/course-preview/course-preview.page.module') + .then(m => m.CoreCoursesCoursePreviewPageModule), + }, + ], + }, ]; @NgModule({ - imports: [CoreHomeRoutingModule.forChild(routes)], - exports: [CoreHomeRoutingModule], + imports: [ + CoreHomeRoutingModule.forChild(homeRoutes), + RouterModule.forChild(routes), + ], + exports: [ + CoreHomeRoutingModule, + RouterModule, + ], providers: [ CoreDashboardHomeHandler, + CoreCoursesMyCoursesHomeHandler, ], }) export class CoreCoursesModule { @@ -38,8 +100,10 @@ export class CoreCoursesModule { constructor( homeDelegate: CoreHomeDelegate, coursesDashboardHandler: CoreDashboardHomeHandler, + coursesMyCoursesHandler: CoreCoursesMyCoursesHomeHandler, ) { homeDelegate.registerHandler(coursesDashboardHandler); + homeDelegate.registerHandler(coursesMyCoursesHandler); } } diff --git a/src/core/features/courses/pages/available-courses/available-courses.html b/src/core/features/courses/pages/available-courses/available-courses.html new file mode 100644 index 000000000..eafff0704 --- /dev/null +++ b/src/core/features/courses/pages/available-courses/available-courses.html @@ -0,0 +1,19 @@ + + + + + + {{ 'core.courses.availablecourses' | translate }} + + + + + + + + + + + + + diff --git a/src/core/features/courses/pages/available-courses/available-courses.page.module.ts b/src/core/features/courses/pages/available-courses/available-courses.page.module.ts new file mode 100644 index 000000000..ef135f506 --- /dev/null +++ b/src/core/features/courses/pages/available-courses/available-courses.page.module.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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +import { CoreCoursesAvailableCoursesPage } from './available-courses.page'; + + +const routes: Routes = [ + { + path: '', + component: CoreCoursesAvailableCoursesPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCoursesComponentsModule, + ], + declarations: [ + CoreCoursesAvailableCoursesPage, + ], + exports: [RouterModule], +}) +export class CoreCoursesAvailableCoursesPageModule { } diff --git a/src/core/features/courses/pages/available-courses/available-courses.page.ts b/src/core/features/courses/pages/available-courses/available-courses.page.ts new file mode 100644 index 000000000..d979dbd41 --- /dev/null +++ b/src/core/features/courses/pages/available-courses/available-courses.page.ts @@ -0,0 +1,78 @@ +// (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, OnInit } from '@angular/core'; +import { IonRefresher } from '@ionic/angular'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreCourses, CoreCourseSearchedData } from '../../services/courses'; + +/** + * Page that displays available courses in current site. + */ +@Component({ + selector: 'page-core-courses-available-courses', + templateUrl: 'available-courses.html', +}) +export class CoreCoursesAvailableCoursesPage implements OnInit { + + courses: CoreCourseSearchedData[] = []; + coursesLoaded = false; + + /** + * View loaded. + */ + ngOnInit(): void { + this.loadCourses().finally(() => { + this.coursesLoaded = true; + }); + } + + /** + * Load the courses. + * + * @return Promise resolved when done. + */ + protected async loadCourses(): Promise { + const frontpageCourseId = CoreSites.instance.getCurrentSite()!.getSiteHomeId(); + + try { + const courses = await CoreCourses.instance.getCoursesByField(); + + this.courses = courses.filter((course) => course.id != frontpageCourseId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + } + } + + /** + * Refresh the courses. + * + * @param refresher Refresher. + */ + refreshCourses(refresher: CustomEvent): void { + const promises: Promise[] = []; + + promises.push(CoreCourses.instance.invalidateUserCourses()); + promises.push(CoreCourses.instance.invalidateCoursesByField()); + + Promise.all(promises).finally(() => { + this.loadCourses().finally(() => { + refresher?.detail.complete(); + }); + }); + } + +} diff --git a/src/core/features/courses/pages/categories/categories.html b/src/core/features/courses/pages/categories/categories.html new file mode 100644 index 000000000..1ff31c8ab --- /dev/null +++ b/src/core/features/courses/pages/categories/categories.html @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + +

+ +

+
+
+ + +

+ +

+
+
+ +
+ + +

{{ 'core.courses.categories' | translate }}

+
+
+
+ + + +

+ + +

+
+ {{category.coursecount}} +
+
+
+ +
+ + +

{{ 'core.courses.courses' | translate }}

+
+
+ +
+ + +
+
diff --git a/src/core/features/courses/pages/categories/categories.page.module.ts b/src/core/features/courses/pages/categories/categories.page.module.ts new file mode 100644 index 000000000..2266c2d4d --- /dev/null +++ b/src/core/features/courses/pages/categories/categories.page.module.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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +import { CoreCoursesCategoriesPage } from './categories.page'; + + +const routes: Routes = [ + { + path: '', + component: CoreCoursesCategoriesPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCoursesComponentsModule, + ], + declarations: [ + CoreCoursesCategoriesPage, + ], + exports: [RouterModule], +}) +export class CoreCoursesCategoriesPageModule { } diff --git a/src/core/features/courses/pages/categories/categories.page.ts b/src/core/features/courses/pages/categories/categories.page.ts new file mode 100644 index 000000000..1980fcdf4 --- /dev/null +++ b/src/core/features/courses/pages/categories/categories.page.ts @@ -0,0 +1,123 @@ +// (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, OnInit } from '@angular/core'; +import { IonRefresher, NavController } from '@ionic/angular'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCategoryData, CoreCourses, CoreCourseSearchedData } from '../../services/courses'; +import { Translate } from '@singletons/core.singletons'; +import { ActivatedRoute } from '@angular/router'; + +/** + * Page that displays a list of categories and the courses in the current category if any. + */ +@Component({ + selector: 'page-core-courses-categories', + templateUrl: 'categories.html', +}) +export class CoreCoursesCategoriesPage implements OnInit { + + title: string; + currentCategory?: CoreCategoryData; + categories: CoreCategoryData[] = []; + courses: CoreCourseSearchedData[] = []; + categoriesLoaded = false; + + protected categoryId = 0; + + constructor( + protected navCtrl: NavController, + protected route: ActivatedRoute, + ) { + this.title = Translate.instance.instant('core.courses.categories'); + } + + /** + * View loaded. + */ + ngOnInit(): void { + this.categoryId = parseInt(this.route.snapshot.params['id'], 0) || 0; + + this.fetchCategories().finally(() => { + this.categoriesLoaded = true; + }); + } + + /** + * Fetch the categories. + * + * @return Promise resolved when done. + */ + protected async fetchCategories(): Promise { + try{ + const categories: CoreCategoryData[] = await CoreCourses.instance.getCategories(this.categoryId, true); + + this.currentCategory = undefined; + + const index = categories.findIndex((category) => category.id == this.categoryId); + + if (index >= 0) { + this.currentCategory = categories[index]; + // Delete current Category to avoid problems with the formatTree. + delete categories[index]; + } + + // Sort by depth and sortorder to avoid problems formatting Tree. + categories.sort((a, b) => { + if (a.depth == b.depth) { + return (a.sortorder > b.sortorder) ? 1 : ((b.sortorder > a.sortorder) ? -1 : 0); + } + + return a.depth > b.depth ? 1 : -1; + }); + + this.categories = CoreUtils.instance.formatTree(categories, 'parent', 'id', this.categoryId); + + if (this.currentCategory) { + this.title = this.currentCategory.name; + + try { + this.courses = await CoreCourses.instance.getCoursesByField('category', this.categoryId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + } + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorloadcategories', true); + } + } + + /** + * Refresh the categories. + * + * @param refresher Refresher. + */ + refreshCategories(refresher?: CustomEvent): void { + const promises: Promise[] = []; + + promises.push(CoreCourses.instance.invalidateUserCourses()); + promises.push(CoreCourses.instance.invalidateCategories(this.categoryId, true)); + promises.push(CoreCourses.instance.invalidateCoursesByField('category', this.categoryId)); + promises.push(CoreSites.instance.getCurrentSite()!.invalidateConfig()); + + Promise.all(promises).finally(() => { + this.fetchCategories().finally(() => { + refresher?.detail.complete(); + }); + }); + } + +} diff --git a/src/core/features/courses/pages/course-preview/course-preview.html b/src/core/features/courses/pages/course-preview/course-preview.html new file mode 100644 index 000000000..5d4e09c2b --- /dev/null +++ b/src/core/features/courses/pages/course-preview/course-preview.html @@ -0,0 +1,125 @@ + + + + + + + + + + + + + +
+
+ +
+
+
+ + + +

+

+

+ {{course.startdate * 1000 | coreFormatDate:"strftimedatefullshort" }} + - {{course.enddate * 1000 | coreFormatDate:"strftimedatefullshort" }} +

+
+
+ + + + + + + + + + + +

{{ 'core.teachers' | translate }}

+
+
+ + + + +

{{contact.fullname}}

+
+
+ +
+ + + + +
+ + + + : + + + + +
+
+
+
+ +
+ + +

{{ instance.name }}

+ + {{ 'core.courses.enrolme' | translate }} + +
+
+
+ + +

{{ 'core.courses.paypalaccepted' | translate }}

+

{{ 'core.paymentinstant' | translate }}

+ + {{ 'core.courses.sendpaymentbutton' | translate }} + +
+
+ +

{{ 'core.courses.notenrollable' | translate }}

+
+ + + + + + +

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

+
+ + +

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

+
+ + +

{{ 'core.openinbrowser' | translate }}

+
+
+
+
diff --git a/src/core/features/courses/pages/course-preview/course-preview.page.module.ts b/src/core/features/courses/pages/course-preview/course-preview.page.module.ts new file mode 100644 index 000000000..00f9eaa9f --- /dev/null +++ b/src/core/features/courses/pages/course-preview/course-preview.page.module.ts @@ -0,0 +1,51 @@ +// (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 { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; + +import { CoreCoursesCoursePreviewPage } from './course-preview.page'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +const routes: Routes = [ + { + path: '', + component: CoreCoursesCoursePreviewPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + CoreCoursesComponentsModule, + ], + declarations: [ + CoreCoursesCoursePreviewPage, + ], + exports: [RouterModule], +}) +export class CoreCoursesCoursePreviewPageModule { } diff --git a/src/core/features/courses/pages/course-preview/course-preview.page.ts b/src/core/features/courses/pages/course-preview/course-preview.page.ts new file mode 100644 index 000000000..623a955b9 --- /dev/null +++ b/src/core/features/courses/pages/course-preview/course-preview.page.ts @@ -0,0 +1,475 @@ +// (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, OnDestroy, NgZone, OnInit } from '@angular/core'; +import { ModalController, IonRefresher, NavController } from '@ionic/angular'; +import { CoreApp } from '@services/app'; +import { CoreEventCourseStatusChanged, CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { + CoreCourseEnrolmentMethod, + CoreCourseGetCoursesData, + CoreCourses, + CoreCourseSearchedData, + CoreCoursesProvider, + CoreEnrolledCourseData, +} from '@features/courses/services/courses'; +// import { CoreCourseOptionsDelegate } from '@features/course/services/options-delegate'; +import { CoreCourse, CoreCourseProvider } from '@features/course/services/course'; +import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course.helper'; +import { Translate } from '@singletons/core.singletons'; +import { ActivatedRoute } from '@angular/router'; +import { CoreConstants } from '@/core/constants'; +import { CoreCoursesSelfEnrolPasswordComponent } from '../../components/self-enrol-password/self-enrol-password'; + +/** + * Page that allows "previewing" a course and enrolling in it if enabled and not enrolled. + */ +@Component({ + selector: 'page-core-courses-course-preview', + templateUrl: 'course-preview.html', + styleUrls: ['course-preview.scss'], +}) +export class CoreCoursesCoursePreviewPage implements OnInit, OnDestroy { + + course?: CoreCourseSearchedData; + isEnrolled = false; + canAccessCourse = true; + selfEnrolInstances: CoreCourseEnrolmentMethod[] = []; + paypalEnabled = false; + dataLoaded = false; + avoidOpenCourse = false; + prefetchCourseData: CorePrefetchStatusInfo = { + icon: '', + statusTranslatable: 'core.loading', + status: '', + loading: true, + }; + + statusDownloaded = CoreConstants.DOWNLOADED; + + downloadCourseEnabled: boolean; + courseUrl = ''; + courseImageUrl?: string; + + protected isGuestEnabled = false; + protected guestInstanceId?: number; + protected enrolmentMethods: CoreCourseEnrolmentMethod[] = []; + protected waitStart = 0; + protected enrolUrl = ''; + protected paypalReturnUrl = ''; + protected isMobile: boolean; + protected pageDestroyed = false; + protected courseStatusObserver?: CoreEventObserver; + + constructor( + protected modalCtrl: ModalController, + // protected courseOptionsDelegate: CoreCourseOptionsDelegate, + protected zone: NgZone, + protected route: ActivatedRoute, + protected navCtrl: NavController, + ) { + this.isMobile = CoreApp.instance.isMobile(); + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + + if (this.downloadCourseEnabled) { + // Listen for status change in course. + this.courseStatusObserver = CoreEvents.on(CoreEvents.COURSE_STATUS_CHANGED, (data: CoreEventCourseStatusChanged) => { + if (data.courseId == this.course!.id || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) { + this.updateCourseStatus(data.status); + } + }, CoreSites.instance.getCurrentSiteId()); + } + } + + /** + * View loaded. + */ + async ngOnInit(): Promise { + const navParams = this.route.snapshot.queryParams; + this.course = navParams['course']; + this.avoidOpenCourse = !!navParams['avoidOpenCourse']; + + if (!this.course) { + this.navCtrl.back(); + + return; + } + + const currentSite = CoreSites.instance.getCurrentSite(); + const currentSiteUrl = currentSite && currentSite.getURL(); + + this.paypalEnabled = this.course!.enrollmentmethods?.indexOf('paypal') > -1; + this.enrolUrl = CoreTextUtils.instance.concatenatePaths(currentSiteUrl!, 'enrol/index.php?id=' + this.course!.id); + this.courseUrl = CoreTextUtils.instance.concatenatePaths(currentSiteUrl!, 'course/view.php?id=' + this.course!.id); + this.paypalReturnUrl = CoreTextUtils.instance.concatenatePaths(currentSiteUrl!, 'enrol/paypal/return.php'); + if (this.course.overviewfiles.length > 0) { + this.courseImageUrl = this.course.overviewfiles[0].fileurl; + } + + try { + await this.getCourse(); + } finally { + if (this.downloadCourseEnabled) { + + // Determine course prefetch icon. + this.prefetchCourseData = await CoreCourseHelper.instance.getCourseStatusIconAndTitle(this.course!.id); + + if (this.prefetchCourseData.loading) { + // Course is being downloaded. Get the download promise. + const promise = CoreCourseHelper.instance.getCourseDownloadPromise(this.course!.id); + if (promise) { + // There is a download promise. If it fails, show an error. + promise.catch((error) => { + if (!this.pageDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }); + } else { + // No download, this probably means that the app was closed while downloading. Set previous status. + CoreCourse.instance.setCoursePreviousStatus(this.course!.id); + } + } + } + } + } + + /** + * Check if the user can access as guest. + * + * @return Promise resolved if can access as guest, rejected otherwise. Resolve param indicates if + * password is required for guest access. + */ + protected async canAccessAsGuest(): Promise { + if (!this.isGuestEnabled) { + throw Error('Guest access is not enabled.'); + } + + // Search instance ID of guest enrolment method. + const method = this.enrolmentMethods.find((method) => method.type == 'guest'); + this.guestInstanceId = method?.id; + + if (this.guestInstanceId) { + const info = await CoreCourses.instance.getCourseGuestEnrolmentInfo(this.guestInstanceId); + if (!info.status) { + // Not active, reject. + throw Error('Guest access is not enabled.'); + } + + return info.passwordrequired; + } + + throw Error('Guest enrollment method not found.'); + } + + /** + * Convenience function to get course. We use this to determine if a user can see the course or not. + */ + protected async getCourse(): Promise { + // Get course enrolment methods. + this.selfEnrolInstances = []; + + try { + this.enrolmentMethods = await CoreCourses.instance.getCourseEnrolmentMethods(this.course!.id); + + this.enrolmentMethods.forEach((method) => { + if (method.type === 'self') { + this.selfEnrolInstances.push(method); + } else if (method.type === 'guest') { + this.isGuestEnabled = true; + } + }); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting enrolment data'); + } + + try { + let course: CoreEnrolledCourseData | CoreCourseGetCoursesData; + + // Check if user is enrolled in the course. + try { + course = await CoreCourses.instance.getUserCourse(this.course!.id); + this.isEnrolled = true; + } catch { + // The user is not enrolled in the course. Use getCourses to see if it's an admin/manager and can see the course. + this.isEnrolled = false; + + course = await CoreCourses.instance.getCourse(this.course!.id); + } + + // Success retrieving the course, we can assume the user has permissions to view it. + this.course!.fullname = course.fullname || this.course!.fullname; + this.course!.summary = course.summary || this.course!.summary; + this.canAccessCourse = true; + } catch { + // The user is not an admin/manager. Check if we can provide guest access to the course. + try { + this.canAccessCourse = !(await this.canAccessAsGuest()); + } catch { + this.canAccessCourse = false; + } + } + + if (!CoreSites.instance.getCurrentSite()?.isVersionGreaterEqualThan('3.7')) { + try { + const available = await CoreCourses.instance.isGetCoursesByFieldAvailableInSite(); + if (available) { + const course = await CoreCourses.instance.getCourseByField('id', this.course!.id); + + this.course!.customfields = course.customfields; + } + } catch { + // Ignore errors. + } + } + + this.dataLoaded = true; + } + + /** + * Open the course. + */ + openCourse(): void { + if (!this.canAccessCourse || this.avoidOpenCourse) { + // Course cannot be opened or we are avoiding opening because we accessed from inside a course. + return; + } + + CoreCourseHelper.instance.openCourse(this.course!); + } + + /** + * Enrol using PayPal. + */ + async paypalEnrol(): Promise { + // We cannot control browser in browser. + if (!this.isMobile || !CoreSites.instance.getCurrentSite()) { + return; + } + + let hasReturnedFromPaypal = false; + + const urlLoaded = (event: InAppBrowserEvent): void => { + if (event.url.indexOf(this.paypalReturnUrl) != -1) { + hasReturnedFromPaypal = true; + } else if (event.url.indexOf(this.courseUrl) != -1 && hasReturnedFromPaypal) { + // User reached the course index page after returning from PayPal, close the InAppBrowser. + inAppClosed(); + window.close(); + } + }; + const inAppClosed = (): void => { + // InAppBrowser closed, refresh data. + unsubscribeAll(); + + if (!this.dataLoaded) { + return; + } + this.dataLoaded = false; + this.refreshData(); + }; + const unsubscribeAll = (): void => { + inAppLoadSubscription?.unsubscribe(); + inAppExitSubscription?.unsubscribe(); + }; + + // Open the enrolment page in InAppBrowser. + const window = await CoreSites.instance.getCurrentSite()!.openInAppWithAutoLogin(this.enrolUrl); + + // Observe loaded pages in the InAppBrowser to check if the enrol process has ended. + const inAppLoadSubscription = window.on('loadstart').subscribe((event) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + this.zone.run(() => urlLoaded(event)); + }); + // Observe window closed. + const inAppExitSubscription = window.on('exit').subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + this.zone.run(inAppClosed); + }); + } + + /** + * User clicked in a self enrol button. + * + * @param instanceId The instance ID of the enrolment method. + */ + async selfEnrolClicked(instanceId: number): Promise { + try { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.courses.confirmselfenrol')); + + this.selfEnrolInCourse('', instanceId); + } catch { + // User cancelled. + } + } + + /** + * Self enrol in a course. + * + * @param password Password to use. + * @param instanceId The instance ID. + * @return Promise resolved when self enrolled. + */ + async selfEnrolInCourse(password: string, instanceId: number): Promise { + const modal = await CoreDomUtils.instance.showModalLoading('core.loading', true); + + try { + await CoreCourses.instance.selfEnrol(this.course!.id, password, instanceId); + + // Close modal and refresh data. + this.isEnrolled = true; + this.dataLoaded = false; + + // Sometimes the list of enrolled courses takes a while to be updated. Wait for it. + await this.waitForEnrolled(true); + + this.refreshData().finally(() => { + // My courses have been updated, trigger event. + CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, { + courseId: this.course!.id, + course: this.course, + action: CoreCoursesProvider.ACTION_ENROL, + }, CoreSites.instance.getCurrentSiteId()); + }); + + modal?.dismiss(); + } catch (error) { + modal?.dismiss(); + + if (error && error.errorcode === CoreCoursesProvider.ENROL_INVALID_KEY) { + // Initialize the self enrol modal. + const selfEnrolModal = await this.modalCtrl.create( + { + component: CoreCoursesSelfEnrolPasswordComponent, + componentProps: { password }, + }, + ); + + // Invalid password, show the modal to enter the password. + await selfEnrolModal.present(); + + const data = await selfEnrolModal.onDidDismiss(); + if (typeof data?.data != 'undefined') { + this.selfEnrolInCourse(data.data, instanceId); + + return; + } + + if (!password) { + // No password entered, don't show error. + return; + } + } + + CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorselfenrol', true); + } + } + + /** + * Refresh the data. + * + * @param refresher The refresher if this was triggered by a Pull To Refresh. + */ + async refreshData(refresher?: CustomEvent): Promise { + const promises: Promise[] = []; + + promises.push(CoreCourses.instance.invalidateUserCourses()); + promises.push(CoreCourses.instance.invalidateCourse(this.course!.id)); + promises.push(CoreCourses.instance.invalidateCourseEnrolmentMethods(this.course!.id)); + // @todo promises.push(this.courseOptionsDelegate.clearAndInvalidateCoursesOptions(this.course!.id)); + if (CoreSites.instance.getCurrentSite() && !CoreSites.instance.getCurrentSite()!.isVersionGreaterEqualThan('3.7')) { + promises.push(CoreCourses.instance.invalidateCoursesByField('id', this.course!.id)); + } + if (this.guestInstanceId) { + promises.push(CoreCourses.instance.invalidateCourseGuestEnrolmentInfo(this.guestInstanceId)); + } + + await Promise.all(promises).finally(() => this.getCourse()).finally(() => { + refresher?.detail.complete(); + }); + } + + /** + * Update the course status icon and title. + * + * @param status Status to show. + */ + protected updateCourseStatus(status: string): void { + this.prefetchCourseData = CoreCourseHelper.instance.getCourseStatusIconAndTitleFromStatus(status); + } + + /** + * Wait for the user to be enrolled in the course. + * + * @param first If it's the first call (true) or it's a recursive call (false). + * @return Promise resolved when enrolled or timeout. + */ + protected async waitForEnrolled(first?: boolean): Promise { + if (first) { + this.waitStart = Date.now(); + } + + // Check if user is enrolled in the course. + try { + CoreCourses.instance.invalidateUserCourses(); + } catch { + // Ignore errors. + } + + try { + CoreCourses.instance.getUserCourse(this.course!.id); + } catch { + // Not enrolled, wait a bit and try again. + if (this.pageDestroyed || (Date.now() - this.waitStart > 60000)) { + // Max time reached or the user left the view, stop. + return; + } + + return new Promise((resolve): void => { + setTimeout(async () => { + if (!this.pageDestroyed) { + // Wait again. + await this.waitForEnrolled(); + } + resolve(); + }, 5000); + }); + } + } + + /** + * Prefetch the course. + */ + prefetchCourse(): void { + /* @todo CoreCourseHelper.instance.confirmAndPrefetchCourse(this.prefetchCourseData, this.course).catch((error) => { + if (!this.pageDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + });*/ + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.pageDestroyed = true; + + if (this.courseStatusObserver) { + this.courseStatusObserver.off(); + } + } + +} diff --git a/src/core/features/courses/pages/course-preview/course-preview.scss b/src/core/features/courses/pages/course-preview/course-preview.scss new file mode 100644 index 000000000..ba783b098 --- /dev/null +++ b/src/core/features/courses/pages/course-preview/course-preview.scss @@ -0,0 +1,40 @@ +:host { + --scroll-factor: 0.5; + --translate-z: calc(-2 * var(--scroll-factor))px; + --scale: calc(1 + var(--scroll-factor) * 2); + + perspective: 1px; + perspective-origin: center top; + transform-style: preserve-3d; + + .core-course-thumb-parallax-content { + transform: translateZ(0); + -webkit-filter: drop-shadow(0px -3px 3px rgba(var(--drop-shadow))); + filter: drop-shadow(0px -3px 3px rgba(var(--drop-shadow))); + } + .core-course-thumb-parallax { + height: 40vw; + max-height: 35vh; + z-index: -1; + overflow: hidden; + } + .core-course-thumb { + overflow: hidden; + text-align: center; + cursor: pointer; + pointer-events: auto; + transform-origin: center top; + + /** + * Calculated with scroll-factor: 0.5; + * translate-z: -2 * $scroll-factor px; + * scale: 1 + $scroll-factor * 2; + */ + transform: translateZ(-1px) scale(2); + } + + + .core-customfieldvalue core-format-text { + display: inline; + } +} diff --git a/src/core/features/courses/pages/dashboard/dashboard.html b/src/core/features/courses/pages/dashboard/dashboard.html index a2a6c650c..53ab4c47d 100644 --- a/src/core/features/courses/pages/dashboard/dashboard.html +++ b/src/core/features/courses/pages/dashboard/dashboard.html @@ -1,3 +1,16 @@ + + + + + + + + + diff --git a/src/core/features/courses/pages/dashboard/dashboard.page.ts b/src/core/features/courses/pages/dashboard/dashboard.page.ts index 458512990..16382a526 100644 --- a/src/core/features/courses/pages/dashboard/dashboard.page.ts +++ b/src/core/features/courses/pages/dashboard/dashboard.page.ts @@ -12,25 +12,93 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { NavController } from '@ionic/angular'; + +import { CoreCourses, CoreCoursesProvider } from '../../services/courses'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; /** - * Page that displays the Home. + * Page that displays the dashboard page. */ @Component({ selector: 'page-core-courses-dashboard', templateUrl: 'dashboard.html', styleUrls: ['dashboard.scss'], }) -export class CoreCoursesDashboardPage implements OnInit { +export class CoreCoursesDashboardPage implements OnInit, OnDestroy { + + searchEnabled = false; + downloadEnabled = false; + downloadCourseEnabled = false; + downloadCoursesEnabled = false; + downloadEnabledIcon = 'far-square'; + + protected updateSiteObserver?: CoreEventObserver; siteName = 'Hello world'; + constructor( + protected navCtrl: NavController, + ) { } + /** * Initialize the component. */ ngOnInit(): void { - // @todo + this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite(); + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite(); + + // Refresh the enabled flags if site is updated. + this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite(); + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite(); + + this.switchDownload(this.downloadEnabled && this.downloadCourseEnabled && this.downloadCoursesEnabled); + }, CoreSites.instance.getCurrentSiteId()); } + /** + * Toggle download enabled. + */ + toggleDownload(): void { + this.switchDownload(!this.downloadEnabled); + } + + /** + * Convenience function to switch download enabled. + * + * @param enable If enable or disable. + */ + protected switchDownload(enable: boolean): void { + this.downloadEnabled = (this.downloadCourseEnabled || this.downloadCoursesEnabled) && enable; + this.downloadEnabledIcon = this.downloadEnabled ? 'far-check-square' : 'far-square'; + CoreEvents.trigger(CoreCoursesProvider.EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED, { enabled: this.downloadEnabled }); + } + + /** + * Open page to manage courses storage. + */ + manageCoursesStorage(): void { + // @todo this.navCtrl.navigateForward(['/courses/storage']); + } + + /** + * Go to search courses. + */ + openSearch(): void { + this.navCtrl.navigateForward(['/courses/search']); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.updateSiteObserver?.off(); + } + + } diff --git a/src/core/features/courses/pages/my-courses/my-courses.html b/src/core/features/courses/pages/my-courses/my-courses.html new file mode 100644 index 000000000..6cbd9c972 --- /dev/null +++ b/src/core/features/courses/pages/my-courses/my-courses.html @@ -0,0 +1,50 @@ + + + + + + {{ 'core.courses.mycourses' | translate }} + + + + + + + + + + + {{downloadAllCoursesBadge}} + + + + + + + + + + + + + + + + + + + + + +

{{ 'core.courses.searchcoursesadvice' | translate }}

+
+
+
diff --git a/src/core/features/courses/pages/my-courses/my-courses.page.module.ts b/src/core/features/courses/pages/my-courses/my-courses.page.module.ts new file mode 100644 index 000000000..2dd0d13a5 --- /dev/null +++ b/src/core/features/courses/pages/my-courses/my-courses.page.module.ts @@ -0,0 +1,51 @@ +// (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 { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormsModule } from '@angular/forms'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +import { CoreCoursesMyCoursesPage } from './my-courses.page'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +const routes: Routes = [ + { + path: '', + component: CoreCoursesMyCoursesPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + FormsModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCoursesComponentsModule, + ], + declarations: [ + CoreCoursesMyCoursesPage, + ], + exports: [RouterModule], +}) +export class CoreCoursesMyCoursesPageModule { } diff --git a/src/core/features/courses/pages/my-courses/my-courses.page.ts b/src/core/features/courses/pages/my-courses/my-courses.page.ts new file mode 100644 index 000000000..c9b9aa1ae --- /dev/null +++ b/src/core/features/courses/pages/my-courses/my-courses.page.ts @@ -0,0 +1,215 @@ +// (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, OnInit, OnDestroy, ViewChild } from '@angular/core'; +import { NavController, IonSearchbar, IonRefresher } from '@ionic/angular'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { + CoreCoursesProvider, + CoreCoursesMyCoursesUpdatedEventData, + CoreCourses, +} from '../../services/courses'; +import { CoreCoursesHelper, CoreEnrolledCourseDataWithExtraInfoAndOptions } from '../../services/courses.helper'; +import { CoreCourseHelper } from '@features/course/services/course.helper'; +import { CoreConstants } from '@/core/constants'; +// import { CoreCourseOptionsDelegate } from '@core/course/services/options-delegate'; + +/** + * Page that displays the list of courses the user is enrolled in. + */ +@Component({ + selector: 'page-core-courses-my-courses', + templateUrl: 'my-courses.html', +}) +export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { + + @ViewChild(IonSearchbar) searchbar!: IonSearchbar; + + courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = []; + filteredCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = []; + searchEnabled = false; + filter = ''; + showFilter = false; + coursesLoaded = false; + downloadAllCoursesIcon = CoreConstants.NOT_DOWNLOADED_ICON; + downloadAllCoursesLoading = false; + downloadAllCoursesBadge = ''; + downloadAllCoursesEnabled = false; + + protected myCoursesObserver: CoreEventObserver; + protected siteUpdatedObserver: CoreEventObserver; + protected isDestroyed = false; + protected courseIds = ''; + + constructor( + protected navCtrl: NavController, + ) { + // Update list if user enrols in a course. + this.myCoursesObserver = CoreEvents.on( + CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, + (data: CoreCoursesMyCoursesUpdatedEventData) => { + + if (data.action == CoreCoursesProvider.ACTION_ENROL) { + this.fetchCourses(); + } + }, + + CoreSites.instance.getCurrentSiteId(), + ); + + // Refresh the enabled flags if site is updated. + this.siteUpdatedObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite(); + this.downloadAllCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite(); + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite(); + this.downloadAllCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite(); + + this.fetchCourses().finally(() => { + this.coursesLoaded = true; + }); + } + + /** + * Fetch the user courses. + * + * @return Promise resolved when done. + */ + protected async fetchCourses(): Promise { + try { + const courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = await CoreCourses.instance.getUserCourses(); + const courseIds = courses.map((course) => course.id); + + this.courseIds = courseIds.join(','); + + await CoreCoursesHelper.instance.loadCoursesExtraInfo(courses); + + if (CoreCourses.instance.canGetAdminAndNavOptions()) { + const options = await CoreCourses.instance.getCoursesAdminAndNavOptions(courseIds); + courses.forEach((course) => { + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + }); + } + + this.courses = courses; + this.filteredCourses = this.courses; + this.filter = ''; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + } + } + + /** + * Refresh the courses. + * + * @param refresher Refresher. + */ + refreshCourses(refresher: CustomEvent): void { + const promises: Promise[] = []; + + promises.push(CoreCourses.instance.invalidateUserCourses()); + // @todo promises.push(this.courseOptionsDelegate.clearAndInvalidateCoursesOptions()); + if (this.courseIds) { + promises.push(CoreCourses.instance.invalidateCoursesByField('ids', this.courseIds)); + } + + Promise.all(promises).finally(() => { + this.fetchCourses().finally(() => { + refresher?.detail.complete(); + }); + }); + } + + /** + * Show or hide the filter. + */ + switchFilter(): void { + this.filter = ''; + this.showFilter = !this.showFilter; + this.filteredCourses = this.courses; + if (this.showFilter) { + setTimeout(() => { + this.searchbar.setFocus(); + }, 500); + } + } + + /** + * The filter has changed. + * + * @param Received Event. + */ + filterChanged(event?: Event): void { + const target = event?.target || null; + const newValue = target ? String(target.value).trim().toLowerCase() : null; + if (!newValue || !this.courses) { + this.filteredCourses = this.courses; + } else { + // Use displayname if avalaible, or fullname if not. + if (this.courses.length > 0 && typeof this.courses[0].displayname != 'undefined') { + this.filteredCourses = this.courses.filter((course) => course.displayname!.toLowerCase().indexOf(newValue) > -1); + } else { + this.filteredCourses = this.courses.filter((course) => course.fullname.toLowerCase().indexOf(newValue) > -1); + } + } + } + + /** + * Prefetch all the courses. + * + * @return Promise resolved when done. + */ + async prefetchCourses(): Promise { + this.downloadAllCoursesLoading = true; + + try { + await CoreCourseHelper.instance.confirmAndPrefetchCourses(this.courses, (progress) => { + this.downloadAllCoursesBadge = progress.count + ' / ' + progress.total; + }); + } catch (error) { + if (!this.isDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + } + + this.downloadAllCoursesBadge = ''; + this.downloadAllCoursesLoading = false; + } + + /** + * Go to search courses. + */ + openSearch(): void { + this.navCtrl.navigateForward(['/courses/search']); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + this.myCoursesObserver?.off(); + this.siteUpdatedObserver?.off(); + } + +} diff --git a/src/core/features/courses/pages/search/search.html b/src/core/features/courses/pages/search/search.html new file mode 100644 index 000000000..a67c4bb35 --- /dev/null +++ b/src/core/features/courses/pages/search/search.html @@ -0,0 +1,24 @@ + + + + + + {{ 'core.courses.searchcourses' | translate }} + + + + + + + +

{{ 'core.courses.totalcoursesearchresults' | translate:{$a: total} }}

+
+ + + +
+ +
+ diff --git a/src/core/features/courses/pages/search/search.page.module.ts b/src/core/features/courses/pages/search/search.page.module.ts new file mode 100644 index 000000000..a24a9bb09 --- /dev/null +++ b/src/core/features/courses/pages/search/search.page.module.ts @@ -0,0 +1,52 @@ +// (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 { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; + +import { CoreCoursesSearchPage } from './search.page'; + +const routes: Routes = [ + { + path: '', + component: CoreCoursesSearchPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCoursesComponentsModule, + CoreSearchComponentsModule, + ], + declarations: [ + CoreCoursesSearchPage, + ], + exports: [RouterModule], +}) +export class CoreCoursesSearchPageModule { } + diff --git a/src/core/features/courses/pages/search/search.page.ts b/src/core/features/courses/pages/search/search.page.ts new file mode 100644 index 000000000..57c04555e --- /dev/null +++ b/src/core/features/courses/pages/search/search.page.ts @@ -0,0 +1,100 @@ +// (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 { CoreDomUtils } from '@services/utils/dom'; +import { CoreCourseBasicSearchedData, CoreCourses } from '../../services/courses'; + +/** + * Page that allows searching for courses. + */ +@Component({ + selector: 'page-core-courses-search', + templateUrl: 'search.html', +}) +export class CoreCoursesSearchPage { + + total = 0; + courses: CoreCourseBasicSearchedData[] = []; + canLoadMore = false; + loadMoreError = false; + + protected page = 0; + protected currentSearch = ''; + + /** + * Search a new text. + * + * @param text The text to search. + */ + async search(text: string): Promise { + this.currentSearch = text; + this.courses = []; + this.page = 0; + this.total = 0; + + const modal = await CoreDomUtils.instance.showModalLoading('core.searching', true); + this.searchCourses().finally(() => { + modal.dismiss(); + }); + } + + /** + * Clear search box. + */ + clearSearch(): void { + this.currentSearch = ''; + this.courses = []; + this.page = 0; + this.total = 0; + } + + /** + * Load more results. + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + */ + loadMoreResults(infiniteComplete?: () => void ): void { + this.searchCourses().finally(() => { + infiniteComplete && infiniteComplete(); + }); + } + + /** + * Search courses or load the next page of current search. + * + * @return Promise resolved when done. + */ + protected async searchCourses(): Promise { + this.loadMoreError = false; + + try { + const response = await CoreCourses.instance.search(this.currentSearch, this.page); + + if (this.page === 0) { + this.courses = response.courses; + } else { + this.courses = this.courses.concat(response.courses); + } + this.total = response.total; + + this.page++; + this.canLoadMore = this.courses.length < this.total; + } catch (error) { + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + CoreDomUtils.instance.showErrorModalDefault(error, 'core.courses.errorsearching', true); + } + } + +} diff --git a/src/core/features/courses/services/courses.helper.ts b/src/core/features/courses/services/courses.helper.ts new file mode 100644 index 000000000..f2fac5251 --- /dev/null +++ b/src/core/features/courses/services/courses.helper.ts @@ -0,0 +1,217 @@ +// (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 { Injectable } from '@angular/core'; +// import { PopoverController } from '@ionic/angular'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSites } from '@services/sites'; +import { CoreCourses, CoreCourseSearchedData, CoreCourseUserAdminOrNavOptionIndexed, CoreEnrolledCourseData } from './courses'; +import { makeSingleton } from '@singletons/core.singletons'; +import { CoreWSExternalFile } from '@services/ws'; +// import { AddonCourseCompletionProvider } from '@addon/coursecompletion/providers/coursecompletion'; +// import { CoreCoursePickerMenuPopoverComponent } from '@components/course-picker-menu/course-picker-menu-popover'; + +/** + * Helper to gather some common courses functions. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCoursesHelperProvider { + + /** + * Get the courses to display the course picker popover. If a courseId is specified, it will also return its categoryId. + * + * @param courseId Course ID to get the category. + * @return Promise resolved with the list of courses and the category. + */ + async getCoursesForPopover(): Promise { + // @todo params and logic + } + + /** + * Given a course object returned by core_enrol_get_users_courses and another one returned by core_course_get_courses_by_field, + * load some extra data to the first one. + * + * @param course Course returned by core_enrol_get_users_courses. + * @param courseByField Course returned by core_course_get_courses_by_field. + * @param addCategoryName Whether add category name or not. + */ + loadCourseExtraInfo( + course: CoreEnrolledCourseDataWithExtraInfo, + courseByField: CoreCourseSearchedData, + addCategoryName: boolean = false, + colors?: (string | undefined)[], + ): void { + if (courseByField) { + course.displayname = courseByField.displayname; + course.categoryname = addCategoryName ? courseByField.categoryname : undefined; + course.overviewfiles = course.overviewfiles || courseByField.overviewfiles; + } else { + delete course.displayname; + } + + this.loadCourseColorAndImage(course, colors); + } + + /** + * Given a list of courses returned by core_enrol_get_users_courses, load some extra data using the WebService + * core_course_get_courses_by_field if available. + * + * @param courses List of courses. + * @param loadCategoryNames Whether load category names or not. + * @return Promise resolved when done. + */ + async loadCoursesExtraInfo(courses: CoreEnrolledCourseDataWithExtraInfo[], loadCategoryNames: boolean = false): Promise { + if (!courses.length ) { + // No courses or cannot get the data, stop. + return; + } + + let coursesInfo = {}; + let courseInfoAvailable = false; + + const promises: Promise[] = []; + let colors: (string | undefined)[] = []; + + promises.push(this.loadCourseSiteColors().then((loadedColors) => { + colors = loadedColors; + + return; + })); + + if (CoreCourses.instance.isGetCoursesByFieldAvailable() && (loadCategoryNames || + (typeof courses[0].overviewfiles == 'undefined' && typeof courses[0].displayname == 'undefined'))) { + const courseIds = courses.map((course) => course.id).join(','); + + courseInfoAvailable = true; + + // Get the extra data for the courses. + promises.push(CoreCourses.instance.getCoursesByField('ids', courseIds).then((coursesInfos) => { + coursesInfo = CoreUtils.instance.arrayToObject(coursesInfos, 'id'); + + return; + })); + } + + await Promise.all(promises); + + courses.forEach((course) => { + this.loadCourseExtraInfo(course, courseInfoAvailable ? coursesInfo[course.id] : course, loadCategoryNames, colors); + }); + } + + /** + * Load course colors from site config. + * + * @return course colors RGB. + */ + protected async loadCourseSiteColors(): Promise<(string | undefined)[]> { + const site = CoreSites.instance.getCurrentSite(); + const colors: (string | undefined)[] = []; + + if (site?.isVersionGreaterEqualThan('3.8')) { + try { + const configs = await site.getConfig(); + for (let x = 0; x < 10; x++) { + colors[x] = configs['core_admin_coursecolor' + (x + 1)] || undefined; + } + } catch { + // Ignore errors. + } + } + + return colors; + } + + /** + * Loads the color of the course or the thumb image. + * + * @param course Course data. + * @param colors Colors loaded. + */ + async loadCourseColorAndImage(course: CoreCourseWithImageAndColor, colors?: (string | undefined)[]): Promise { + if (!colors) { + colors = await this.loadCourseSiteColors(); + } + + if (course.overviewfiles && course.overviewfiles[0]) { + course.courseImage = course.overviewfiles[0].fileurl; + } else { + course.colorNumber = course.id % 10; + course.color = colors.length ? colors[course.colorNumber] : undefined; + } + } + + /** + * Get user courses with admin and nav options. + * + * @param sort Sort courses after get them. If sort is not defined it won't be sorted. + * @param slice Slice results to get the X first one. If slice > 0 it will be done after sorting. + * @param filter Filter using some field. + * @param loadCategoryNames Whether load category names or not. + * @return Courses filled with options. + */ + async getUserCoursesWithOptions(): Promise { + // @todo params and logic + } + + /** + * Show a context menu to select a course, and return the courseId and categoryId of the selected course (-1 for all courses). + * Returns an empty object if popover closed without picking a course. + * + * @param event Click event. + * @param courses List of courses, from CoreCoursesHelperProvider.getCoursesForPopover. + * @param courseId The course to select at start. + * @return Promise resolved with the course ID and category ID. + */ + async selectCourse(): Promise { + // @todo params and logic + } + +} + +export class CoreCoursesHelper extends makeSingleton(CoreCoursesHelperProvider) { } + +/** + * Course with colors info and course image. + */ +export type CoreCourseWithImageAndColor = { + id: number; // Course id. + overviewfiles?: CoreWSExternalFile[]; + colorNumber?: number; // Color index number. + color?: string; // Color RGB. + courseImage?: string; // Course thumbnail. +}; + +/** + * Enrolled course data with extra rendering info. + */ +export type CoreEnrolledCourseDataWithExtraInfo = CoreCourseWithImageAndColor & CoreEnrolledCourseData & { + categoryname?: string; // Category name, +}; + +/** + * Enrolled course data with admin and navigation option availability. + */ +export type CoreEnrolledCourseDataWithOptions = CoreEnrolledCourseData & { + navOptions?: CoreCourseUserAdminOrNavOptionIndexed; + admOptions?: CoreCourseUserAdminOrNavOptionIndexed; +}; + +/** + * Enrolled course data with admin and navigation option availability and extra rendering info. + */ +export type CoreEnrolledCourseDataWithExtraInfoAndOptions = CoreEnrolledCourseDataWithExtraInfo & CoreEnrolledCourseDataWithOptions; + diff --git a/src/core/features/courses/services/courses.ts b/src/core/features/courses/services/courses.ts new file mode 100644 index 000000000..4c205ddb4 --- /dev/null +++ b/src/core/features/courses/services/courses.ts @@ -0,0 +1,1592 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreLogger } from '@singletons/logger'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { makeSingleton } from '@singletons/core.singletons'; +import { CoreStatusWithWarningsWSResponse, CoreWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { CoreEvents } from '@singletons/events'; +import { CoreWSError } from '@classes/errors/wserror'; + +const ROOT_CACHE_KEY = 'mmCourses:'; + +/** + * Service that provides some features regarding lists of courses and categories. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCoursesProvider { + + static readonly SEARCH_PER_PAGE = 20; + static readonly ENROL_INVALID_KEY = 'CoreCoursesEnrolInvalidKey'; + static readonly EVENT_MY_COURSES_CHANGED = 'courses_my_courses_changed'; // User course list changed while app is running. + // A course was hidden/favourite, or user enroled in a course. + static readonly EVENT_MY_COURSES_UPDATED = 'courses_my_courses_updated'; + static readonly EVENT_MY_COURSES_REFRESHED = 'courses_my_courses_refreshed'; + static readonly EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED = 'dashboard_download_enabled_changed'; + + // Actions for event EVENT_MY_COURSES_UPDATED. + static readonly ACTION_ENROL = 'enrol'; // User enrolled in a course. + static readonly ACTION_STATE_CHANGED = 'state_changed'; // Course state changed (hidden, favourite). + static readonly ACTION_VIEW = 'view'; // Course viewed. + + // Possible states changed. + static readonly STATE_HIDDEN = 'hidden'; + static readonly STATE_FAVOURITE = 'favourite'; + + protected logger: CoreLogger; + protected userCoursesIds: { [id: number]: boolean } = {}; // Use an object to make it faster to search. + + constructor() { + this.logger = CoreLogger.getInstance('CoreCoursesProvider'); + } + + /** + * Whether current site supports getting course options. + * + * @return Whether current site supports getting course options. + */ + canGetAdminAndNavOptions(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_course_get_user_navigation_options') && + CoreSites.instance.wsAvailableInCurrentSite('core_course_get_user_administration_options'); + } + + /** + * Get categories. They can be filtered by id. + * + * @param categoryId Category ID to get. + * @param addSubcategories If it should add subcategories to the list. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the categories. + */ + async getCategories( + categoryId: number, + addSubcategories: boolean = false, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // Get parent when id is the root category. + const criteriaKey = categoryId == 0 ? 'parent' : 'id'; + const params: CoreCourseGetCategoriesWSParams = { + criteria: [ + { + key: criteriaKey, + value: categoryId, + }, + ], + addsubcategories: addSubcategories, + }; + + const preSets = { + cacheKey: this.getCategoriesCacheKey(categoryId, addSubcategories), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + return site.read('core_course_get_categories', params, preSets); + } + + /** + * Get cache key for get categories methods WS call. + * + * @param categoryId Category ID to get. + * @param addSubcategories If add subcategories to the list. + * @return Cache key. + */ + protected getCategoriesCacheKey(categoryId: number, addSubcategories?: boolean): string { + return ROOT_CACHE_KEY + 'categories:' + categoryId + ':' + !!addSubcategories; + } + + /** + * Given a list of course IDs to get course admin and nav options, return the list of courseIds to use. + * + * @param courseIds Course IDs. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with the list of course IDs. + */ + protected async getCourseIdsForAdminAndNavOptions(courseIds: number[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const siteHomeId = site.getSiteHomeId(); + if (courseIds.length == 1) { + // Only 1 course, check if it belongs to the user courses. If so, use all user courses. + return this.getCourseIdsIfEnrolled(courseIds[0], siteId); + } else { + if (courseIds.length > 1 && courseIds.indexOf(siteHomeId) == -1) { + courseIds.push(siteHomeId); + } + + // Sort the course IDs. + courseIds.sort((a, b) => b - a); + + return courseIds; + } + } + + /** + * Given a course ID, if user is enrolled in the course it will return the IDs of all enrolled courses and site home. + * Return only the course ID otherwise. + * + * @param courseIds Course IDs. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with the list of course IDs. + */ + async getCourseIdsIfEnrolled(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const siteHomeId = site.getSiteHomeId(); + + try { + // Check if user is enrolled in the course. + const courses = await this.getUserCourses(true, siteId); + let useAllCourses = false; + + if (courseId == siteHomeId) { + // It's site home, use all courses. + useAllCourses = true; + } else { + useAllCourses = !!courses.find((course) => course.id == courseId); + } + + if (useAllCourses) { + // User is enrolled, return all the courses. + const courseIds = courses.map((course) => course.id); + + // Always add the site home ID. + courseIds.push(siteHomeId); + + // Sort the course IDs. + courseIds.sort((a, b) => b - a); + + return courseIds; + } + } catch { + // Ignore errors. + } + + return [courseId]; + } + + /** + * Check if download a whole course is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isDownloadCourseDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isDownloadCoursesDisabledInSite(site); + } + + /** + * Check if download a whole course is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isDownloadCourseDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !site || site.isOfflineDisabled() || site.isFeatureDisabled('NoDelegate_CoreCourseDownload'); + } + + /** + * Check if download all courses is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isDownloadCoursesDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isDownloadCoursesDisabledInSite(site); + } + + /** + * Check if download all courses is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isDownloadCoursesDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !site || site.isOfflineDisabled() || site.isFeatureDisabled('NoDelegate_CoreCoursesDownload'); + } + + /** + * Check if My Courses is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isMyCoursesDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isMyCoursesDisabledInSite(site); + } + + /** + * Check if My Courses is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isMyCoursesDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !site || site.isFeatureDisabled('CoreMainMenuDelegate_CoreCourses'); + } + + /** + * Check if Search Courses is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isSearchCoursesDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isSearchCoursesDisabledInSite(site); + } + + /** + * Check if Search Courses is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isSearchCoursesDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !site || site.isFeatureDisabled('CoreCourseOptionsDelegate_search'); + } + + /** + * Get course. + * + * @param id ID of the course to get. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the course. + */ + async getCourse(id: number, siteId?: string): Promise { + const courses = await this.getCourses([id], siteId); + + if (courses && courses.length > 0) { + return courses[0]; + } + + throw Error('Course not found on core_course_get_courses'); + } + + /** + * Get the enrolment methods from a course. + * + * @param id ID of the course. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the methods. + */ + async getCourseEnrolmentMethods(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreEnrolGetCourseEnrolmentMethodsWSParams = { + courseid: id, + }; + const preSets = { + cacheKey: this.getCourseEnrolmentMethodsCacheKey(id), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + return site.read('core_enrol_get_course_enrolment_methods', params, preSets); + } + + /** + * Get cache key for get course enrolment methods WS call. + * + * @param id Course ID. + * @return Cache key. + */ + protected getCourseEnrolmentMethodsCacheKey(id: number): string { + return ROOT_CACHE_KEY + 'enrolmentmethods:' + id; + } + + /** + * Get info from a course guest enrolment method. + * + * @param instanceId Guest instance ID. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when the info is retrieved. + */ + async getCourseGuestEnrolmentInfo(instanceId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: EnrolGuestGetInstanceInfoWSParams = { + instanceid: instanceId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCourseGuestEnrolmentInfoCacheKey(instanceId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const response = await site.read('enrol_guest_get_instance_info', params, preSets); + + return response.instanceinfo; + } + + /** + * Get cache key for get course guest enrolment methods WS call. + * + * @param instanceId Guest instance ID. + * @return Cache key. + */ + protected getCourseGuestEnrolmentInfoCacheKey(instanceId: number): string { + return ROOT_CACHE_KEY + 'guestinfo:' + instanceId; + } + + /** + * Get courses. + * Warning: if the user doesn't have permissions to view some of the courses passed the WS call will fail. + * The user must be able to view ALL the courses passed. + * + * @param ids List of IDs of the courses to get. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the courses. + */ + async getCourses(ids: number[], siteId?: string): Promise { + if (!Array.isArray(ids)) { + throw Error('ids parameter should be an array'); + } + + if (ids.length === 0) { + return []; + } + + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseGetCoursesWSParams = { + options: { + ids: ids, + }, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCoursesCacheKey(ids), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + return site.read('core_course_get_courses', params, preSets); + } + + /** + * Get cache key for get courses WS call. + * + * @param ids Courses IDs. + * @return Cache key. + */ + protected getCoursesCacheKey(ids: number[]): string { + return ROOT_CACHE_KEY + 'course:' + JSON.stringify(ids); + } + + /** + * This function is meant to decrease WS calls. + * When requesting a single course that belongs to enrolled courses, request all enrolled courses because + * the WS call is probably cached. + * + * @param field The field to search. + * @param value The value to match. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the field and value to use. + */ + protected async fixCoursesByFieldParams( + field: string = '', + value: number | string = '', + siteId?: string, + ): Promise<{ field: string; value: number | string }> { + + if (field == 'id' || field == 'ids') { + let courseIds: number[]; + if (typeof value == 'string') { + courseIds = value.split(',').map((id) => parseInt(id, 10)); + } else { + courseIds = [value]; + } + + // Use the same optimization as in get admin and nav options. This will return the course IDs to use. + courseIds = await this.getCourseIdsForAdminAndNavOptions(courseIds, siteId); + + if (courseIds.length > 1) { + return { field: 'ids', value: courseIds.join(',') }; + } else { + return { field: 'id', value: Number(courseIds[0]) }; + } + } else { + // Nothing to do. + return { field: field, value: value }; + } + } + + /** + * Get the first course returned by getCoursesByField. + * + * @param field The field to search. Can be left empty for all courses or: + * id: course id. + * ids: comma separated course ids. + * shortname: course short name. + * idnumber: course id number. + * category: category id the course belongs to. + * @param value The value to match. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the first course. + * @since 3.2 + */ + async getCourseByField(field?: string, value?: string | number, siteId?: string): Promise { + const courses = await this.getCoursesByField(field, value, siteId); + + if (courses && courses.length > 0) { + return courses[0]; + } + + throw Error('Course not found on core_course_get_courses_by_field'); + } + + /** + * Get courses. They can be filtered by field. + * + * @param field The field to search. Can be left empty for all courses or: + * id: course id. + * ids: comma separated course ids. + * shortname: course short name. + * idnumber: course id number. + * category: category id the course belongs to. + * @param value The value to match. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the courses. + * @since 3.2 + */ + async getCoursesByField( + field: string = '', + value: string | number = '', + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const originalValue = value; + + const site = await CoreSites.instance.getSite(siteId); + + const fieldParams = await this.fixCoursesByFieldParams(field, value, siteId); + + const hasChanged = fieldParams.field != field || fieldParams.value != value; + field = fieldParams.field; + value = fieldParams.value; + const data: CoreCourseGetCoursesByFieldWSParams = { + field: field, + value: field ? value : '', + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCoursesByFieldCacheKey(field, value), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + const response = await site.read('core_course_get_courses_by_field', data, preSets); + if (!response.courses) { + throw Error('WS core_course_get_courses_by_field failed'); + } + + if (field == 'ids' && hasChanged) { + // The list of courses requestes was changed to optimize it. + // Return only the ones that were being requested. + const courseIds = String(originalValue).split(',').map((id) => parseInt(id, 10)); + + // Only courses from the original selection. + response.courses = response.courses.filter((course) => courseIds.indexOf(course.id) >= 0); + } + + // Courses will be sorted using sortorder if avalaible. + return response.courses.sort((a, b) => { + if (typeof a.sortorder == 'undefined' && typeof b.sortorder == 'undefined') { + return b.id - a.id; + } + + if (typeof a.sortorder == 'undefined') { + return 1; + } + + if (typeof b.sortorder == 'undefined') { + return -1; + } + + return a.sortorder - b.sortorder; + }); + } + + /** + * Get cache key for get courses WS call. + * + * @param field The field to search. + * @param value The value to match. + * @return Cache key. + */ + protected getCoursesByFieldCacheKey(field: string = '', value: string | number = ''): string { + return ROOT_CACHE_KEY + 'coursesbyfield:' + field + ':' + value; + } + + /** + * Get courses matching the given custom field. Only works in online. + * + * @param customFieldName Custom field name. + * @param customFieldValue Custom field value. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of courses. + * @since 3.8 + */ + async getEnrolledCoursesByCustomField( + customFieldName: string, + customFieldValue: string, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCourseGetEnrolledCoursesByTimelineClassificationWSParams = { + classification: 'customfield', + customfieldname: customFieldName, + customfieldvalue: customFieldValue, + }; + const preSets: CoreSiteWSPreSets = { + getFromCache: false, + }; + const courses = await site.read( + 'core_course_get_enrolled_courses_by_timeline_classification', + params, + preSets, + ); + if (courses.courses) { + return courses.courses; + } + + throw Error('WS core_course_get_enrolled_courses_by_timeline_classification failed'); + } + + /** + * Check if get courses by field WS is available in a certain site. + * + * @param site Site to check. + * @return Whether get courses by field is available. + * @since 3.2 + */ + isGetCoursesByFieldAvailable(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.wsAvailable('core_course_get_courses_by_field'); + } + + /** + * Check if get courses by field WS is available in a certain site, by site ID. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether get courses by field is available. + * @since 3.2 + */ + async isGetCoursesByFieldAvailableInSite(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isGetCoursesByFieldAvailable(site); + } + + /** + * Get the navigation and administration options for the given courses. + * + * @param courseIds IDs of courses to get. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the options for each course. + */ + async getCoursesAdminAndNavOptions( + courseIds: number[], + siteId?: string, + ): Promise<{ + navOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + admOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + }> { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Get the list of courseIds to use based on the param. + courseIds = await this.getCourseIdsForAdminAndNavOptions(courseIds, siteId); + + let navOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + let admOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + + // Get user navigation and administration options. + try { + navOptions = await this.getUserNavigationOptions(courseIds, siteId); + } catch { + // Couldn't get it, return empty options. + navOptions = {}; + } + + try { + admOptions = await this.getUserAdministrationOptions(courseIds, siteId); + } catch { + // Couldn't get it, return empty options. + admOptions = {}; + } + + return ({ navOptions: navOptions, admOptions: admOptions }); + } + + /** + * Get the common part of the cache keys for user administration options WS calls. + * + * @return Cache key. + */ + protected getUserAdministrationOptionsCommonCacheKey(): string { + return ROOT_CACHE_KEY + 'administrationOptions:'; + } + + /** + * Get cache key for get user administration options WS call. + * + * @param courseIds IDs of courses to get. + * @return Cache key. + */ + protected getUserAdministrationOptionsCacheKey(courseIds: number[]): string { + return this.getUserAdministrationOptionsCommonCacheKey() + courseIds.join(','); + } + + /** + * Get user administration options for a set of courses. + * + * @param courseIds IDs of courses to get. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with administration options for each course. + */ + async getUserAdministrationOptions(courseIds: number[], siteId?: string): Promise { + if (!courseIds || courseIds.length == 0) { + return {}; + } + + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseGetUserAdminOrNavOptionsWSParams = { + courseids: courseIds, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + const response: CoreCourseGetUserAdminOrNavOptionsWSResponse = + await site.read('core_course_get_user_administration_options', params, preSets); + + // Format returned data. + return this.formatUserAdminOrNavOptions(response.courses); + } + + /** + * Get the common part of the cache keys for user navigation options WS calls. + * + * @param courseIds IDs of courses to get. + * @return Cache key. + */ + protected getUserNavigationOptionsCommonCacheKey(): string { + return ROOT_CACHE_KEY + 'navigationOptions:'; + } + + /** + * Get cache key for get user navigation options WS call. + * + * @return Cache key. + */ + protected getUserNavigationOptionsCacheKey(courseIds: number[]): string { + return this.getUserNavigationOptionsCommonCacheKey() + courseIds.join(','); + } + + /** + * Get user navigation options for a set of courses. + * + * @param courseIds IDs of courses to get. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with navigation options for each course. + */ + async getUserNavigationOptions(courseIds: number[], siteId?: string): Promise { + if (!courseIds || courseIds.length == 0) { + return {}; + } + + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseGetUserAdminOrNavOptionsWSParams = { + courseids: courseIds, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserNavigationOptionsCacheKey(courseIds), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + const response: CoreCourseGetUserAdminOrNavOptionsWSResponse = + await site.read('core_course_get_user_navigation_options', params, preSets); + + // Format returned data. + return this.formatUserAdminOrNavOptions(response.courses); + } + + /** + * Format user navigation or administration options. + * + * @param courses Navigation or administration options for each course. + * @return Formatted options. + */ + protected formatUserAdminOrNavOptions(courses: CoreCourseUserAdminOrNavOption[]): CoreCourseUserAdminOrNavOptionCourseIndexed { + const result = {}; + + courses.forEach((course) => { + const options = {}; + + if (course.options) { + course.options.forEach((option) => { + options[option.name] = option.available; + }); + } + + result[course.id] = options; + }); + + return result; + } + + /** + * Get a course the user is enrolled in. This function relies on getUserCourses. + * preferCache=true will try to speed up the response, but the data returned might not be updated. + * + * @param id ID of the course to get. + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the course. + */ + async getUserCourse(id: number, preferCache?: boolean, siteId?: string): Promise { + if (!id) { + throw Error('Invalid id parameter on getUserCourse'); + } + + const courses = await this.getUserCourses(preferCache, siteId); + + const course = courses.find((course) => course.id == id); + + if (course) { + return course; + } + + throw Error('Course not found on core_enrol_get_users_courses'); + } + + /** + * Get user courses. + * + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the courses. + */ + async getUserCourses( + preferCache: boolean = false, + siteId?: string, + strategy?: CoreSitesReadingStrategy, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const userId = site.getUserId(); + const wsParams: CoreEnrolGetUsersCoursesWSParams = { + userid: userId, + }; + const strategyPreSets = strategy + ? CoreSites.instance.getReadingStrategyPreSets(strategy) + : { omitExpires: !!preferCache }; + + const preSets = { + cacheKey: this.getUserCoursesCacheKey(), + getCacheUsingCacheKey: true, + updateFrequency: CoreSite.FREQUENCY_RARELY, + ...strategyPreSets, + }; + + if (site.isVersionGreaterEqualThan('3.7')) { + wsParams.returnusercount = false; + } + + const courses = await site.read('core_enrol_get_users_courses', wsParams, preSets); + + if (this.userCoursesIds) { + // Check if the list of courses has changed. + const added: number[] = []; + const removed: number[] = []; + const previousIds = Object.keys(this.userCoursesIds); + const currentIds = {}; // Use an object to make it faster to search. + + courses.forEach((course) => { + // Move category field to categoryid on a course. + course.categoryid = course.category; + delete course.category; + + currentIds[course.id] = true; + + if (!this.userCoursesIds[course.id]) { + // Course added. + added.push(course.id); + } + }); + + if (courses.length - added.length != previousIds.length) { + // A course was removed, check which one. + previousIds.forEach((id) => { + if (!currentIds[id]) { + // Course removed. + removed.push(Number(id)); + } + }); + } + + if (added.length || removed.length) { + // At least 1 course was added or removed, trigger the event. + CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_CHANGED, { + added: added, + removed: removed, + }, site.getId()); + } + + this.userCoursesIds = currentIds; + } else { + this.userCoursesIds = {}; + + // Store the list of courses. + courses.forEach((course) => { + // Move category field to categoryid on a course. + course.categoryid = course.category; + delete course.category; + + this.userCoursesIds[course.id] = true; + }); + } + + return courses; + } + + /** + * Get cache key for get user courses WS call. + * + * @return Cache key. + */ + protected getUserCoursesCacheKey(): string { + return ROOT_CACHE_KEY + 'usercourses'; + } + + /** + * Invalidates get categories WS call. + * + * @param categoryId Category ID to get. + * @param addSubcategories If it should add subcategories to the list. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCategories(categoryId: number, addSubcategories?: boolean, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCategoriesCacheKey(categoryId, addSubcategories)); + } + + /** + * Invalidates get course WS call. + * + * @param id Course ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + invalidateCourse(id: number, siteId?: string): Promise { + return this.invalidateCourses([id], siteId); + } + + /** + * Invalidates get course enrolment methods WS call. + * + * @param id Course ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCourseEnrolmentMethods(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCourseEnrolmentMethodsCacheKey(id)); + } + + /** + * Invalidates get course guest enrolment info WS call. + * + * @param instanceId Guest instance ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCourseGuestEnrolmentInfo(instanceId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCourseGuestEnrolmentInfoCacheKey(instanceId)); + } + + /** + * Invalidates the navigation and administration options for the given courses. + * + * @param courseIds IDs of courses to get. + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCoursesAdminAndNavOptions(courseIds: number[], siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const ids = await this.getCourseIdsForAdminAndNavOptions(courseIds, siteId); + + const promises: Promise[] = []; + promises.push(this.invalidateUserAdministrationOptionsForCourses(ids, siteId)); + promises.push(this.invalidateUserNavigationOptionsForCourses(ids, siteId)); + + await Promise.all(promises); + } + + /** + * Invalidates get courses WS call. + * + * @param ids Courses IDs. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCourses(ids: number[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCoursesCacheKey(ids)); + } + + /** + * Invalidates get courses by field WS call. + * + * @param field See getCoursesByField for info. + * @param value The value to match. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCoursesByField(field: string = '', value: number | string = '', siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const result = await this.fixCoursesByFieldParams(field, value, siteId); + field = result.field; + value = result.value; + + const site = await CoreSites.instance.getSite(siteId); + + return site.invalidateWsCacheForKey(this.getCoursesByFieldCacheKey(field, value)); + } + + /** + * Invalidates all user administration options. + * + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAdministrationOptions(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserAdministrationOptionsCommonCacheKey()); + } + + /** + * Invalidates user administration options for certain courses. + * + * @param courseIds IDs of courses. + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAdministrationOptionsForCourses(courseIds: number[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserAdministrationOptionsCacheKey(courseIds)); + } + + /** + * Invalidates get user courses WS call. + * + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserCourses(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserCoursesCacheKey()); + } + + /** + * Invalidates all user navigation options. + * + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserNavigationOptions(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserNavigationOptionsCommonCacheKey()); + } + + /** + * Invalidates user navigation options for certain courses. + * + * @param courseIds IDs of courses. + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserNavigationOptionsForCourses(courseIds: number[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserNavigationOptionsCacheKey(courseIds)); + } + + /** + * Check if WS to retrieve guest enrolment data is available. + * + * @return Whether guest WS is available. + * @since 3.1 + * @deprecated Will always return true since it's available since 3.1. + */ + isGuestWSAvailable(): boolean { + return true; + } + + /** + * Search courses. + * + * @param text Text to search. + * @param page Page to get. + * @param perPage Number of courses per page. Defaults to CoreCoursesProvider.SEARCH_PER_PAGE. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the courses and the total of matches. + */ + async search( + text: string, + page: number = 0, + perPage: number = CoreCoursesProvider.SEARCH_PER_PAGE, + siteId?: string, + ): Promise<{ total: number; courses: CoreCourseBasicSearchedData[] }> { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCourseSearchCoursesWSParams = { + criterianame: 'search', + criteriavalue: text, + page: page, + perpage: perPage, + }; + const preSets: CoreSiteWSPreSets = { + getFromCache: false, + }; + + const response = await site.read('core_course_search_courses', params, preSets); + + return ({ total: response.total, courses: response.courses }); + } + + /** + * Self enrol current user in a certain course. + * + * @param courseId Course ID. + * @param password Password to use. + * @param instanceId Enrol instance ID. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved if the user is enrolled. If the password is invalid, the promise is rejected + * with an object with errorcode = CoreCoursesProvider.ENROL_INVALID_KEY. + */ + async selfEnrol(courseId: number, password: string = '', instanceId?: number, siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const params: EnrolSelfEnrolUserWSParams = { + courseid: courseId, + password: password, + }; + if (instanceId) { + params.instanceid = instanceId; + } + + const response = await site.write('enrol_self_enrol_user', params); + + if (!response) { + throw Error('WS enrol_self_enrol_user failed'); + } + + if (response.status) { + return true; + } + + if (response.warnings && response.warnings.length) { + // Invalid password warnings. + const warning = response.warnings.find((warning) => + warning.warningcode == '2' || warning.warningcode == '3' || warning.warningcode == '4'); + + if (warning) { + throw new CoreWSError({ errorcode: CoreCoursesProvider.ENROL_INVALID_KEY, message: warning.message }); + } else { + throw new CoreWSError(response.warnings[0]); + } + } + + throw Error('WS enrol_self_enrol_user failed without warnings'); + } + + /** + * Set favourite property on a course. + * + * @param courseId Course ID. + * @param favourite If favourite or unfavourite. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when done. + */ + async setFavouriteCourse(courseId: number, favourite: boolean, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseSetFavouriteCoursesWSParams = { + courses: [ + { + id: courseId, + favourite: favourite, + }, + ], + }; + + return site.write('core_course_set_favourite_courses', params); + } + +} + +export class CoreCourses extends makeSingleton(CoreCoursesProvider) {} + +/** + * Data sent to the EVENT_MY_COURSES_UPDATED. + * + * @todo course type. + */ +export type CoreCoursesMyCoursesUpdatedEventData = { + action: string; // Action performed. + courseId?: number; // Course ID affected (if any). + course?: any; // Course affected (if any). + state?: string; // Only for ACTION_STATE_CHANGED. The state that changed (hidden, favourite). + value?: boolean; // The new value for the state changed. +}; + +/** + * Params of core_enrol_get_users_courses WS. + */ +type CoreEnrolGetUsersCoursesWSParams = { + userid: number; // User id. + returnusercount?: boolean; // Include count of enrolled users for each course? This can add several seconds to the response + // time if a user is on several large courses, so set this to false if the value will not be used to improve performance. +}; + +/** + * Data returned by core_enrol_get_users_courses WS. + */ +type CoreEnrolGetUsersCoursesWSResponse = (CoreEnrolledCourseData & { + category?: number; // Course category id. +})[]; + +/** + * Basic data obtained form any course. + */ +export type CoreCourseBasicData = { + id: number; // Course id. + fullname: string; // Course full name. + displayname?: string; // Course display name. + shortname: string; // Course short name. + summary: string; // Summary. + summaryformat: number; // Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + categoryid?: number; // Course category id. +}; + +/** + * Basic data obtained from a course when the user is enrolled. + */ +export type CoreEnrolledCourseBasicData = CoreCourseBasicData & { + idnumber?: string; // Id number of course. + visible?: number; // 1 means visible, 0 means not yet visible course. + format?: string; // Course format: weeks, topics, social, site. + showgrades?: boolean; // True if grades are shown, otherwise false. + lang?: string; // Forced course language. + enablecompletion?: boolean; // True if completion is enabled, otherwise false. + startdate?: number; // Timestamp when the course start. + enddate?: number; // Timestamp when the course end. +}; + +/** + * Course Data model received when the user is enrolled. + */ +export type CoreEnrolledCourseData = CoreEnrolledCourseBasicData & { + enrolledusercount?: number; // Number of enrolled users in this course. + completionhascriteria?: boolean; // If completion criteria is set. + completionusertracked?: boolean; // If the user is completion tracked. + progress?: number; // Progress percentage. + completed?: boolean; // Whether the course is completed. + marker?: number; // Course section marker. + lastaccess?: number; // Last access to the course (timestamp). + isfavourite?: boolean; // If the user marked this course a favourite. + hidden?: boolean; // If the user hide the course from the dashboard. + overviewfiles?: CoreWSExternalFile[]; +}; + +/** + * Basic course data received on search. + */ +export type CoreCourseBasicSearchedData = CoreCourseBasicData & { + categoryid: number; // Category id. + categoryname: string; // Category name. + sortorder?: number; // Sort order in the category. + summaryfiles?: CoreWSExternalFile[]; + overviewfiles: CoreWSExternalFile[]; + contacts: { // Contact users. + id: number; // Contact user id. + fullname: string; // Contact user fullname. + }[]; + enrollmentmethods: string[]; // Enrollment methods list. + customfields?: CoreCourseCustomField[]; // Custom fields and associated values. +}; + +export type CoreCourseSearchedData = CoreCourseBasicSearchedData & { + idnumber?: string; // Id number. + format?: string; // Course format: weeks, topics, social, site,.. + showgrades?: number; // 1 if grades are shown, otherwise 0. + newsitems?: number; // Number of recent items appearing on the course page. + startdate?: number; // Timestamp when the course start. + enddate?: number; // Timestamp when the course end. + maxbytes?: number; // Largest size of file that can be uploaded into. + showreports?: number; // Are activity report shown (yes = 1, no =0). + visible?: number; // 1: available to student, 0:not available. + groupmode?: number; // No group, separate, visible. + groupmodeforce?: number; // 1: yes, 0: no. + defaultgroupingid?: number; // Default grouping id. + enablecompletion?: number; // Completion enabled? 1: yes 0: no. + completionnotify?: number; // 1: yes 0: no. + lang?: string; // Forced course language. + theme?: string; // Fame of the forced theme. + marker?: number; // Current course marker. + legacyfiles?: number; // If legacy files are enabled. + calendartype?: string; // Calendar type. + timecreated?: number; // Time when the course was created. + timemodified?: number; // Last time the course was updated. + requested?: number; // If is a requested course. + cacherev?: number; // Cache revision number. + filters?: { // Course filters. + filter: string; // Filter plugin name. + localstate: number; // Filter state: 1 for on, -1 for off, 0 if inherit. + inheritedstate: number; // 1 or 0 to use when localstate is set to inherit. + }[]; + courseformatoptions?: CoreCourseFormatOption[]; // Additional options for particular course format. +}; + +export type CoreCourseGetCoursesData = CoreEnrolledCourseBasicData & { + categoryid: number; // Category id. + categorysortorder?: number; // Sort order into the category. + newsitems?: number; // Number of recent items appearing on the course page. + /** + * Number of weeks/topics. + * + * @deprecated use courseformatoptions. + */ + numsections?: number; + maxbytes?: number; // Largest size of file that can be uploaded into the course. + showreports?: number; // Are activity report shown (yes = 1, no =0). + /** + * How the hidden sections in the course are displayed to students. + * + * @deprecated use courseformatoptions. + */ + hiddensections?: number; + groupmode?: number; // No group, separate, visible. + groupmodeforce?: number; // 1: yes, 0: no. + defaultgroupingid?: number; // Default grouping id. + timecreated?: number; // Timestamp when the course have been created. + timemodified?: number; // Timestamp when the course have been modified. + completionnotify?: number; // 1: yes 0: no. + forcetheme?: string; // Name of the force theme. + courseformatoptions?: CoreCourseFormatOption[]; // Additional options for particular course format. + customfields?: CoreCourseCustomField[]; // Custom fields and associated values. +}; + +/** + * Course custom fields and associated values. + */ +export type CoreCourseCustomField = { + name: string; // The name of the custom field. + shortname: string; // The shortname of the custom field. + type: string; // The type of the custom field - text, checkbox... + valueraw: string; // The raw value of the custom field. + value: string; // The value of the custom field. +}; + +/** + * Additional options for particular course format. + */ +export type CoreCourseFormatOption = { + name: string; // Course format option name. + value: string; // Course format option value. +}; + +/** + * Indexed course format options. + */ +export type CoreCourseFormatOptionsIndexed = { + [name: string]: string; +}; + +/** + * Params of core_course_get_courses_by_field WS. + */ +type CoreCourseGetCoursesByFieldWSParams = { + /** + * The field to search can be left empty for all courses or: + * id: course id + * ids: comma separated course ids + * shortname: course short name + * idnumber: course id number + * category: category id the course belongs to. + */ + field?: string; + value?: string | number; // The value to match. +}; + +/** + * Data returned by core_course_get_courses_by_field WS. + */ +export type CoreCourseGetCoursesByFieldWSResponse = { + courses: CoreCourseSearchedData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_course_search_courses WS. + */ +type CoreCourseSearchCoursesWSParams = { + criterianame: string; // Criteria name (search, modulelist (only admins), blocklist (only admins), tagid). + criteriavalue: string; // Criteria value. + page?: number; // Page number (0 based). + perpage?: number; // Items per page. + requiredcapabilities?: string[]; // Optional list of required capabilities (used to filter the list). + limittoenrolled?: boolean; // Limit to enrolled courses. + onlywithcompletion?: boolean; // Limit to courses where completion is enabled. +}; + +/** + * Data returned by core_course_search_courses WS. + */ +export type CoreCourseSearchCoursesWSResponse = { + total: number; // Total course count. + courses: CoreCourseBasicSearchedData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_course_get_courses WS. + */ +type CoreCourseGetCoursesWSParams = { + options?: { + ids?: number[]; // List of course id. If empty return all courses except front page course. + }; // Options - operator OR is used. +}; + +/** + * Data returned by core_course_get_courses WS. + */ +export type CoreCourseGetCoursesWSResponse = CoreCourseGetCoursesData[]; + +/** + * Course type exported in CoreCourseGetEnrolledCoursesByTimelineClassificationWSResponse; + */ +export type CoreCourseGetEnrolledCoursesByTimelineClassification = CoreCourseBasicData & { // Course. + idnumber: string; // Idnumber. + startdate: number; // Startdate. + enddate: number; // Enddate. + visible: boolean; // Visible. + fullnamedisplay: string; // Fullnamedisplay. + viewurl: string; // Viewurl. + courseimage: string; // Courseimage. + progress?: number; // Progress. + hasprogress: boolean; // Hasprogress. + isfavourite: boolean; // Isfavourite. + hidden: boolean; // Hidden. + timeaccess?: number; // Timeaccess. + showshortname: boolean; // Showshortname. + coursecategory: string; // Coursecategory. +}; + +/** + * Params of core_course_get_enrolled_courses_by_timeline_classification WS. + */ +type CoreCourseGetEnrolledCoursesByTimelineClassificationWSParams = { + classification: string; // Future, inprogress, or past. + limit?: number; // Result set limit. + offset?: number; // Result set offset. + sort?: string; // Sort string. + customfieldname?: string; // Used when classification = customfield. + customfieldvalue?: string; // Used when classification = customfield. +}; + +/** + * Data returned by core_course_get_enrolled_courses_by_timeline_classification WS. + */ +export type CoreCourseGetEnrolledCoursesByTimelineClassificationWSResponse = { + courses: CoreCourseGetEnrolledCoursesByTimelineClassification[]; + nextoffset: number; // Offset for the next request. +}; + +/** + * Params of core_course_get_categories WS. + */ +type CoreCourseGetCategoriesWSParams = { + criteria?: { // Criteria. + /** + * The category column to search, expected keys (value format) are: + * "id" (int) the category id, + * "ids" (string) category ids separated by commas, + * "name" (string) the category name, + * "parent" (int) the parent category id, + * "idnumber" (string) category idnumber - user must have 'moodle/category:manage' to search on idnumber, + * "visible" (int) whether the returned categories must be visible or hidden. + * If the key is not passed, then the function return all categories that the user can see.. + */ + key: string; + value: string | number; // The value to match. + }[]; + addsubcategories?: boolean; // Return the sub categories infos (1 - default) otherwise only the category info (0). +}; + +/** + * Data returned by core_course_get_categories WS. + */ +export type CoreCourseGetCategoriesWSResponse = CoreCategoryData[]; + +/** + * Category data model. + */ +export type CoreCategoryData = { + id: number; // Category id. + name: string; // Category name. + idnumber?: string; // Category id number. + description: string; // Category description. + descriptionformat: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + parent: number; // Parent category id. + sortorder: number; // Category sorting order. + coursecount: number; // Number of courses in this category. + visible?: number; // 1: available, 0:not available. + visibleold?: number; // 1: available, 0:not available. + timemodified?: number; // Timestamp. + depth: number; // Category depth. + path: string; // Category path. + theme?: string; // Category theme. +}; + +/** + * Params of core_course_get_user_navigation_options and core_course_get_user_administration_options WS. + */ +type CoreCourseGetUserAdminOrNavOptionsWSParams = { + courseids: number[]; +}; + +/** + * Data returned by core_course_get_user_navigation_options and core_course_get_user_administration_options WS. + */ +export type CoreCourseGetUserAdminOrNavOptionsWSResponse = { + courses: CoreCourseUserAdminOrNavOption[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Admin or navigation option data. + */ +export type CoreCourseUserAdminOrNavOption = { + id: number; // Course id. + options: { + name: string; // Option name. + available: boolean; // Whether the option is available or not. + }[]; +}; + +/** + * Indexed administration or navigation course options. + */ +export type CoreCourseUserAdminOrNavOptionCourseIndexed = { + [id: number]: CoreCourseUserAdminOrNavOptionIndexed; +}; + +/** + * Indexed administration or navigation options. + */ +export type CoreCourseUserAdminOrNavOptionIndexed = { + [name: string]: // Option name. + boolean; // Whether the option is available or not. +}; + +/** + * Params of core_enrol_get_course_enrolment_methods WS. + */ +type CoreEnrolGetCourseEnrolmentMethodsWSParams = { + courseid: number; // Course id. +}; + +/** + * Course enrolment method. + */ +export type CoreCourseEnrolmentMethod = { + id: number; // Id of course enrolment instance. + courseid: number; // Id of course. + type: string; // Type of enrolment plugin. + name: string; // Name of enrolment plugin. + status: string; // Status of enrolment plugin. + wsfunction?: string; // Webservice function to get more information. +}; + +/** + * Params of enrol_guest_get_instance_info WS. + */ +type EnrolGuestGetInstanceInfoWSParams = { + instanceid: number; // Instance id of guest enrolment plugin. +}; + +/** + * Data returned by enrol_guest_get_instance_info WS. + */ +export type EnrolGuestGetInstanceInfoWSResponse = { + instanceinfo: CoreCourseEnrolmentGuestMethod; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Course guest enrolment method. + */ +export type CoreCourseEnrolmentGuestMethod = CoreCourseEnrolmentMethod & { + passwordrequired: boolean; // Is a password required?. +}; + +/** + * Params of enrol_self_enrol_user WS. + */ +type EnrolSelfEnrolUserWSParams = { + courseid: number; // Id of the course. + password?: string; // Enrolment key. + instanceid?: number; // Instance id of self enrolment plugin. +}; + +/** + * Params of core_course_set_favourite_courses WS. + */ +type CoreCourseSetFavouriteCoursesWSParams = { + courses: { + id: number; // Course ID. + favourite: boolean; // Favourite status. + }[]; +}; diff --git a/src/core/features/courses/services/handlers/dashboard.home.ts b/src/core/features/courses/services/handlers/dashboard.home.ts index 3c9505e2f..76dc61175 100644 --- a/src/core/features/courses/services/handlers/dashboard.home.ts +++ b/src/core/features/courses/services/handlers/dashboard.home.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreHomeHandler, CoreHomeHandlerToDisplay } from '@features/mainmenu/services/home.delegate'; /** - * Handler to add Home into main menu. + * Handler to add dashboard into home page. */ Injectable(); export class CoreDashboardHomeHandler implements CoreHomeHandler { @@ -41,7 +41,7 @@ export class CoreDashboardHomeHandler implements CoreHomeHandler { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async isEnabledForSite(siteId?: string): Promise { - // @todo + // @todo return this.blockDelegate.hasSupportedBlock(this.blocks); return true; } diff --git a/src/core/features/courses/services/handlers/my-courses.home.ts b/src/core/features/courses/services/handlers/my-courses.home.ts new file mode 100644 index 000000000..f43c134c2 --- /dev/null +++ b/src/core/features/courses/services/handlers/my-courses.home.ts @@ -0,0 +1,63 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreHomeHandler, CoreHomeHandlerToDisplay } from '@features/mainmenu/services/home.delegate'; + +/** + * Handler to add my courses into home page. + */ +Injectable(); +export class CoreCoursesMyCoursesHomeHandler implements CoreHomeHandler { + + name = 'CoreCoursesMyCourses'; + priority = 900; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): Promise { + return this.isEnabledForSite(); + } + + /** + * Check if the handler is enabled on a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Whether or not the handler is enabled on a site level. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async isEnabledForSite(siteId?: string): Promise { + // @todo return !this.blockDelegate.hasSupportedBlock(this.blocks) && !CoreSiteHome.instance.isAvailable(siteId); + return true; + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + getDisplayData(): CoreHomeHandlerToDisplay { + return { + title: 'core.courses.mycourses', + page: 'courses/my', + class: 'core-courses-my-courses-handler', + icon: 'fas-graduation-cap', + selectPriority: 900, + }; + } + +} diff --git a/src/core/features/emulator/components/capture-media/capture-media.html b/src/core/features/emulator/components/capture-media/capture-media.html index fcd74424e..ef7d7d167 100644 --- a/src/core/features/emulator/components/capture-media/capture-media.html +++ b/src/core/features/emulator/components/capture-media/capture-media.html @@ -30,8 +30,8 @@ - - + + @@ -47,13 +47,13 @@ - - - - + + + + - + diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index ff3633baf..29874d84d 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -14,19 +14,23 @@ import { NgModule } from '@angular/core'; +import { CoreCourseModule } from './course/course.module'; import { CoreCoursesModule } from './courses/courses.module'; import { CoreEmulatorModule } from './emulator/emulator.module'; import { CoreFileUploaderInitModule } from './fileuploader/fileuploader-init.module'; import { CoreLoginModule } from './login/login.module'; import { CoreSettingsInitModule } from './settings/settings-init.module'; +import { CoreSiteHomeInitModule } from './sitehome/sitehome-init.module'; @NgModule({ imports: [ CoreEmulatorModule, CoreLoginModule, + CoreCourseModule, CoreCoursesModule, CoreSettingsInitModule, CoreFileUploaderInitModule, + CoreSiteHomeInitModule, ], }) export class CoreFeaturesModule {} diff --git a/src/core/features/login/components/site-help/site-help.html b/src/core/features/login/components/site-help/site-help.html index 5bc9e9615..6f01bc259 100644 --- a/src/core/features/login/components/site-help/site-help.html +++ b/src/core/features/login/components/site-help/site-help.html @@ -4,7 +4,7 @@ - + @@ -73,4 +73,4 @@ -
\ No newline at end of file + diff --git a/src/core/features/login/components/site-onboarding/site-onboarding.html b/src/core/features/login/components/site-onboarding/site-onboarding.html index 0db7e52f6..850bfa635 100644 --- a/src/core/features/login/components/site-onboarding/site-onboarding.html +++ b/src/core/features/login/components/site-onboarding/site-onboarding.html @@ -2,7 +2,7 @@ - + diff --git a/src/core/features/login/pages/credentials/credentials.html b/src/core/features/login/pages/credentials/credentials.html index c753c36c4..3ba70db0b 100644 --- a/src/core/features/login/pages/credentials/credentials.html +++ b/src/core/features/login/pages/credentials/credentials.html @@ -9,7 +9,7 @@ - + @@ -53,7 +53,7 @@
{{ 'core.login.or' | translate }}
- + {{ 'core.scanqr' | translate }}
diff --git a/src/core/features/login/pages/site/site.html b/src/core/features/login/pages/site/site.html index dfcc17abd..29def2140 100644 --- a/src/core/features/login/pages/site/site.html +++ b/src/core/features/login/pages/site/site.html @@ -9,7 +9,7 @@ - + @@ -50,7 +50,7 @@ - + diff --git a/src/core/features/mainmenu/pages/home/home.html b/src/core/features/mainmenu/pages/home/home.html index 71b4ffdad..ad107680e 100644 --- a/src/core/features/mainmenu/pages/home/home.html +++ b/src/core/features/mainmenu/pages/home/home.html @@ -8,9 +8,7 @@ - - diff --git a/src/core/features/mainmenu/pages/home/home.page.ts b/src/core/features/mainmenu/pages/home/home.page.ts index 4a9d1c418..41c78cc17 100644 --- a/src/core/features/mainmenu/pages/home/home.page.ts +++ b/src/core/features/mainmenu/pages/home/home.page.ts @@ -12,10 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreSites } from '@services/sites'; -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ViewChild } from '@angular/core'; import { Subscription } from 'rxjs'; + +import { CoreSites } from '@services/sites'; import { CoreHomeDelegate, CoreHomeHandlerToDisplay } from '../../services/home.delegate'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreTabsComponent } from '@components/tabs/tabs'; /** * Page that displays the Home. @@ -27,12 +30,16 @@ import { CoreHomeDelegate, CoreHomeHandlerToDisplay } from '../../services/home. }) export class CoreHomePage implements OnInit { + @ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent; + + siteName!: string; tabs: CoreHomeHandlerToDisplay[] = []; loaded = false; selectedTab?: number; protected subscription?: Subscription; + protected updateSiteObserver?: CoreEventObserver; constructor( protected homeDelegate: CoreHomeDelegate, @@ -47,6 +54,11 @@ export class CoreHomePage implements OnInit { this.subscription = this.homeDelegate.getHandlersObservable().subscribe((handlers) => { handlers && this.initHandlers(handlers); }); + + // Refresh the enabled flags if site is updated. + this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + this.loadSiteName(); + }, CoreSites.instance.getCurrentSiteId()); } /** @@ -91,4 +103,18 @@ export class CoreHomePage implements OnInit { this.siteName = CoreSites.instance.getCurrentSite()!.getSiteName(); } + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.tabsComponent?.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.tabsComponent?.ionViewDidLeave(); + } + } diff --git a/src/core/features/mainmenu/pages/menu/menu.html b/src/core/features/mainmenu/pages/menu/menu.html index 65c5db9b6..148c37584 100644 --- a/src/core/features/mainmenu/pages/menu/menu.html +++ b/src/core/features/mainmenu/pages/menu/menu.html @@ -13,7 +13,7 @@ - + {{ 'core.more' | translate }} diff --git a/src/core/features/mainmenu/pages/more/more.html b/src/core/features/mainmenu/pages/more/more.html index 1fe0bd121..4776dc9b5 100644 --- a/src/core/features/mainmenu/pages/more/more.html +++ b/src/core/features/mainmenu/pages/more/more.html @@ -51,34 +51,34 @@ - +

{{ 'core.scanqr' | translate }}

- +

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

- +

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

- +

{{ 'core.settings.preferences' | translate }}

- +

{{ logoutLabel | translate }}

@@ -86,7 +86,7 @@ - +

{{ 'core.settings.appsettings' | translate }}

diff --git a/src/core/features/search/components/search-box/core-search-box.html b/src/core/features/search/components/search-box/core-search-box.html index 6989a6039..e8f4b3aa5 100644 --- a/src/core/features/search/components/search-box/core-search-box.html +++ b/src/core/features/search/components/search-box/core-search-box.html @@ -17,9 +17,9 @@
- - + + {{item.searchedtext}} diff --git a/src/core/features/settings/pages/about/about.html b/src/core/features/settings/pages/about/about.html index 8a47fbf42..016f905de 100644 --- a/src/core/features/settings/pages/about/about.html +++ b/src/core/features/settings/pages/about/about.html @@ -18,11 +18,11 @@ {{ 'core.settings.opensourcelicenses' | translate }} - + {{ 'core.settings.privacypolicy' | translate }} - + {{ 'core.settings.deviceinfo' | translate }} diff --git a/src/core/features/settings/pages/app/app.html b/src/core/features/settings/pages/app/app.html index c8150a9db..f97a0250f 100644 --- a/src/core/features/settings/pages/app/app.html +++ b/src/core/features/settings/pages/app/app.html @@ -10,25 +10,25 @@ - + {{ 'core.settings.general' | translate }} - + {{ 'core.settings.spaceusage' | translate }} - + {{ 'core.settings.synchronization' | translate }} - + {{ 'core.sharedfiles.sharedfiles' | translate }} - + {{ 'core.settings.about' | translate }} diff --git a/src/core/features/settings/pages/deviceinfo/deviceinfo.html b/src/core/features/settings/pages/deviceinfo/deviceinfo.html index b892896e3..2a7b2b42a 100644 --- a/src/core/features/settings/pages/deviceinfo/deviceinfo.html +++ b/src/core/features/settings/pages/deviceinfo/deviceinfo.html @@ -8,7 +8,7 @@ - + diff --git a/src/core/features/settings/pages/site/site.html b/src/core/features/settings/pages/site/site.html index b10ce9c28..b20ac3539 100644 --- a/src/core/features/settings/pages/site/site.html +++ b/src/core/features/settings/pages/site/site.html @@ -28,7 +28,7 @@ + [class.core-split-item-selected]="'CoreSharedFilesListPage' == selectedPage" detail>

{{ 'core.sharedfiles.sharedfiles' | translate }}

@@ -37,7 +37,7 @@
diff --git a/src/core/features/settings/pages/space-usage/space-usage.html b/src/core/features/settings/pages/space-usage/space-usage.html index ad08d1c8d..8d9294c40 100644 --- a/src/core/features/settings/pages/space-usage/space-usage.html +++ b/src/core/features/settings/pages/space-usage/space-usage.html @@ -5,10 +5,11 @@
{{ 'core.settings.spaceusage' | translate }} - - - - + + + + + diff --git a/src/core/features/settings/pages/synchronization/synchronization.html b/src/core/features/settings/pages/synchronization/synchronization.html index 587cf2b4a..3ac2cd268 100644 --- a/src/core/features/settings/pages/synchronization/synchronization.html +++ b/src/core/features/settings/pages/synchronization/synchronization.html @@ -5,10 +5,11 @@ {{ 'core.settings.synchronization' | translate }} - - - - + + + + + diff --git a/src/core/features/settings/services/settings.helper.ts b/src/core/features/settings/services/settings.helper.ts index 6f9022b6b..54deb5a30 100644 --- a/src/core/features/settings/services/settings.helper.ts +++ b/src/core/features/settings/services/settings.helper.ts @@ -24,7 +24,7 @@ import { CoreConstants } from '@/core/constants'; import { CoreConfig } from '@services/config'; // import { CoreFilterProvider } from '@features/filter/providers/filter'; import { CoreDomUtils } from '@services/utils/dom'; -// import { CoreCourseProvider } from '@features/course/providers/course'; +import { CoreCourse } from '@features/course/services/course'; import { makeSingleton, Translate } from '@singletons/core.singletons'; import { CoreError } from '@classes/errors/error'; @@ -58,7 +58,6 @@ export class CoreSettingsHelperProvider { constructor() { // protected filterProvider: CoreFilterProvider, - // protected courseProvider: CoreCourseProvider, if (!CoreConstants.CONFIG.forceColorScheme) { // Update color scheme when a user enters or leaves a site, or when the site info is updated. @@ -116,7 +115,7 @@ export class CoreSettingsHelperProvider { promises.push(site.deleteFolder().then(() => { filepoolService.clearAllPackagesStatus(siteId); filepoolService.clearFilepool(siteId); - // this.courseProvider.clearAllCoursesStatus(siteId); + CoreCourse.instance.clearAllCoursesStatus(siteId); siteInfo.spaceUsage = 0; diff --git a/src/core/features/sitehome/lang/en.json b/src/core/features/sitehome/lang/en.json new file mode 100644 index 000000000..acf7f742f --- /dev/null +++ b/src/core/features/sitehome/lang/en.json @@ -0,0 +1,4 @@ +{ + "sitehome": "Site home", + "sitenews": "Site announcements" +} diff --git a/src/core/features/sitehome/pages/index/index.html b/src/core/features/sitehome/pages/index/index.html new file mode 100644 index 000000000..ba85e91f8 --- /dev/null +++ b/src/core/features/sitehome/pages/index/index.html @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ 'core.courses.availablecourses' | translate}}

+
+
+
+ + + + News (TODO) + + + + + + + + +

{{ 'core.courses.categories' | translate}}

+
+
+
+ + + + + +

{{ 'core.courses.mycourses' | translate}}

+
+
+ + + + +

{{ 'core.courses.searchcourses' | translate}}

+
+
diff --git a/src/core/features/sitehome/pages/index/index.page.module.ts b/src/core/features/sitehome/pages/index/index.page.module.ts new file mode 100644 index 000000000..59e2a1986 --- /dev/null +++ b/src/core/features/sitehome/pages/index/index.page.module.ts @@ -0,0 +1,47 @@ +// (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 { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreComponentsModule } from '@components/components.module'; + +import { CoreSiteHomeIndexPage } from './index.page'; + +const routes: Routes = [ + { + path: '', + component: CoreSiteHomeIndexPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreDirectivesModule, + CoreComponentsModule, + ], + declarations: [ + CoreSiteHomeIndexPage, + ], + exports: [RouterModule], +}) +export class CoreSiteHomeIndexPageModule {} diff --git a/src/core/features/sitehome/pages/index/index.page.ts b/src/core/features/sitehome/pages/index/index.page.ts new file mode 100644 index 000000000..a9aa029c1 --- /dev/null +++ b/src/core/features/sitehome/pages/index/index.page.ts @@ -0,0 +1,214 @@ +// (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, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { IonRefresher, NavController } from '@ionic/angular'; + +import { CoreSite, CoreSiteConfig } from '@classes/site'; +import { CoreCourse, CoreCourseSection } from '@features/course/services/course'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreSites } from '@services/sites'; +import { CoreSiteHome } from '@features/sitehome/services/sitehome'; +import { CoreCourses, CoreCoursesProvider } from '@features//courses/services/courses'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreCourseHelper } from '@features/course/services/course.helper'; + +/** + * Page that displays site home index. + */ +@Component({ + selector: 'page-core-sitehome-index', + templateUrl: 'index.html', +}) +export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { + + // @todo @ViewChild(CoreBlockCourseBlocksComponent) courseBlocksComponent: CoreBlockCourseBlocksComponent; + + dataLoaded = false; + section?: CoreCourseSection & { + hasContent?: boolean; + }; + + hasContent = false; + items: string[] = []; + siteHomeId?: number; + currentSite?: CoreSite; + searchEnabled = false; + downloadEnabled = false; + downloadCourseEnabled = false; + downloadCoursesEnabled = false; + downloadEnabledIcon = 'far-square'; + + protected updateSiteObserver?: CoreEventObserver; + + constructor( + protected route: ActivatedRoute, + protected navCtrl: NavController, + // @todo private prefetchDelegate: CoreCourseModulePrefetchDelegate, + ) {} + + /** + * Page being initialized. + */ + ngOnInit(): void { + const navParams = this.route.snapshot.queryParams; + + this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite(); + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite(); + + // Refresh the enabled flags if site is updated. + this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite(); + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite(); + + this.switchDownload(this.downloadEnabled && this.downloadCourseEnabled && this.downloadCoursesEnabled); + }, CoreSites.instance.getCurrentSiteId()); + + this.currentSite = CoreSites.instance.getCurrentSite()!; + this.siteHomeId = this.currentSite.getSiteHomeId(); + + const module = navParams['module']; + if (module) { + // @todo const modParams = navParams.get('modParams'); + // CoreCourseHelper.instance.openModule(module, this.siteHomeId, undefined, modParams); + } + + this.loadContent().finally(() => { + this.dataLoaded = true; + }); + } + + /** + * Convenience function to fetch the data. + * + * @return Promise resolved when done. + */ + protected async loadContent(): Promise { + this.hasContent = false; + + const config = this.currentSite!.getStoredConfig() || { numsections: 1, frontpageloggedin: undefined }; + + this.items = await CoreSiteHome.instance.getFrontPageItems(config.frontpageloggedin); + this.hasContent = this.items.length > 0; + + try { + const sections = await CoreCourse.instance.getSections(this.siteHomeId!, false, true); + + // Check "Include a topic section" setting from numsections. + this.section = config.numsections ? sections.find((section) => section.section == 1) : undefined; + if (this.section) { + this.section.hasContent = false; + this.section.hasContent = CoreCourseHelper.instance.sectionHasContent(this.section); + /* @todo this.hasContent = CoreCourseHelper.instance.addHandlerDataForModules( + [this.section], + this.siteHomeId, + undefined, + undefined, + true, + ) || this.hasContent;*/ + } + + // Add log in Moodle. + CoreCourse.instance.logView( + this.siteHomeId!, + undefined, + undefined, + this.currentSite!.getInfo()?.sitename, + ).catch(() => { + // Ignore errors. + }); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.couldnotloadsectioncontent', true); + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + */ + doRefresh(refresher?: CustomEvent): void { + const promises: Promise[] = []; + + promises.push(CoreCourse.instance.invalidateSections(this.siteHomeId!)); + promises.push(this.currentSite!.invalidateConfig().then(async () => { + // Config invalidated, fetch it again. + const config: CoreSiteConfig = await this.currentSite!.getConfig(); + this.currentSite!.setConfig(config); + + return; + })); + + if (this.section && this.section.modules) { + // Invalidate modules prefetch data. + // @todo promises.push(this.prefetchDelegate.invalidateModules(this.section.modules, this.siteHomeId)); + } + + // @todo promises.push(this.courseBlocksComponent.invalidateBlocks()); + + Promise.all(promises).finally(async () => { + const p2: Promise[] = []; + + p2.push(this.loadContent()); + // @todo p2.push(this.courseBlocksComponent.loadContent()); + + await Promise.all(p2).finally(() => { + refresher?.detail.complete(); + }); + }); + } + + /** + * Toggle download enabled. + */ + toggleDownload(): void { + this.switchDownload(!this.downloadEnabled); + } + + /** + * Convenience function to switch download enabled. + * + * @param enable If enable or disable. + */ + protected switchDownload(enable: boolean): void { + this.downloadEnabled = (this.downloadCourseEnabled || this.downloadCoursesEnabled) && enable; + this.downloadEnabledIcon = this.downloadEnabled ? 'far-check-square' : 'far-square'; + CoreEvents.trigger(CoreCoursesProvider.EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED, { enabled: this.downloadEnabled }); + } + + /** + * Open page to manage courses storage. + */ + manageCoursesStorage(): void { + // @todo this.navCtrl.navigateForward(['/courses/storage']); + } + + /** + * Go to search courses. + */ + openSearch(): void { + this.navCtrl.navigateForward(['/courses/search']); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.updateSiteObserver?.off(); + } + +} diff --git a/src/core/features/sitehome/services/handlers/index.link.ts b/src/core/features/sitehome/services/handlers/index.link.ts new file mode 100644 index 000000000..aa97435c4 --- /dev/null +++ b/src/core/features/sitehome/services/handlers/index.link.ts @@ -0,0 +1,75 @@ +// (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 { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreSites } from '@services/sites'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks.helper'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks.delegate'; +import { CoreSiteHome } from '../sitehome'; + +/** + * Handler to treat links to site home index. + */ +Injectable(); +export class CoreSiteHomeIndexLinkHandler extends CoreContentLinksHandlerBase { + + name = 'CoreSiteHomeIndexLinkHandler'; + featureName = 'CoreMainMenuDelegate_CoreSiteHome'; + pattern = /\/course\/view\.php.*([?&]id=\d+)/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return List of (or promise resolved with list of) actions. + */ + getActions(): CoreContentLinksAction[] | Promise { + return [{ + action: (siteId: string): void => { + CoreContentLinksHelper.instance.goInSite('sitehome', [], siteId); + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + async isEnabled(siteId: string, url: string, params: Params, courseId?: number): Promise { + courseId = parseInt(params.id, 10); + if (!courseId) { + return false; + } + + const site = await CoreSites.instance.getSite(siteId); + if (courseId != site.getSiteHomeId()) { + // The course is not site home. + return false; + } + + return CoreSiteHome.instance.isAvailable(siteId).then(() => true).catch(() => false); + } + +} diff --git a/src/core/features/sitehome/services/handlers/sitehome.home.ts b/src/core/features/sitehome/services/handlers/sitehome.home.ts new file mode 100644 index 000000000..3848f7b55 --- /dev/null +++ b/src/core/features/sitehome/services/handlers/sitehome.home.ts @@ -0,0 +1,66 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreHomeHandler, CoreHomeHandlerToDisplay } from '@features/mainmenu/services/home.delegate'; +import { CoreSiteHome } from '../sitehome'; + +/** + * Handler to add site home into home page. + */ +Injectable(); +export class CoreSiteHomeHomeHandler implements CoreHomeHandler { + + name = 'CoreSiteHomeDashboard'; + priority = 1200; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): Promise { + return this.isEnabledForSite(); + } + + /** + * Check if the handler is enabled on a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabledForSite(siteId?: string): Promise { + return CoreSiteHome.instance.isAvailable(siteId); + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + getDisplayData(): CoreHomeHandlerToDisplay { + const site = CoreSites.instance.getCurrentSite(); + const displaySiteHome = site?.getInfo() && site?.getInfo()?.userhomepage === 0; + + return { + title: 'core.sitehome.sitehome', + page: 'sitehome', + class: 'core-sitehome-dashboard-handler', + icon: 'fas-home', + selectPriority: displaySiteHome ? 1100 : 900, + }; + } + +} diff --git a/src/core/features/sitehome/services/sitehome.ts b/src/core/features/sitehome/services/sitehome.ts new file mode 100644 index 000000000..6714dfd6e --- /dev/null +++ b/src/core/features/sitehome/services/sitehome.ts @@ -0,0 +1,196 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { makeSingleton } from '@singletons/core.singletons'; +import { CoreCourse, CoreCourseSection } from '../../course/services/course'; +import { CoreCourses } from '../../courses/services/courses'; + +/** + * Items with index 1 and 3 were removed on 2.5 and not being supported in the app. + */ +export enum FrontPageItemNames { + NEWS_ITEMS = 0, + LIST_OF_CATEGORIES = 2, + COMBO_LIST = 3, + ENROLLED_COURSES = 5, + LIST_OF_COURSE = 6, + COURSE_SEARCH_BOX = 7, +} + +/** + * Service that provides some features regarding site home. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreSiteHomeProvider { + + /** + * Get the news forum for the Site Home. + * + * @param siteHomeId Site Home ID. + * @return Promise resolved with the forum if found, rejected otherwise. + */ + getNewsForum(): void { + // @todo params and logic. + } + + /** + * Invalidate the WS call to get the news forum for the Site Home. + * + * @param siteHomeId Site Home ID. + * @return Promise resolved when invalidated. + */ + invalidateNewsForum(): void { + // @todo params and logic. + } + + /** + * Returns whether or not the frontpage is available for the current site. + * + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's available. + */ + async isAvailable(siteId?: string): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + // First check if it's disabled. + if (this.isDisabledInSite(site)) { + return false; + } + + // Use a WS call to check if there's content in the site home. + const siteHomeId = site.getSiteHomeId(); + const preSets: CoreSiteWSPreSets = { emergencyCache: false }; + + try { + const sections: CoreCourseSection[] = + await CoreCourse.instance.getSections(siteHomeId, false, true, preSets, site.id); + + if (!sections || !sections.length) { + throw Error('No sections found'); + } + + const hasContent = sections.some((section) => section.summary || (section.modules && section.modules.length)); + + if (hasContent) { + // There's a section with content. + return true; + } + } catch { + // Ignore errors. + } + + const config = site.getStoredConfig(); + if (config && config.frontpageloggedin) { + const items = await this.getFrontPageItems(config.frontpageloggedin); + + // There are items to show. + return items.length > 0; + } + } catch { + // Ignore errors. + } + + return false; + } + + /** + * Check if Site Home is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isDisabledInSite(site); + } + + /** + * Check if Site Home is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isDisabledInSite(site: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return site.isFeatureDisabled('CoreMainMenuDelegate_CoreSiteHome'); + } + + /** + * Get the nams of the valid frontpage items. + * + * @param frontpageItemIds CSV string with indexes of site home components. + * @return Valid names for each item. + */ + async getFrontPageItems(frontpageItemIds?: string): Promise { + if (!frontpageItemIds) { + return []; + } + + const items = frontpageItemIds.split(','); + + const filteredItems: string[] = []; + + for (const item of items) { + let itemNumber = parseInt(item, 10); + + let add = false; + switch (itemNumber) { + case FrontPageItemNames['NEWS_ITEMS']: + // @todo + add = true; + break; + case FrontPageItemNames['LIST_OF_CATEGORIES']: + case FrontPageItemNames['COMBO_LIST']: + case FrontPageItemNames['LIST_OF_COURSE']: + add = CoreCourses.instance.isGetCoursesByFieldAvailable(); + if (add && itemNumber == FrontPageItemNames['COMBO_LIST']) { + itemNumber = FrontPageItemNames['LIST_OF_CATEGORIES']; + } + break; + case FrontPageItemNames['ENROLLED_COURSES']: + if (!CoreCourses.instance.isMyCoursesDisabledInSite()) { + const courses = await CoreCourses.instance.getUserCourses(); + + add = courses.length > 0; + } + break; + case FrontPageItemNames['COURSE_SEARCH_BOX']: + add = !CoreCourses.instance.isSearchCoursesDisabledInSite(); + break; + default: + break; + } + + // Do not add an item twice. + if (add && filteredItems.indexOf(FrontPageItemNames[itemNumber]) < 0) { + filteredItems.push(FrontPageItemNames[itemNumber]); + } + } + + return filteredItems; + } + +} + +export class CoreSiteHome extends makeSingleton(CoreSiteHomeProvider) {} + diff --git a/src/core/features/sitehome/sitehome-init.module.ts b/src/core/features/sitehome/sitehome-init.module.ts new file mode 100644 index 000000000..7739e17aa --- /dev/null +++ b/src/core/features/sitehome/sitehome-init.module.ts @@ -0,0 +1,53 @@ +// (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 { Routes } from '@angular/router'; + +import { CoreSiteHomeIndexLinkHandler } from './services/handlers/index.link'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks.delegate'; +import { CoreSiteHomeHomeHandler } from './services/handlers/sitehome.home'; +import { CoreHomeDelegate } from '@features/mainmenu/services/home.delegate'; +import { CoreHomeRoutingModule } from '@features/mainmenu/pages/home/home-routing.module'; + +const routes: Routes = [ + { + path: 'sitehome', + loadChildren: () => + import('@features/sitehome/pages/index/index.page.module').then(m => m.CoreSiteHomeIndexPageModule), + }, +]; + +@NgModule({ + imports: [CoreHomeRoutingModule.forChild(routes)], + exports: [CoreHomeRoutingModule], + providers: [ + CoreSiteHomeIndexLinkHandler, + CoreSiteHomeHomeHandler, + ], +}) +export class CoreSiteHomeInitModule { + + constructor( + contentLinksDelegate: CoreContentLinksDelegate, + homeDelegate: CoreHomeDelegate, + siteHomeIndexLinkHandler: CoreSiteHomeIndexLinkHandler, + siteHomeDashboardHandler: CoreSiteHomeHomeHandler, + ) { + contentLinksDelegate.registerHandler(siteHomeIndexLinkHandler); + homeDelegate.registerHandler(siteHomeDashboardHandler); + + } + +} diff --git a/src/core/services/groups.ts b/src/core/services/groups.ts index b82596a84..1959cd999 100644 --- a/src/core/services/groups.ts +++ b/src/core/services/groups.ts @@ -21,6 +21,8 @@ import { makeSingleton, Translate } from '@singletons/core.singletons'; import { CoreWSExternalWarning } from '@services/ws'; import { CoreCourseBase } from '@/types/global'; +const ROOT_CACHE_KEY = 'mmGroups:'; + /* * Service to handle groups. */ @@ -31,7 +33,6 @@ export class CoreGroupsProvider { static readonly NOGROUPS = 0; static readonly SEPARATEGROUPS = 1; static readonly VISIBLEGROUPS = 2; - protected readonly ROOT_CACHE_KEY = 'mmGroups:'; /** * Check if group mode of an activity is enabled. @@ -65,12 +66,12 @@ export class CoreGroupsProvider { userId?: number, siteId?: string, ignoreCache?: boolean, - ): Promise { + ): Promise { const site = await CoreSites.instance.getSite(siteId); userId = userId || site.getUserId(); - const params = { + const params: CoreGroupGetActivityAllowedGroupsWSParams = { cmid: cmId, userid: userId, }; @@ -84,7 +85,7 @@ export class CoreGroupsProvider { preSets.emergencyCache = false; } - const response: CoreGroupGetActivityAllowedGroupsResponse = + const response: CoreGroupGetActivityAllowedGroupsWSResponse = await site.read('core_group_get_activity_allowed_groups', params, preSets); if (!response || !response.groups) { @@ -102,7 +103,7 @@ export class CoreGroupsProvider { * @return Cache key. */ protected getActivityAllowedGroupsCacheKey(cmId: number, userId: number): string { - return this.ROOT_CACHE_KEY + 'allowedgroups:' + cmId + ':' + userId; + return ROOT_CACHE_KEY + 'allowedgroups:' + cmId + ':' + userId; } /** @@ -115,7 +116,7 @@ export class CoreGroupsProvider { * @return Promise resolved when the groups are retrieved. If not allowed, empty array will be returned. */ async getActivityAllowedGroupsIfEnabled(cmId: number, userId?: number, siteId?: string, ignoreCache?: boolean): - Promise { + Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); // Get real groupmode, in case it's forced by the course. @@ -157,7 +158,7 @@ export class CoreGroupsProvider { groupInfo.separateGroups = groupMode === CoreGroupsProvider.SEPARATEGROUPS; groupInfo.visibleGroups = groupMode === CoreGroupsProvider.VISIBLEGROUPS; - let result: CoreGroupGetActivityAllowedGroupsResponse; + let result: CoreGroupGetActivityAllowedGroupsWSResponse; if (groupInfo.separateGroups || groupInfo.visibleGroups) { result = await this.getActivityAllowedGroups(cmId, userId, siteId, ignoreCache); } else { @@ -195,7 +196,7 @@ export class CoreGroupsProvider { */ async getActivityGroupMode(cmId: number, siteId?: string, ignoreCache?: boolean): Promise { const site = await CoreSites.instance.getSite(siteId); - const params = { + const params: CoreGroupGetActivityGroupmodeWSParams = { cmid: cmId, }; const preSets: CoreSiteWSPreSets = { @@ -208,7 +209,7 @@ export class CoreGroupsProvider { preSets.emergencyCache = false; } - const response: CoreGroupGetActivityGroupModeResponse = + const response: CoreGroupGetActivityGroupModeWSResponse = await site.read('core_group_get_activity_groupmode', params, preSets); if (!response || typeof response.groupmode == 'undefined') { @@ -225,7 +226,7 @@ export class CoreGroupsProvider { * @return Cache key. */ protected getActivityGroupModeCacheKey(cmId: number): string { - return this.ROOT_CACHE_KEY + 'groupmode:' + cmId; + return ROOT_CACHE_KEY + 'groupmode:' + cmId; } /** @@ -274,7 +275,7 @@ export class CoreGroupsProvider { async getUserGroupsInCourse(courseId: number, siteId?: string, userId?: number): Promise { const site = await CoreSites.instance.getSite(siteId); userId = userId || site.getUserId(); - const data = { + const data: CoreGroupGetCourseUserGroupsWSParams = { userid: userId, courseid: courseId, }; @@ -283,7 +284,7 @@ export class CoreGroupsProvider { updateFrequency: CoreSite.FREQUENCY_RARELY, }; - const response: CoreGroupGetCourseUserGroupsResponse = + const response: CoreGroupGetCourseUserGroupsWSResponse = await site.read('core_group_get_course_user_groups', data, preSets); if (!response || !response.groups) { @@ -299,7 +300,7 @@ export class CoreGroupsProvider { * @return Prefix Cache key. */ protected getUserGroupsInCoursePrefixCacheKey(): string { - return this.ROOT_CACHE_KEY + 'courseGroups:'; + return ROOT_CACHE_KEY + 'courseGroups:'; } /** @@ -474,24 +475,48 @@ export type CoreGroupInfo = { /** * WS core_group_get_activity_allowed_groups response type. */ -export type CoreGroupGetActivityAllowedGroupsResponse = { +export type CoreGroupGetActivityAllowedGroupsWSResponse = { groups: CoreGroup[]; // List of groups. canaccessallgroups?: boolean; // Whether the user will be able to access all the activity groups. warnings?: CoreWSExternalWarning[]; }; +/** + * Params of core_group_get_activity_groupmode WS. + */ +type CoreGroupGetActivityGroupmodeWSParams = { + cmid: number; // Course module id. +}; + /** * Result of WS core_group_get_activity_groupmode. */ -export type CoreGroupGetActivityGroupModeResponse = { +export type CoreGroupGetActivityGroupModeWSResponse = { groupmode: number; // Group mode: 0 for no groups, 1 for separate groups, 2 for visible groups. warnings?: CoreWSExternalWarning[]; }; +/** + * Params of core_group_get_activity_allowed_groups WS. + */ +type CoreGroupGetActivityAllowedGroupsWSParams = { + cmid: number; // Course module id. + userid?: number; // Id of user, empty for current user. +}; + +/** + * Params of core_group_get_course_user_groups WS. + */ +type CoreGroupGetCourseUserGroupsWSParams = { + courseid?: number; // Id of course (empty or 0 for all the courses where the user is enrolled). + userid?: number; // Id of user (empty or 0 for current user). + groupingid?: number; // Returns only groups in the specified grouping. +}; + /** * Result of WS core_group_get_course_user_groups. */ -export type CoreGroupGetCourseUserGroupsResponse = { +export type CoreGroupGetCourseUserGroupsWSResponse = { groups: { id: number; // Group record id. name: string; // Multilang compatible name, course unique. diff --git a/src/core/services/ws.ts b/src/core/services/ws.ts index cd7b37868..967b48947 100644 --- a/src/core/services/ws.ts +++ b/src/core/services/ws.ts @@ -990,6 +990,13 @@ export type CoreStatusWithWarningsWSResponse = { warnings?: CoreWSExternalWarning[]; }; +/** + * Special response structure of many webservices that contains only warnings. + */ +export type CoreWarningsWSResponse = { + warnings?: CoreWSExternalWarning[]; +}; + /** * Structure of files returned by WS. */ diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index eeb8f554c..e58dc9740 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -228,3 +228,11 @@ export type CoreEventLoadPageMainMenuData = { redirectPage: string; redirectParams?: Params; }; + +/** + * Data passed to COURSE_STATUS_CHANGED event. + */ +export type CoreEventCourseStatusChanged = { + courseId: number; // Course Id. + status: string; +}; diff --git a/src/theme/app.scss b/src/theme/app.scss index 568622430..f48f74a42 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -10,6 +10,10 @@ ion-toolbar .in-toolbar.button-clear { --color: var(--ion-color-primary-contrast); } +ion-toolbar .core-navbar-button-hidden { + display: none !important; +} + // Ionic icon. ion-icon { &.icon-slash::after, @@ -64,6 +68,11 @@ ion-alert.core-alert-network-error .alert-head { right: unset; left: -15%; } +ion-alert.core-nohead { + .alert-head { + padding-bottom: 0; + } +} // Ionic item divider. ion-item-divider { @@ -76,6 +85,16 @@ ion-list.list-md { padding-bottom: 0; } +// Header. +ion-tabs.hide-header ion-header { + display: none; +} +ion-toolbar { + ion-spinner { + margin: 10px; + } +} + // Modals. .core-modal-fullscreen .modal-wrapper { position: absolute; diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 20a65148c..e35b04f3c 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -103,6 +103,10 @@ ion-toolbar { --color: var(--custom-toolbar-color, var(--ion-color-primary-contrast)); --background: var(--custom-toolbar-background, var(--ion-color-primary)); + + ion-spinner { + --color: var(--custom-toolbar-color, var(--ion-color-primary-contrast)); + } } ion-action-sheet { @@ -135,13 +139,32 @@ --color: var(--core-color); } + core-progress-bar { + --height: var(--custom-progress-bar-height, 8px); + --color: var(--custom-progress-color, var(--core-color)); + --text-color: var(--custom-progress-text-color, var(--gray-darker)); + --background: var(--custom-progress-background, var(--gray-lighter)); + } + --selected-item-color: var(--custom-selected-item-color, var(--core-color)); --selected-item-border-width: var(--custom-selected-item-border-width, 5px); --drop-shadow: var(--custom-drop-shadow, 0, 0, 0, 0.2); --core-login-background: var(--custom-login-background, var(--white)); - --core-login-text-color: var(--custom-login-text-color, var(--black)); + --core-login-text-color: var(--custom-login-text-color, var(--black)); + + --core-course-color-0: var(--custom-course-color-0, #81ecec); + --core-course-color-1: var(--custom-course-color-1, #74b9ff); + --core-course-color-2: var(--custom-course-color-2, #a29bfe); + --core-course-color-3: var(--custom-course-color-3, #dfe6e9); + --core-course-color-4: var(--custom-course-color-4, #00b894); + --core-course-color-5: var(--custom-course-color-5, #0984e3); + --core-course-color-6: var(--custom-course-color-6, #b2bec3); + --core-course-color-7: var(--custom-course-color-7, #fdcb6e); + --core-course-color-8: var(--custom-course-color-9, #fd79a8); + --core-course-color-9: var(--custom-course-color-90, #6c5ce7); + --core-star-color: var(--custom-star-color, var(--core-color)); } /* @@ -196,6 +219,10 @@ } } + core-progress-bar { + --text-color: var(--custom-progress-text-color, var(--gray-lighter)); + } + --core-login-background: var(--custom-login-background, #3a3a3a); --core-login-text-color: var(--custom-login-text-color, white); }