From f8d71dd6eeedcff3185566d1df608579e4a4b210 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 31 Jan 2018 15:17:41 +0100 Subject: [PATCH 01/11] MOBILE-2335 book: Implement provider and handlers --- src/addon/mod/book/book.module.ts | 39 ++ src/addon/mod/book/lang/en.json | 3 + src/addon/mod/book/providers/book.ts | 399 ++++++++++++++++++ src/addon/mod/book/providers/link-handler.ts | 29 ++ .../mod/book/providers/module-handler.ts | 68 +++ src/app/app.module.ts | 2 + src/core/contentlinks/providers/helper.ts | 5 +- 7 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/book/book.module.ts create mode 100644 src/addon/mod/book/lang/en.json create mode 100644 src/addon/mod/book/providers/book.ts create mode 100644 src/addon/mod/book/providers/link-handler.ts create mode 100644 src/addon/mod/book/providers/module-handler.ts diff --git a/src/addon/mod/book/book.module.ts b/src/addon/mod/book/book.module.ts new file mode 100644 index 000000000..e1be5e8e2 --- /dev/null +++ b/src/addon/mod/book/book.module.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { AddonModBookProvider } from './providers/book'; +import { AddonModBookModuleHandler } from './providers/module-handler'; +import { AddonModBookLinkHandler } from './providers/link-handler'; +import { CoreCourseModuleDelegate } from '../../../core/course/providers/module-delegate'; +import { CoreContentLinksDelegate } from '../../../core/contentlinks/providers/delegate'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonModBookProvider, + AddonModBookModuleHandler, + AddonModBookLinkHandler + ] +}) +export class AddonModBookModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModBookModuleHandler, + contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModBookLinkHandler) { + moduleDelegate.registerHandler(moduleHandler); + contentLinksDelegate.registerHandler(linkHandler); + } +} diff --git a/src/addon/mod/book/lang/en.json b/src/addon/mod/book/lang/en.json new file mode 100644 index 000000000..5ea91c840 --- /dev/null +++ b/src/addon/mod/book/lang/en.json @@ -0,0 +1,3 @@ +{ + "errorchapter": "Error reading chapter of book." +} \ No newline at end of file diff --git a/src/addon/mod/book/providers/book.ts b/src/addon/mod/book/providers/book.ts new file mode 100644 index 000000000..ba93df02a --- /dev/null +++ b/src/addon/mod/book/providers/book.ts @@ -0,0 +1,399 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Http, Response } from '@angular/http'; +import { CoreFileProvider } from '../../../../providers/file'; +import { CoreFilepoolProvider } from '../../../../providers/filepool'; +import { CoreLoggerProvider } from '../../../../providers/logger'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreUtilsProvider } from '../../../../providers/utils/utils'; +import { CoreCourseProvider } from '../../../../core/course/providers/course'; + +/** + * A book chapter inside the toc list. + */ +export interface AddonModBookTocChapter { + /** + * ID to identify the chapter. + * @type {string} + */ + id: string; + + /** + * Chapter's title. + * @type {string} + */ + title: string; + + /** + * The chapter's level. + * @type {number} + */ + level: number; +} + +/** + * Map of book contents. For each chapter it has its index URL and the list of paths of the files the chapter has. Each path + * is identified by the relative path in the book, and the value is the URL of the file. + */ +export type AddonModBookContentsMap = {[chapter: string]: {indexUrl?: string, paths: {[path: string]: string}}}; + +/** + * Service that provides some features for books. + */ +@Injectable() +export class AddonModBookProvider { + static COMPONENT = 'mmaModBook'; + + protected ROOT_CACHE_KEY = 'mmaModBook:'; + protected logger; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, + private fileProvider: CoreFileProvider, private filepoolProvider: CoreFilepoolProvider, private http: Http, + private utils: CoreUtilsProvider, private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider) { + this.logger = logger.getInstance('AddonModBookProvider'); + } + + /** + * Get a book by course module ID. + * + * @param {number} courseId Course ID. + * @param {number} cmId Course module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the book is retrieved. + */ + getBook(courseId: number, cmId: number, siteId?: string): Promise { + return this.getBookByField(courseId, 'coursemodule', cmId, siteId); + } + + /** + * Get a book with key=value. If more than one is found, only the first will be returned. + * + * @param {number} courseId Course ID. + * @param {string} key Name of the property to check. + * @param {any} value Value to search. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the book is retrieved. + */ + protected getBookByField(courseId: number, key: string, value: any, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets = { + cacheKey: this.getBookDataCacheKey(courseId) + }; + + return site.read('mod_book_get_books_by_courses', params, preSets).then((response) => { + // Search the book. + if (response && response.books) { + for (const i in response.books) { + const book = response.books[i]; + if (book[key] == value) { + return book; + } + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get book data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getBookDataCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'book:' + courseId; + } + + /** + * Gets a chapter contents. + * + * @param {AddonModBookContentsMap} contentsMap Contents map returned by getContentsMap. + * @param {string} chapterId Chapter to retrieve. + * @param {number} moduleId The module ID. + * @return {Promise} Promise resolved with the contents. + */ + getChapterContent(contentsMap: AddonModBookContentsMap, chapterId: string, moduleId: number): Promise { + const indexUrl = contentsMap[chapterId] ? contentsMap[chapterId].indexUrl : undefined, + siteId = this.sitesProvider.getCurrentSiteId(); + let promise; + + if (!indexUrl) { + // It shouldn't happen. + this.logger.debug('Could not locate the index chapter'); + + return Promise.reject(null); + } + + if (this.fileProvider.isAvailable()) { + promise = this.filepoolProvider.downloadUrl(siteId, indexUrl, false, AddonModBookProvider.COMPONENT, moduleId); + } else { + // We return the live URL. + return Promise.resolve(this.sitesProvider.getCurrentSite().fixPluginfileURL(indexUrl)); + } + + return promise.then((url) => { + // Fetch the URL content. + const observable = this.http.get(url); + + return this.utils.observableToPromise(observable).then((response: Response): any => { + const content = response.text(); + if (typeof content !== 'string') { + return Promise.reject(null); + } else { + // Now that we have the content, we update the SRC to point back to the external resource. + return this.domUtils.restoreSourcesInHtml(content, contentsMap[chapterId].paths); + } + }); + }); + } + + /** + * Convert an array of book contents into an object where contents are organized in chapters. + * Each chapter has an indexUrl and the list of contents in that chapter. + * + * @param {any[]} contents The module contents. + * @return {AddonModBookContentsMap} Contents map. + */ + getContentsMap(contents: any[]): AddonModBookContentsMap { + const map: AddonModBookContentsMap = {}; + + if (!contents) { + return map; + } + + contents.forEach((content) => { + if (this.isFileDownloadable(content)) { + let chapter, + matches, + split, + filepathIsChapter, + key; + + // Search the chapter number in the filepath. + matches = content.filepath.match(/\/(\d+)\//); + if (matches && matches[1]) { + chapter = matches[1]; + filepathIsChapter = content.filepath == '/' + chapter + '/'; + + // Init the chapter if it's not defined yet. + map[chapter] = map[chapter] || { paths: {} }; + + if (content.filename == 'index.html' && filepathIsChapter) { + // Index of the chapter, set indexUrl of the chapter. + map[chapter].indexUrl = content.fileurl; + } else { + if (filepathIsChapter) { + // It's a file in the root folder OR the WS isn't returning the filepath as it should (MDL-53671). + // Try to get the path to the file from the URL. + split = content.fileurl.split('mod_book/chapter' + content.filepath); + key = split[1] || content.filename; // Use filename if we couldn't find the path. + } else { + // Remove the chapter folder from the path and add the filename. + key = content.filepath.replace('/' + chapter + '/', '') + content.filename; + } + + map[chapter].paths[this.textUtils.decodeURIComponent(key)] = content.fileurl; + } + } + } + }); + + return map; + } + + /** + * Get the first chapter of a book. + * + * @param {AddonModBookTocChapter[]} chapters The chapters list. + * @return {string} The chapter id. + */ + getFirstChapter(chapters: AddonModBookTocChapter[]): string { + if (!chapters || !chapters.length) { + return; + } + + return chapters[0].id; + } + + /** + * Get the next chapter to the given one. + * + * @param {AddonModBookTocChapter[]} chapters The chapters list. + * @param {string} chapterId The current chapter. + * @return {string} The next chapter id. + */ + getNextChapter(chapters: AddonModBookTocChapter[], chapterId: string): string { + let next = '0'; + + for (let i = 0; i < chapters.length; i++) { + if (chapters[i].id == chapterId) { + if (typeof chapters[i + 1] != 'undefined') { + next = chapters[i + 1].id; + break; + } + } + } + + return next; + } + + /** + * Get the previous chapter to the given one. + * + * @param {AddonModBookTocChapter[]} chapters The chapters list. + * @param {string} chapterId The current chapter. + * @return {string} The next chapter id. + */ + getPreviousChapter(chapters: AddonModBookTocChapter[], chapterId: string): string { + let previous = '0'; + + for (let i = 0; i < chapters.length; i++) { + if (chapters[i].id == chapterId) { + break; + } + previous = chapters[i].id; + } + + return previous; + } + + /** + * Get the book toc as an array. + * + * @param {any[]} contents The module contents. + * @return {any[]} The toc. + */ + getToc(contents: any[]): any[] { + if (!contents || !contents.length) { + return []; + } + + return JSON.parse(contents[0].content); + } + + /** + * Get the book toc as an array of chapters (not nested). + * + * @param {any[]} contents The module contents. + * @return {AddonModBookTocChapter[]} The toc as a list. + */ + getTocList(contents: any[]): AddonModBookTocChapter[] { + const chapters = [], + toc = this.getToc(contents); + + toc.forEach((chapter) => { + // Add the chapter to the list. + let chapterId = chapter.href.replace('/index.html', ''); + chapters.push({id: chapterId, title: chapter.title, level: chapter.level}); + + if (chapter.subitems) { + // Add all the subchapters to the list. + chapter.subitems.forEach((subChapter) => { + chapterId = subChapter.href.replace('/index.html', ''); + chapters.push({id: chapterId, title: subChapter.title, level: subChapter.level}); + }); + } + }); + + return chapters; + } + + /** + * Invalidates book data. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateBookData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getBookDataCacheKey(courseId)); + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID of the module. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = []; + + promises.push(this.invalidateBookData(courseId, siteId)); + promises.push(this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModBookProvider.COMPONENT, moduleId)); + promises.push(this.courseProvider.invalidateModule(moduleId, siteId)); + + return this.utils.allPromises(promises); + } + + /** + * Check if a file is downloadable. The file param must have a 'type' attribute like in core_course_get_contents response. + * + * @param {any} file File to check. + * @return {boolean} Whether it's downloadable. + */ + isFileDownloadable(file: any): boolean { + return file.type === 'file'; + } + + /** + * Return whether or not the plugin is enabled. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + isPluginEnabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.canDownloadFiles(); + }); + } + + /** + * Report a book as being viewed. + * + * @param {number} id Module ID. + * @param {string} chapterId Chapter ID. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(id: number, chapterId: string): Promise { + if (id) { + const params = { + bookid: id, + chapterid: chapterId + }; + + return this.sitesProvider.getCurrentSite().write('mod_book_view_book', params).then((response) => { + if (!response.status) { + return Promise.reject(null); + } + }); + } + + return Promise.reject(null); + } +} diff --git a/src/addon/mod/book/providers/link-handler.ts b/src/addon/mod/book/providers/link-handler.ts new file mode 100644 index 000000000..4013d4226 --- /dev/null +++ b/src/addon/mod/book/providers/link-handler.ts @@ -0,0 +1,29 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreContentLinksModuleIndexHandler } from '../../../../core/contentlinks/classes/module-index-handler'; +import { CoreCourseHelperProvider } from '../../../../core/course/providers/helper'; + +/** + * Handler to treat links to book. + */ +@Injectable() +export class AddonModBookLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModBookLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider) { + super(courseHelper, 'mmaModBook', 'book'); + } +} diff --git a/src/addon/mod/book/providers/module-handler.ts b/src/addon/mod/book/providers/module-handler.ts new file mode 100644 index 000000000..179b6e536 --- /dev/null +++ b/src/addon/mod/book/providers/module-handler.ts @@ -0,0 +1,68 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NavController, NavOptions } from 'ionic-angular'; +import { AddonModBookProvider } from './book'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '../../../../core/course/providers/module-delegate'; +import { CoreCourseProvider } from '../../../../core/course/providers/course'; + +/** + * Handler to support book modules. + */ +@Injectable() +export class AddonModBookModuleHandler implements CoreCourseModuleHandler { + name = 'book'; + + constructor(protected bookProvider: AddonModBookProvider, private courseProvider: CoreCourseProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return this.bookProvider.isPluginEnabled(); + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('book'), + title: module.name, + class: 'addon-mod_book-handler', + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + // @todo + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + // @todo + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5da362ffc..1e31d4e4e 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -66,6 +66,7 @@ import { CoreUserModule } from '../core/user/user.module'; import { AddonCalendarModule } from '../addon/calendar/calendar.module'; import { AddonUserProfileFieldModule } from '../addon/userprofilefield/userprofilefield.module'; import { AddonFilesModule } from '../addon/files/files.module'; +import { AddonModBookModule } from '../addon/mod/book/book.module'; import { AddonModLabelModule } from '../addon/mod/label/label.module'; // For translate loader. AoT requires an exported function for factories. @@ -105,6 +106,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { AddonCalendarModule, AddonUserProfileFieldModule, AddonFilesModule, + AddonModBookModule, AddonModLabelModule ], bootstrap: [IonicApp], diff --git a/src/core/contentlinks/providers/helper.ts b/src/core/contentlinks/providers/helper.ts index 72d68da66..cdf1be398 100644 --- a/src/core/contentlinks/providers/helper.ts +++ b/src/core/contentlinks/providers/helper.ts @@ -21,6 +21,7 @@ import { CoreInitDelegate } from '../../../providers/init'; import { CoreLoggerProvider } from '../../../providers/logger'; import { CoreSitesProvider } from '../../../providers/sites'; import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../providers/utils/text'; import { CoreUrlUtilsProvider } from '../../../providers/utils/url'; import { CoreLoginHelperProvider } from '../../login/providers/helper'; import { CoreContentLinksDelegate, CoreContentLinksAction } from './delegate'; @@ -37,7 +38,7 @@ export class CoreContentLinksHelperProvider { constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private loginHelper: CoreLoginHelperProvider, private contentLinksDelegate: CoreContentLinksDelegate, private appProvider: CoreAppProvider, private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private translate: TranslateService, - private initDelegate: CoreInitDelegate, eventsProvider: CoreEventsProvider) { + private initDelegate: CoreInitDelegate, eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider) { this.logger = logger.getInstance('CoreContentLinksHelperProvider'); // Listen for app launched URLs. If we receive one, check if it's a content link. @@ -103,7 +104,7 @@ export class CoreContentLinksHelperProvider { const modal = this.domUtils.showModalLoading(); let username; - url = decodeURIComponent(url); + url = this.textUtils.decodeURIComponent(url); // App opened using custom URL scheme. this.logger.debug('Treating custom URL scheme: ' + url); From 7379ca3e714c3d00930e561a06e1312e2dc806e0 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 1 Feb 2018 10:14:47 +0100 Subject: [PATCH 02/11] MOBILE-2335 book: Implement prefetch handler --- src/addon/mod/book/book.module.ts | 9 +- .../mod/book/providers/prefetch-handler.ts | 103 ++++++++++++++++++ src/classes/delegate.ts | 4 +- .../course/classes/module-prefetch-handler.ts | 13 +-- src/core/course/pages/section/section.ts | 12 ++ .../providers/module-prefetch-delegate.ts | 7 +- .../providers/user-profile-field-delegate.ts | 3 +- src/directives/link.ts | 4 +- 8 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 src/addon/mod/book/providers/prefetch-handler.ts diff --git a/src/addon/mod/book/book.module.ts b/src/addon/mod/book/book.module.ts index e1be5e8e2..5ff680aad 100644 --- a/src/addon/mod/book/book.module.ts +++ b/src/addon/mod/book/book.module.ts @@ -16,8 +16,10 @@ import { NgModule } from '@angular/core'; import { AddonModBookProvider } from './providers/book'; import { AddonModBookModuleHandler } from './providers/module-handler'; import { AddonModBookLinkHandler } from './providers/link-handler'; +import { AddonModBookPrefetchHandler } from './providers/prefetch-handler'; import { CoreCourseModuleDelegate } from '../../../core/course/providers/module-delegate'; import { CoreContentLinksDelegate } from '../../../core/contentlinks/providers/delegate'; +import { CoreCourseModulePrefetchDelegate } from '../../../core/course/providers/module-prefetch-delegate'; @NgModule({ declarations: [ @@ -27,13 +29,16 @@ import { CoreContentLinksDelegate } from '../../../core/contentlinks/providers/d providers: [ AddonModBookProvider, AddonModBookModuleHandler, - AddonModBookLinkHandler + AddonModBookLinkHandler, + AddonModBookPrefetchHandler ] }) export class AddonModBookModule { constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModBookModuleHandler, - contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModBookLinkHandler) { + contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModBookLinkHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModBookPrefetchHandler) { moduleDelegate.registerHandler(moduleHandler); contentLinksDelegate.registerHandler(linkHandler); + prefetchDelegate.registerHandler(prefetchHandler); } } diff --git a/src/addon/mod/book/providers/prefetch-handler.ts b/src/addon/mod/book/providers/prefetch-handler.ts new file mode 100644 index 000000000..90b4fcce8 --- /dev/null +++ b/src/addon/mod/book/providers/prefetch-handler.ts @@ -0,0 +1,103 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { CoreCourseModulePrefetchHandlerBase } from '../../../../core/course/classes/module-prefetch-handler'; +import { AddonModBookProvider } from './book'; + +/** + * Handler to prefetch books. + */ +@Injectable() +export class AddonModBookPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'book'; + component = AddonModBookProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^entries$/; + isResource = true; + + constructor(injector: Injector, protected bookProvider: AddonModBookProvider) { + super(injector); + } + + /** + * Download or prefetch the content. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {boolean} [prefetch] True to prefetch, false to download right away. + * @param {string} [dirPath] Path of the directory where to store all the content files. This is to keep the files + * relative paths and make the package work in an iframe. Undefined to download the files + * in the filepool root folder. + * @return {Promise} Promise resolved when all content is downloaded. Data returned is not reliable. + */ + downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string): Promise { + const promises = []; + + promises.push(super.downloadOrPrefetch(module, courseId, prefetch)); + promises.push(this.bookProvider.getBook(courseId, module.id)); + + return Promise.all(promises); + } + + /** + * Returns module intro files. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved with list of intro files. + */ + getIntroFiles(module: any, courseId: number): Promise { + return this.bookProvider.getBook(courseId, module.id).catch(() => { + // Not found, return undefined so module description is used. + }).then((book) => { + return this.getIntroFilesFromInstance(module, book); + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return this.bookProvider.invalidateContent(moduleId, courseId); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule(module: any, courseId: number): Promise { + const promises = []; + + promises.push(this.bookProvider.invalidateBookData(courseId)); + promises.push(this.courseProvider.invalidateModule(module.id)); + + return Promise.all(promises); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled(): boolean | Promise { + return this.bookProvider.isPluginEnabled(); + } +} diff --git a/src/classes/delegate.ts b/src/classes/delegate.ts index cfbb953d4..187e9d615 100644 --- a/src/classes/delegate.ts +++ b/src/classes/delegate.ts @@ -143,9 +143,9 @@ export class CoreDelegate { * * @param {string} handlerName The handler name. * @param {boolean} [enabled] Only enabled, or any. - * @return {any} Handler. + * @return {CoreDelegateHandler} Handler. */ - protected getHandler(handlerName: string, enabled: boolean = false): any { + protected getHandler(handlerName: string, enabled: boolean = false): CoreDelegateHandler { return enabled ? this.enabledHandlers[handlerName] : this.handlers[handlerName]; } diff --git a/src/core/course/classes/module-prefetch-handler.ts b/src/core/course/classes/module-prefetch-handler.ts index 7d1c9623d..47b115140 100644 --- a/src/core/course/classes/module-prefetch-handler.ts +++ b/src/core/course/classes/module-prefetch-handler.ts @@ -53,17 +53,11 @@ export type prefetchFunction = (module: any, courseId: number, single: boolean, * recommended to call the prefetchPackage function since it'll handle changing the status of the module. */ export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePrefetchHandler { - /** - * A name to identify the addon. - * @type {string} - */ - name = 'CoreCourseModulePrefetchHandlerBase'; - /** * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. * @type {string} */ - modname = ''; + name = ''; /** * The handler's component. @@ -235,7 +229,7 @@ export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePref * @param {number} courseId Course ID the module belongs to. * @return {number|Promise} Size, or promise resolved with the size. */ - getDownloadedSize?(module: any, courseId: number): number | Promise { + getDownloadedSize(module: any, courseId: number): number | Promise { const siteId = this.sitesProvider.getCurrentSiteId(); return this.filepoolProvider.getFilesSizeByComponent(siteId, this.component, module.id); @@ -324,9 +318,10 @@ export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePref * Invalidate the prefetched content. * * @param {number} moduleId The module ID. + * @param {number} courseId The course ID the module belongs to. * @return {Promise} Promise resolved when the data is invalidated. */ - invalidateContent(moduleId: number): Promise { + invalidateContent(moduleId: number, courseId: number): Promise { const promises = [], siteId = this.sitesProvider.getCurrentSiteId(); diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts index 35a434feb..1d30000b2 100644 --- a/src/core/course/pages/section/section.ts +++ b/src/core/course/pages/section/section.ts @@ -151,6 +151,18 @@ export class CoreCourseSectionPage implements OnDestroy { promises.push(promise.then((completionStatus) => { // Get all the sections. promises.push(this.courseProvider.getSections(this.course.id, false, true).then((sections) => { + if (refresh) { + // Invalidate the recently downloaded module list. To ensure info can be prefetched. + const modules = this.courseProvider.getSectionsModules(sections); + + return this.prefetchDelegate.invalidateModules(modules, this.course.id).then(() => { + return sections; + }); + } else { + return sections; + } + }).then((sections) => { + this.courseHelper.addHandlerDataForModules(sections, this.course.id, completionStatus); // Format the name of each section and check if it has content. diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts index 6980c3cad..de95a6287 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -202,9 +202,6 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { }; protected ROOT_CACHE_KEY = 'mmCourse:'; - - protected handlers: { [s: string]: CoreCourseModulePrefetchHandler } = {}; // All registered handlers. - protected enabledHandlers: { [s: string]: CoreCourseModulePrefetchHandler } = {}; // Handlers enabled for the current site. protected statusCache = new CoreCache(); // Promises for check updates, to prevent performing the same request twice at the same time. @@ -225,7 +222,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { private courseProvider: CoreCourseProvider, private filepoolProvider: CoreFilepoolProvider, private timeUtils: CoreTimeUtilsProvider, private fileProvider: CoreFileProvider, protected eventsProvider: CoreEventsProvider) { - super('CoreCourseModulePrefetchDelegate', loggerProvider, sitesProvider); + super('CoreCourseModulePrefetchDelegate', loggerProvider, sitesProvider, eventsProvider); this.sitesProvider.createTableFromSchema(this.checkUpdatesTableSchema); } @@ -859,7 +856,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { * @return {CoreCourseModulePrefetchHandler} Prefetch handler. */ getPrefetchHandlerFor(module: any): CoreCourseModulePrefetchHandler { - return this.enabledHandlers[module.modname]; + return this.getHandler(module.modname, true); } /** diff --git a/src/core/user/providers/user-profile-field-delegate.ts b/src/core/user/providers/user-profile-field-delegate.ts index 5f924896d..c3e8b7282 100644 --- a/src/core/user/providers/user-profile-field-delegate.ts +++ b/src/core/user/providers/user-profile-field-delegate.ts @@ -99,7 +99,8 @@ export class CoreUserProfileFieldDelegate extends CoreDelegate { */ getDataForField(field: any, signup: boolean, registerAuth: string, formValues: any): Promise { const type = field.type || field.datatype, - handler = this.getHandler(type, !signup); + handler = this.getHandler(type, !signup); + if (handler) { const name = 'profile_field_' + field.shortname; if (handler.getData) { diff --git a/src/directives/link.ts b/src/directives/link.ts index d565a90c5..b93ef269b 100644 --- a/src/directives/link.ts +++ b/src/directives/link.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Directive, Input, OnInit, ElementRef } from '@angular/core'; +import { Directive, Input, OnInit, ElementRef, Optional } from '@angular/core'; import { NavController, Content } from 'ionic-angular'; import { CoreSitesProvider } from '../providers/sites'; import { CoreDomUtilsProvider } from '../providers/utils/dom'; @@ -40,7 +40,7 @@ export class CoreLinkDirective implements OnInit { constructor(element: ElementRef, private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, private sitesProvider: CoreSitesProvider, private urlUtils: CoreUrlUtilsProvider, private contentLinksHelper: CoreContentLinksHelperProvider, private navCtrl: NavController, - private content: Content) { + @Optional() private content: Content) { // This directive can be added dynamically. In that case, the first param is the anchor HTMLElement. this.element = element.nativeElement || element; } From 698e2cf8f6e05607a3a0657044d72db8be34d372 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 2 Feb 2018 08:11:29 +0100 Subject: [PATCH 03/11] MOBILE-2335 core: Fix issues with DB and restore media --- src/classes/sqlitedb.ts | 8 ++++---- src/directives/link.ts | 2 +- src/providers/filepool.ts | 22 ++++++++++++++++------ src/providers/utils/dom.ts | 22 ++++++++++------------ 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/classes/sqlitedb.ts b/src/classes/sqlitedb.ts index a4a0b160a..efef5527d 100644 --- a/src/classes/sqlitedb.ts +++ b/src/classes/sqlitedb.ts @@ -649,11 +649,11 @@ export class SQLiteDB { * * @param {string} table The database table. * @param {object} data An object with the fields to insert/update: fieldname=>fieldvalue. - * @param {object} conditions The conditions to check if the record already exists. + * @param {object} conditions The conditions to check if the record already exists (and to update it). * @return {Promise} Promise resolved with done. */ insertOrUpdateRecord(table: string, data: object, conditions: object): Promise { - return this.getRecord(table, conditions || data).then(() => { + return this.getRecord(table, conditions).then(() => { // It exists, update it. return this.updateRecords(table, data, conditions); }).catch(() => { @@ -854,7 +854,7 @@ export class SQLiteDB { // Create the list of params using the "data" object and the params for the where clause. params = Object.keys(data).map((key) => data[key]); if (where && whereParams) { - params = params.concat(whereParams[1]); + params = params.concat(whereParams); } return this.execute(sql, params); @@ -868,7 +868,7 @@ export class SQLiteDB { */ whereClause(conditions: any = {}): any[] { if (!conditions || !Object.keys(conditions).length) { - return ['', []]; + return ['1 = 1', []]; } const where = [], diff --git a/src/directives/link.ts b/src/directives/link.ts index b93ef269b..2069ce479 100644 --- a/src/directives/link.ts +++ b/src/directives/link.ts @@ -81,7 +81,7 @@ export class CoreLinkDirective implements OnInit { protected navigate(href: string): void { const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link='; - if (href.indexOf('cdvfile://') === 0 || href.indexOf('file://') === 0) { + if (href.indexOf('cdvfile://') === 0 || href.indexOf('file://') === 0 || href.indexOf('filesystem:') === 0) { // We have a local file. this.utils.openFile(href).catch((error) => { this.domUtils.showErrorModal(error); diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index 80388199e..ab2dba3e6 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -477,7 +477,7 @@ export class CoreFilepoolProvider { componentId: componentId || '' }; - return db.insertOrUpdateRecord(this.LINKS_TABLE, newEntry, undefined); + return db.insertOrUpdateRecord(this.LINKS_TABLE, newEntry, { fileId: fileId }); }); } @@ -1178,7 +1178,9 @@ export class CoreFilepoolProvider { return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress); }).then((response) => { if (typeof component != 'undefined') { - this.addFileLink(siteId, fileId, component, componentId); + this.addFileLink(siteId, fileId, component, componentId).catch(() => { + // Ignore errors. + }); } this.notifyFileDownloaded(siteId, fileId); @@ -2237,9 +2239,11 @@ export class CoreFilepoolProvider { }), whereAndParams = db.getInOrEqual(fileIds); + whereAndParams[0] = 'fileId ' + whereAndParams[0]; + if (onlyUnknown) { whereAndParams[0] += ' AND (isexternalfile = ? OR (revision < ? AND timemodified = ?))'; - whereAndParams[1] = whereAndParams[1].params.concat([0, 1, 0]); + whereAndParams[1] = whereAndParams[1].concat([0, 1, 0]); } return db.updateRecordsWhere(this.FILES_TABLE, { stale: 1 }, whereAndParams[0], whereAndParams[1]); @@ -2443,8 +2447,12 @@ export class CoreFilepoolProvider { if (entry && !this.isFileOutdated(entry, options.revision, options.timemodified)) { // We have the file, it is not stale, we can update links and remove from queue. this.logger.debug('Queued file already in store, ignoring...'); - this.addFileLinks(siteId, fileId, links); - this.removeFromQueue(siteId, fileId).finally(() => { + this.addFileLinks(siteId, fileId, links).catch(() => { + // Ignore errors. + }); + this.removeFromQueue(siteId, fileId).catch(() => { + // Ignore errors. + }).finally(() => { this.treatQueueDeferred(siteId, fileId, true); }); this.notifyFileDownloaded(siteId, fileId); @@ -2457,7 +2465,9 @@ export class CoreFilepoolProvider { return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, entry).then(() => { // Success, we add links and remove from queue. - this.addFileLinks(siteId, fileId, links); + this.addFileLinks(siteId, fileId, links).catch(() => { + // Ignore errors. + }); this.treatQueueDeferred(siteId, fileId, true); this.notifyFileDownloaded(siteId, fileId); diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index e1a8a5c41..41e3dc049 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -547,28 +547,26 @@ export class CoreDomUtilsProvider { // Treat elements with src (img, audio, video, ...). media = this.element.querySelectorAll('img, video, audio, source, track'); - for (const i in media) { - const el = media[i]; - let newSrc = paths[this.textUtils.decodeURIComponent(el.getAttribute('src'))]; + media.forEach((media: HTMLElement) => { + let newSrc = paths[this.textUtils.decodeURIComponent(media.getAttribute('src'))]; if (typeof newSrc != 'undefined') { - el.setAttribute('src', newSrc); + media.setAttribute('src', newSrc); } // Treat video posters. - if (el.tagName == 'VIDEO' && el.getAttribute('poster')) { - newSrc = paths[this.textUtils.decodeURIComponent(el.getAttribute('poster'))]; + if (media.tagName == 'VIDEO' && media.getAttribute('poster')) { + newSrc = paths[this.textUtils.decodeURIComponent(media.getAttribute('poster'))]; if (typeof newSrc !== 'undefined') { - el.setAttribute('poster', newSrc); + media.setAttribute('poster', newSrc); } } - } + }); // Now treat links. anchors = this.element.querySelectorAll('a'); - for (const i in anchors) { - const anchor = anchors[i], - href = this.textUtils.decodeURIComponent(anchor.getAttribute('href')), + anchors.forEach((anchor: HTMLElement) => { + const href = this.textUtils.decodeURIComponent(anchor.getAttribute('href')), newUrl = paths[href]; if (typeof newUrl != 'undefined') { @@ -578,7 +576,7 @@ export class CoreDomUtilsProvider { anchorFn(anchor, href); } } - } + }); return this.element.innerHTML; } From 4bcecc81dd93dea35eb28de78f7958b4fefe54b1 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 2 Feb 2018 08:13:28 +0100 Subject: [PATCH 04/11] MOBILE-2335 book: Implement index page and its components --- src/addon/mod/book/book.module.ts | 2 + .../mod/book/components/components.module.ts | 52 ++++ .../mod/book/components/index/index.html | 26 ++ src/addon/mod/book/components/index/index.ts | 237 ++++++++++++++++++ .../navigation-arrows/navigation-arrows.html | 14 ++ .../navigation-arrows/navigation-arrows.ts | 32 +++ .../components/toc-popover/toc-popover.html | 5 + .../components/toc-popover/toc-popover.ts | 41 +++ src/addon/mod/book/pages/index/index.html | 16 ++ .../mod/book/pages/index/index.module.ts | 33 +++ src/addon/mod/book/pages/index/index.ts | 48 ++++ .../mod/book/providers/module-handler.ts | 2 +- 12 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 src/addon/mod/book/components/components.module.ts create mode 100644 src/addon/mod/book/components/index/index.html create mode 100644 src/addon/mod/book/components/index/index.ts create mode 100644 src/addon/mod/book/components/navigation-arrows/navigation-arrows.html create mode 100644 src/addon/mod/book/components/navigation-arrows/navigation-arrows.ts create mode 100644 src/addon/mod/book/components/toc-popover/toc-popover.html create mode 100644 src/addon/mod/book/components/toc-popover/toc-popover.ts create mode 100644 src/addon/mod/book/pages/index/index.html create mode 100644 src/addon/mod/book/pages/index/index.module.ts create mode 100644 src/addon/mod/book/pages/index/index.ts diff --git a/src/addon/mod/book/book.module.ts b/src/addon/mod/book/book.module.ts index 5ff680aad..cebd7bb9e 100644 --- a/src/addon/mod/book/book.module.ts +++ b/src/addon/mod/book/book.module.ts @@ -13,6 +13,7 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { AddonModBookComponentsModule } from './components/components.module'; import { AddonModBookProvider } from './providers/book'; import { AddonModBookModuleHandler } from './providers/module-handler'; import { AddonModBookLinkHandler } from './providers/link-handler'; @@ -25,6 +26,7 @@ import { CoreCourseModulePrefetchDelegate } from '../../../core/course/providers declarations: [ ], imports: [ + AddonModBookComponentsModule ], providers: [ AddonModBookProvider, diff --git a/src/addon/mod/book/components/components.module.ts b/src/addon/mod/book/components/components.module.ts new file mode 100644 index 000000000..1ea1ff532 --- /dev/null +++ b/src/addon/mod/book/components/components.module.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; +import { CoreCourseComponentsModule } from '../../../../core/course/components/components.module'; +import { AddonModBookIndexComponent } from './index/index'; +import { AddonModBookTocPopoverComponent } from './toc-popover/toc-popover'; +import { AddonModBookNavigationArrowsComponent } from './navigation-arrows/navigation-arrows'; + +@NgModule({ + declarations: [ + AddonModBookIndexComponent, + AddonModBookTocPopoverComponent, + AddonModBookNavigationArrowsComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModBookIndexComponent, + AddonModBookTocPopoverComponent, + AddonModBookNavigationArrowsComponent + ], + entryComponents: [ + AddonModBookIndexComponent, + AddonModBookTocPopoverComponent + ] +}) +export class AddonModBookComponentsModule {} diff --git a/src/addon/mod/book/components/index/index.html b/src/addon/mod/book/components/index/index.html new file mode 100644 index 000000000..dcfef3a15 --- /dev/null +++ b/src/addon/mod/book/components/index/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + +
+ + + +
+ +
diff --git a/src/addon/mod/book/components/index/index.ts b/src/addon/mod/book/components/index/index.ts new file mode 100644 index 000000000..f79093719 --- /dev/null +++ b/src/addon/mod/book/components/index/index.ts @@ -0,0 +1,237 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, Output, EventEmitter, Optional } from '@angular/core'; +import { NavParams, NavController, Content, PopoverController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../../../../providers/app'; +import { CoreDomUtilsProvider } from '../../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../../providers/utils/text'; +import { CoreCourseProvider } from '../../../../../core/course/providers/course'; +import { CoreCourseHelperProvider } from '../../../../../core/course/providers/helper'; +import { AddonModBookProvider, AddonModBookContentsMap, AddonModBookTocChapter } from '../../providers/book'; +import { AddonModBookPrefetchHandler } from '../../providers/prefetch-handler'; +import { AddonModBookTocPopoverComponent } from '../../components/toc-popover/toc-popover'; + +/** + * Component that displays a book. + */ +@Component({ + selector: 'addon-mod-book-index', + templateUrl: 'index.html', +}) +export class AddonModBookIndexComponent implements OnInit { + @Input() module: any; // The module of the book. + @Input() courseId: number; // Course ID the book belongs to. + @Output() bookRetrieved?: EventEmitter; + + externalUrl: string; + description: string; + loaded: boolean; + component = AddonModBookProvider.COMPONENT; + componentId: number; + chapterContent: string; + previousChapter: string; + nextChapter: string; + refreshIcon: string; + + protected chapters: AddonModBookTocChapter[]; + protected currentChapter: string; + protected contentsMap: AddonModBookContentsMap; + + constructor(private bookProvider: AddonModBookProvider, private courseProvider: CoreCourseProvider, + private domUtils: CoreDomUtilsProvider, private appProvider: CoreAppProvider, private textUtils: CoreTextUtilsProvider, + private courseHelper: CoreCourseHelperProvider, private prefetchDelegate: AddonModBookPrefetchHandler, + private popoverCtrl: PopoverController, private translate: TranslateService, @Optional() private content: Content) { + this.bookRetrieved = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.description = this.module.description; + this.componentId = this.module.id; + this.externalUrl = this.module.url; + this.loaded = false; + this.refreshIcon = 'spinner'; + + this.fetchContent(); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void): Promise { + this.refreshIcon = 'spinner'; + + return this.bookProvider.invalidateContent(this.module.id, this.courseId).catch(() => { + // Ignore errors. + }).then(() => { + return this.fetchContent(this.currentChapter, true); + }).finally(() => { + this.refreshIcon = 'refresh'; + refresher && refresher.complete(); + done && done(); + }); + } + + /** + * Show the TOC. + * + * @param {MouseEvent} event Event. + */ + showToc(event: MouseEvent): void { + const popover = this.popoverCtrl.create(AddonModBookTocPopoverComponent, { + chapters: this.chapters + }); + + popover.onDidDismiss((chapterId) => { + this.changeChapter(chapterId); + }); + + popover.present({ + ev: event + }); + } + + /** + * Change the current chapter. + * + * @param {string} chapterId Chapter to load. + * @return {Promise} Promise resolved when done. + */ + changeChapter(chapterId: string): void { + if (chapterId && chapterId != this.currentChapter) { + this.loaded = false; + this.refreshIcon = 'spinner'; + this.loadChapter(chapterId); + } + } + + /** + * Expand the description. + */ + expandDescription(): void { + this.textUtils.expandText(this.translate.instant('core.description'), this.description, this.component, this.module.id); + } + + /** + * Prefetch the module. + */ + prefetch(): void { + // @todo this.courseHelper.contextMenuPrefetch($scope, this.module, this.courseId); + } + + /** + * Confirm and remove downloaded files. + */ + removeFiles(): void { + this.courseHelper.confirmAndRemoveFiles(this.module, this.courseId); + } + + /** + * Download book contents and load the current chapter. + * + * @param {string} [chapterId] Chapter to load. + * @param {boolean} [refresh] Whether we're refreshing data. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(chapterId?: string, refresh?: boolean): Promise { + const promises = []; + let downloadFailed = false; + + // Try to get the book data. + promises.push(this.bookProvider.getBook(this.courseId, this.module.id).then((book) => { + this.bookRetrieved.emit(book); + this.description = book.intro || this.description; + }).catch(() => { + // Ignore errors since this WS isn't available in some Moodle versions. + })); + + // Download content. This function also loads module contents if needed. + promises.push(this.prefetchDelegate.download(this.module, this.courseId).catch(() => { + // Mark download as failed but go on since the main files could have been downloaded. + downloadFailed = true; + + if (!this.module.contents.length) { + // Try to load module contents for offline usage. + return this.courseProvider.loadModuleContents(this.module, this.courseId); + } + })); + + return Promise.all(promises).then(() => { + this.contentsMap = this.bookProvider.getContentsMap(this.module.contents); + this.chapters = this.bookProvider.getTocList(this.module.contents); + + if (typeof this.currentChapter == 'undefined') { + this.currentChapter = this.bookProvider.getFirstChapter(this.chapters); + } + + // Show chapter. + return this.loadChapter(chapterId || this.currentChapter).then(() => { + if (downloadFailed && this.appProvider.isOnline()) { + // We could load the main file but the download failed. Show error message. + this.domUtils.showErrorModal('core.errordownloadingsomefiles', true); + } + + // All data obtained, now fill the context menu. + // @todo this.courseHelper.fillContextMenu($scope, module, courseId, refresh, mmaModBookComponent); + }).catch(() => { + // Ignore errors, they're handled inside the loadChapter function. + }); + }).catch((error) => { + // Error getting data, fail. + this.loaded = true; + this.refreshIcon = 'refresh'; + this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + }); + } + + /** + * Load a book chapter. + * + * @param {string} chapterId Chapter to load. + * @return {Promise} Promise resolved when done. + */ + protected loadChapter(chapterId: string): Promise { + this.currentChapter = chapterId; + this.content && this.content.scrollToTop(); + + return this.bookProvider.getChapterContent(this.contentsMap, chapterId, this.module.id).then((content) => { + this.chapterContent = content; + this.previousChapter = this.bookProvider.getPreviousChapter(this.chapters, chapterId); + this.nextChapter = this.bookProvider.getNextChapter(this.chapters, chapterId); + + // Chapter loaded, log view. We don't return the promise because we don't want to block the user for this. + this.bookProvider.logView(this.module.instance, chapterId).then(() => { + // Module is completed when last chapter is viewed, so we only check completion if the last is reached. + if (!this.nextChapter) { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + } + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_book.errorchapter', true); + + return Promise.reject(null); + }).finally(() => { + this.loaded = true; + this.refreshIcon = 'refresh'; + }); + } +} diff --git a/src/addon/mod/book/components/navigation-arrows/navigation-arrows.html b/src/addon/mod/book/components/navigation-arrows/navigation-arrows.html new file mode 100644 index 000000000..44e6a24d4 --- /dev/null +++ b/src/addon/mod/book/components/navigation-arrows/navigation-arrows.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/addon/mod/book/components/navigation-arrows/navigation-arrows.ts b/src/addon/mod/book/components/navigation-arrows/navigation-arrows.ts new file mode 100644 index 000000000..acb60d156 --- /dev/null +++ b/src/addon/mod/book/components/navigation-arrows/navigation-arrows.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; + +/** + * Component to navigate to previous or next chapter in a book. + */ +@Component({ + selector: 'addon-mod-book-navigation-arrows', + templateUrl: 'navigation-arrows.html' +}) +export class AddonModBookNavigationArrowsComponent { + @Input() previous?: string; // Previous chapter ID. + @Input() next?: string; // Next chapter ID. + @Output() action?: EventEmitter; // Will emit an event when the item clicked. + + constructor() { + this.action = new EventEmitter(); + } +} diff --git a/src/addon/mod/book/components/toc-popover/toc-popover.html b/src/addon/mod/book/components/toc-popover/toc-popover.html new file mode 100644 index 000000000..6d3dca6f0 --- /dev/null +++ b/src/addon/mod/book/components/toc-popover/toc-popover.html @@ -0,0 +1,5 @@ + + +

{{chapter.title}}

+
+
diff --git a/src/addon/mod/book/components/toc-popover/toc-popover.ts b/src/addon/mod/book/components/toc-popover/toc-popover.ts new file mode 100644 index 000000000..01ce767ab --- /dev/null +++ b/src/addon/mod/book/components/toc-popover/toc-popover.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, ViewController } from 'ionic-angular'; +import { AddonModBookTocChapter } from '../../providers/book'; + +/** + * Component to display the TOC of a book. + */ +@Component({ + selector: 'addon-mod-book-toc-popover', + templateUrl: 'toc-popover.html' +}) +export class AddonModBookTocPopoverComponent { + chapters: AddonModBookTocChapter[]; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.chapters = navParams.get('chapters') || []; + } + + /** + * Function called when a course is clicked. + * + * @param {string} id ID of the clicked chapter. + */ + loadChapter(id: string): void { + this.viewCtrl.dismiss(id); + } +} diff --git a/src/addon/mod/book/pages/index/index.html b/src/addon/mod/book/pages/index/index.html new file mode 100644 index 000000000..c0a0d5fa3 --- /dev/null +++ b/src/addon/mod/book/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/book/pages/index/index.module.ts b/src/addon/mod/book/pages/index/index.module.ts new file mode 100644 index 000000000..02b409af8 --- /dev/null +++ b/src/addon/mod/book/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '../../../../../directives/directives.module'; +import { AddonModBookComponentsModule } from '../../components/components.module'; +import { AddonModBookIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModBookIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModBookComponentsModule, + IonicPageModule.forChild(AddonModBookIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModBookIndexPageModule {} diff --git a/src/addon/mod/book/pages/index/index.ts b/src/addon/mod/book/pages/index/index.ts new file mode 100644 index 000000000..a45a34e1d --- /dev/null +++ b/src/addon/mod/book/pages/index/index.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModBookIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a book. + */ +@IonicPage({ segment: 'addon-mod-book-index' }) +@Component({ + selector: 'page-addon-mod-book-index', + templateUrl: 'index.html', +}) +export class AddonModBookIndexPage { + @ViewChild(AddonModBookIndexComponent) bookComponent: AddonModBookIndexComponent; + + title: string; + module: any; + courseId: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.title = this.module.name; + } + + /** + * Update some data based on the book instance. + * + * @param {any} book Book instance. + */ + updateData(book: any): void { + this.title = book.name || this.title; + } +} diff --git a/src/addon/mod/book/providers/module-handler.ts b/src/addon/mod/book/providers/module-handler.ts index 179b6e536..495bdbbd3 100644 --- a/src/addon/mod/book/providers/module-handler.ts +++ b/src/addon/mod/book/providers/module-handler.ts @@ -50,7 +50,7 @@ export class AddonModBookModuleHandler implements CoreCourseModuleHandler { title: module.name, class: 'addon-mod_book-handler', action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { - // @todo + navCtrl.push('AddonModBookIndexPage', {module: module, courseId: courseId}, options); } }; } From aa95a20287938b933df2c64d60c53459bd8fb2cb Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 2 Feb 2018 08:57:34 +0100 Subject: [PATCH 05/11] MOBILE-2335 book: Support single activity format with book --- src/addon/mod/book/components/index/index.ts | 3 ++- .../mod/book/providers/module-handler.ts | 4 +++- .../mod/label/providers/module-handler.ts | 1 + src/classes/delegate.ts | 2 +- .../dynamic-component/dynamic-component.ts | 13 ++++++++++ src/core/course/components/format/format.ts | 24 ++++++++++++++++++- .../components/singleactivity.ts | 16 ++++++++++++- src/core/course/pages/section/section.ts | 10 +++++--- src/core/course/providers/module-delegate.ts | 15 ++++++++++++ 9 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/addon/mod/book/components/index/index.ts b/src/addon/mod/book/components/index/index.ts index f79093719..64b9ea7a0 100644 --- a/src/addon/mod/book/components/index/index.ts +++ b/src/addon/mod/book/components/index/index.ts @@ -20,6 +20,7 @@ import { CoreDomUtilsProvider } from '../../../../../providers/utils/dom'; import { CoreTextUtilsProvider } from '../../../../../providers/utils/text'; import { CoreCourseProvider } from '../../../../../core/course/providers/course'; import { CoreCourseHelperProvider } from '../../../../../core/course/providers/helper'; +import { CoreCourseModuleMainComponent } from '../../../../../core/course/providers/module-delegate'; import { AddonModBookProvider, AddonModBookContentsMap, AddonModBookTocChapter } from '../../providers/book'; import { AddonModBookPrefetchHandler } from '../../providers/prefetch-handler'; import { AddonModBookTocPopoverComponent } from '../../components/toc-popover/toc-popover'; @@ -31,7 +32,7 @@ import { AddonModBookTocPopoverComponent } from '../../components/toc-popover/to selector: 'addon-mod-book-index', templateUrl: 'index.html', }) -export class AddonModBookIndexComponent implements OnInit { +export class AddonModBookIndexComponent implements OnInit, CoreCourseModuleMainComponent { @Input() module: any; // The module of the book. @Input() courseId: number; // Course ID the book belongs to. @Output() bookRetrieved?: EventEmitter; diff --git a/src/addon/mod/book/providers/module-handler.ts b/src/addon/mod/book/providers/module-handler.ts index 495bdbbd3..7fc1a41ac 100644 --- a/src/addon/mod/book/providers/module-handler.ts +++ b/src/addon/mod/book/providers/module-handler.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { NavController, NavOptions } from 'ionic-angular'; import { AddonModBookProvider } from './book'; +import { AddonModBookIndexComponent } from '../components/index/index'; import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '../../../../core/course/providers/module-delegate'; import { CoreCourseProvider } from '../../../../core/course/providers/course'; @@ -57,12 +58,13 @@ export class AddonModBookModuleHandler implements CoreCourseModuleHandler { /** * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. * * @param {any} course The course object. * @param {any} module The module object. * @return {any} The component to use, undefined if not found. */ getMainComponent(course: any, module: any): any { - // @todo + return AddonModBookIndexComponent; } } diff --git a/src/addon/mod/label/providers/module-handler.ts b/src/addon/mod/label/providers/module-handler.ts index a32b9474a..5b76b9818 100644 --- a/src/addon/mod/label/providers/module-handler.ts +++ b/src/addon/mod/label/providers/module-handler.ts @@ -57,6 +57,7 @@ export class AddonModLabelModuleHandler implements CoreCourseModuleHandler { /** * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. * * @param {any} course The course object. * @param {any} module The module object. diff --git a/src/classes/delegate.ts b/src/classes/delegate.ts index 187e9d615..b26d26a32 100644 --- a/src/classes/delegate.ts +++ b/src/classes/delegate.ts @@ -134,7 +134,7 @@ export class CoreDelegate { if (handler && handler[fnName]) { return handler[fnName].apply(handler, params); } else if (this.defaultHandler && this.defaultHandler[fnName]) { - return this.defaultHandler[fnName].apply(this, params); + return this.defaultHandler[fnName].apply(this.defaultHandler, params); } } diff --git a/src/components/dynamic-component/dynamic-component.ts b/src/components/dynamic-component/dynamic-component.ts index 01fa9532d..2c8e5898b 100644 --- a/src/components/dynamic-component/dynamic-component.ts +++ b/src/components/dynamic-component/dynamic-component.ts @@ -99,6 +99,19 @@ export class CoreDynamicComponent implements OnInit, OnChanges, DoCheck { } } + /** + * Call a certain function on the component. + * + * @param {string} name Name of the function to call. + * @param {any[]} params List of params to send to the function. + * @return {any} Result of the call. Undefined if no component instance or the function doesn't exist. + */ + callComponentFunction(name: string, params: any[]): any { + if (this.instance && typeof this.instance[name] == 'function') { + return this.instance[name].apply(this.instance, params); + } + } + /** * Create a component, add it to a container and set the input data. * diff --git a/src/core/course/components/format/format.ts b/src/core/course/components/format/format.ts index 999c78ad1..ceb8780ca 100644 --- a/src/core/course/components/format/format.ts +++ b/src/core/course/components/format/format.ts @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange, Output, EventEmitter } from '@angular/core'; +import { + Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange, Output, EventEmitter, ViewChildren, QueryList +} from '@angular/core'; import { Content } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreEventsProvider } from '../../../../providers/events'; @@ -22,6 +24,7 @@ import { CoreCourseProvider } from '../../../course/providers/course'; import { CoreCourseHelperProvider } from '../../../course/providers/helper'; import { CoreCourseFormatDelegate } from '../../../course/providers/format-delegate'; import { CoreCourseModulePrefetchDelegate } from '../../../course/providers/module-prefetch-delegate'; +import { CoreDynamicComponent } from '../../../../components/dynamic-component/dynamic-component'; /** * Component to display course contents using a certain format. If the format isn't found, use default one. @@ -46,6 +49,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { @Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section. @Output() completionChanged?: EventEmitter; // Will emit an event when any module completion changes. + @ViewChildren(CoreDynamicComponent) dynamicComponents: QueryList; + // All the possible component classes. courseFormatComponent: any; courseSummaryComponent: any; @@ -286,6 +291,23 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { }); } + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void): Promise { + const promises = []; + + this.dynamicComponents.forEach((component) => { + promises.push(Promise.resolve(component.callComponentFunction('doRefresh', [refresher, done]))); + }); + + return Promise.all(promises); + } + /** * Component destroyed. */ diff --git a/src/core/course/formats/singleactivity/components/singleactivity.ts b/src/core/course/formats/singleactivity/components/singleactivity.ts index cd5cae32c..a94f8730f 100644 --- a/src/core/course/formats/singleactivity/components/singleactivity.ts +++ b/src/core/course/formats/singleactivity/components/singleactivity.ts @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnChanges, SimpleChange } from '@angular/core'; +import { Component, Input, OnChanges, SimpleChange, ViewChild } from '@angular/core'; import { CoreCourseModuleDelegate } from '../../../providers/module-delegate'; import { CoreCourseUnsupportedModuleComponent } from '../../../components/unsupported-module/unsupported-module'; +import { CoreDynamicComponent } from '../../../../../components/dynamic-component/dynamic-component'; /** * Component to display single activity format. It will determine the right component to use and instantiate it. @@ -30,6 +31,8 @@ export class CoreCourseFormatSingleActivityComponent implements OnChanges { @Input() sections: any[]; // List of course sections. @Input() downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. + @ViewChild(CoreDynamicComponent) dynamicComponent: CoreDynamicComponent; + componentClass: any; // The class of the component to render. data: any = {}; // Data to pass to the component. @@ -52,4 +55,15 @@ export class CoreCourseFormatSingleActivityComponent implements OnChanges { this.data.module = module; } } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void): Promise { + return Promise.resolve(this.dynamicComponent.callComponentFunction('doRefresh', [refresher, done])); + } } diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts index 1d30000b2..633fa480f 100644 --- a/src/core/course/pages/section/section.ts +++ b/src/core/course/pages/section/section.ts @@ -24,6 +24,7 @@ import { CoreCourseHelperProvider } from '../../providers/helper'; import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; import { CoreCourseModulePrefetchDelegate } from '../../providers/module-prefetch-delegate'; import { CoreCourseOptionsDelegate, CoreCourseOptionsHandlerToDisplay } from '../../providers/options-delegate'; +import { CoreCourseFormatComponent } from '../../components/format/format'; import { CoreCoursesProvider } from '../../../courses/providers/courses'; /** @@ -36,6 +37,7 @@ import { CoreCoursesProvider } from '../../../courses/providers/courses'; }) export class CoreCourseSectionPage implements OnDestroy { @ViewChild(Content) content: Content; + @ViewChild(CoreCourseFormatComponent) formatComponent: CoreCourseFormatComponent; title: string; course: any; @@ -150,7 +152,7 @@ export class CoreCourseSectionPage implements OnDestroy { promises.push(promise.then((completionStatus) => { // Get all the sections. - promises.push(this.courseProvider.getSections(this.course.id, false, true).then((sections) => { + return this.courseProvider.getSections(this.course.id, false, true).then((sections) => { if (refresh) { // Invalidate the recently downloaded module list. To ensure info can be prefetched. const modules = this.courseProvider.getSectionsModules(sections); @@ -185,7 +187,7 @@ export class CoreCourseSectionPage implements OnDestroy { // Get the title again now that we have sections. this.title = this.courseFormatDelegate.getCourseTitle(this.course, this.sections); - })); + }); })); // Load the course handlers. @@ -207,7 +209,9 @@ export class CoreCourseSectionPage implements OnDestroy { doRefresh(refresher: any): void { this.invalidateData().finally(() => { this.loadData(true).finally(() => { - refresher.complete(); + this.formatComponent.doRefresh(refresher).finally(() => { + refresher.complete(); + }); }); }); } diff --git a/src/core/course/providers/module-delegate.ts b/src/core/course/providers/module-delegate.ts index dd2dec7c9..1fbc7e2be 100644 --- a/src/core/course/providers/module-delegate.ts +++ b/src/core/course/providers/module-delegate.ts @@ -37,6 +37,7 @@ export interface CoreCourseModuleHandler extends CoreDelegateHandler { /** * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. * * @param {any} course The course object. * @param {any} module The module object. @@ -91,6 +92,20 @@ export interface CoreCourseModuleHandlerData { action?(event: Event, navCtrl: NavController, module: any, courseId: number, options?: NavOptions): void; } +/** + * Interface that all the components to render the module in singleactivity must implement. + */ +export interface CoreCourseModuleMainComponent { + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void): Promise; +} + /** * A button to display in a module item. */ From e8cbead5d614d37d48e93bb5d927fefcf3834cf8 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 2 Feb 2018 13:10:19 +0100 Subject: [PATCH 06/11] MOBILE-2335 book: Support context menu prefetch and size --- .../mod/book/components/index/index.html | 4 +- src/addon/mod/book/components/index/index.ts | 24 ++++-- src/components/context-menu/context-menu.scss | 6 ++ src/core/course/providers/helper.ts | 78 ++++++++++++++++++- .../providers/module-prefetch-delegate.ts | 50 ++++++++---- 5 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 src/components/context-menu/context-menu.scss diff --git a/src/addon/mod/book/components/index/index.html b/src/addon/mod/book/components/index/index.html index dcfef3a15..5463e70a5 100644 --- a/src/addon/mod/book/components/index/index.html +++ b/src/addon/mod/book/components/index/index.html @@ -7,8 +7,8 @@ - - + + diff --git a/src/addon/mod/book/components/index/index.ts b/src/addon/mod/book/components/index/index.ts index 64b9ea7a0..48e2592ed 100644 --- a/src/addon/mod/book/components/index/index.ts +++ b/src/addon/mod/book/components/index/index.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, Input, Output, EventEmitter, Optional } from '@angular/core'; +import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, Optional } from '@angular/core'; import { NavParams, NavController, Content, PopoverController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '../../../../../providers/app'; @@ -32,24 +32,31 @@ import { AddonModBookTocPopoverComponent } from '../../components/toc-popover/to selector: 'addon-mod-book-index', templateUrl: 'index.html', }) -export class AddonModBookIndexComponent implements OnInit, CoreCourseModuleMainComponent { +export class AddonModBookIndexComponent implements OnInit, OnDestroy, CoreCourseModuleMainComponent { @Input() module: any; // The module of the book. @Input() courseId: number; // Course ID the book belongs to. @Output() bookRetrieved?: EventEmitter; - externalUrl: string; - description: string; loaded: boolean; component = AddonModBookProvider.COMPONENT; componentId: number; chapterContent: string; previousChapter: string; nextChapter: string; + + // Data for context menu. + externalUrl: string; + description: string; refreshIcon: string; + prefetchStatusIcon: string; + prefetchText: string; + size: string; protected chapters: AddonModBookTocChapter[]; protected currentChapter: string; protected contentsMap: AddonModBookContentsMap; + protected isDestroyed = false; + protected statusObserver; constructor(private bookProvider: AddonModBookProvider, private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider, private appProvider: CoreAppProvider, private textUtils: CoreTextUtilsProvider, @@ -136,7 +143,7 @@ export class AddonModBookIndexComponent implements OnInit, CoreCourseModuleMainC * Prefetch the module. */ prefetch(): void { - // @todo this.courseHelper.contextMenuPrefetch($scope, this.module, this.courseId); + this.courseHelper.contextMenuPrefetch(this, this.module, this.courseId); } /** @@ -192,7 +199,7 @@ export class AddonModBookIndexComponent implements OnInit, CoreCourseModuleMainC } // All data obtained, now fill the context menu. - // @todo this.courseHelper.fillContextMenu($scope, module, courseId, refresh, mmaModBookComponent); + this.courseHelper.fillContextMenu(this, this.module, this.courseId, refresh, this.component); }).catch(() => { // Ignore errors, they're handled inside the loadChapter function. }); @@ -235,4 +242,9 @@ export class AddonModBookIndexComponent implements OnInit, CoreCourseModuleMainC this.refreshIcon = 'refresh'; }); } + + ngOnDestroy(): void { + this.isDestroyed = true; + this.statusObserver && this.statusObserver.off(); + } } diff --git a/src/components/context-menu/context-menu.scss b/src/components/context-menu/context-menu.scss new file mode 100644 index 000000000..9730ca1e4 --- /dev/null +++ b/src/components/context-menu/context-menu.scss @@ -0,0 +1,6 @@ +core-context-menu-popover { + .item-md ion-icon[item-start] + .item-inner, + .item-md ion-icon[item-start] + .item-input { + @include margin-horizontal(5px, null); + } +} diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index d4b76e357..36fbc2433 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '../../../providers/events'; import { CoreFilepoolProvider } from '../../../providers/filepool'; import { CoreSitesProvider } from '../../../providers/sites'; import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; @@ -114,7 +115,8 @@ export class CoreCourseHelperProvider { private filepoolProvider: CoreFilepoolProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider, private utils: CoreUtilsProvider, private translate: TranslateService, private loginHelper: CoreLoginHelperProvider, - private courseOptionsDelegate: CoreCourseOptionsDelegate, private siteHomeProvider: CoreSiteHomeProvider) { } + private courseOptionsDelegate: CoreCourseOptionsDelegate, private siteHomeProvider: CoreSiteHomeProvider, + private eventsProvider: CoreEventsProvider) { } /** * This function treats every module on the sections provided to load the handler data, treat completion @@ -358,8 +360,12 @@ export class CoreCourseHelperProvider { * @return {Promise} Promise resolved when done. */ confirmAndRemoveFiles(module: any, courseId: number): Promise { - return this.domUtils.showConfirm(this.translate.instant('course.confirmdeletemodulefiles')).then(() => { + return this.domUtils.showConfirm(this.translate.instant('core.course.confirmdeletemodulefiles')).then(() => { return this.prefetchDelegate.removeModuleFiles(module, courseId); + }).catch((error) => { + if (error) { + this.domUtils.showErrorModal(error); + } }); } @@ -405,6 +411,39 @@ export class CoreCourseHelperProvider { }); } + /** + * 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 {any} instance The component instance that has the context menu. It should have prefetchStatusIcon and isDestroyed. + * @param {any} module Module to be prefetched + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + contextMenuPrefetch(instance: any, module: any, courseId: number): Promise { + const initialIcon = instance.prefetchStatusIcon; + let cancelled = false; + + instance.prefetchStatusIcon = 'spinner'; // Show spinner since this operation might take a while. + + // We need to call getDownloadSize, the package might have been updated. + return this.prefetchDelegate.getModuleDownloadSize(module, courseId, true).then((size) => { + return this.domUtils.confirmDownloadSize(size).catch(() => { + // User hasn't confirmed, stop. + cancelled = true; + + return Promise.reject(null); + }).then(() => { + return this.prefetchDelegate.prefetchModule(module, courseId, true); + }); + }).catch((error) => { + instance.prefetchStatusIcon = initialIcon; + if (!instance.isDestroyed && !cancelled) { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + } + }); + } + /** * Determine the status of a list of courses. * @@ -431,6 +470,41 @@ export class CoreCourseHelperProvider { }); } + /** + * Fill the Context Menu for a certain module. + * + * @param {any} instance The component instance that has the context menu. + * @param {any} module Module to be prefetched + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [invalidateCache] Invalidates the cache first. + * @param {string} [component] Component of the module. + * @return {Promise} Promise resolved when done. + */ + fillContextMenu(instance: any, module: any, courseId: number, invalidateCache?: boolean, component?: string): Promise { + return this.getModulePrefetchInfo(module, courseId, invalidateCache, component).then((moduleInfo) => { + instance.size = moduleInfo.size > 0 ? moduleInfo.sizeReadable : 0; + instance.prefetchStatusIcon = moduleInfo.statusIcon; + + if (moduleInfo.status != CoreConstants.NOT_DOWNLOADABLE) { + // Module is downloadable, get the text to display to prefetch. + if (moduleInfo.downloadTime > 0) { + instance.prefetchText = this.translate.instant('core.lastdownloaded') + ': ' + moduleInfo.downloadTimeReadable; + } else { + // Module not downloaded, show a default text. + instance.prefetchText = this.translate.instant('core.download'); + } + } + + if (typeof instance.statusObserver == 'undefined' && component) { + instance.statusObserver = this.eventsProvider.on(CoreEventsProvider.PACKAGE_STATUS_CHANGED, (data) => { + if (data.componentId == module.id && data.component == component) { + this.fillContextMenu(instance, module, courseId, false, component); + } + }, this.sitesProvider.getCurrentSiteId()); + } + }); + } + /** * Get a course download promise (if any). * diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts index de95a6287..a80e9c74f 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -225,6 +225,11 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { super('CoreCourseModulePrefetchDelegate', loggerProvider, sitesProvider, eventsProvider); this.sitesProvider.createTableFromSchema(this.checkUpdatesTableSchema); + + eventsProvider.on(CoreEventsProvider.LOGOUT, this.clearStatusCache.bind(this)); + eventsProvider.on(CoreEventsProvider.PACKAGE_STATUS_CHANGED, (data) => { + this.updateStatusCache(data.status, data.component, data.componentId); + }, this.sitesProvider.getCurrentSiteId()); } /** @@ -653,6 +658,8 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { promise; if (!refresh && typeof status != 'undefined') { + this.storeCourseAndSection(packageId, courseId, sectionId); + return Promise.resolve(this.determineModuleStatus(module, status, canCheck)); } @@ -664,7 +671,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { // Get the saved package status. return this.filepoolProvider.getPackageStatus(siteId, component, module.id).then((currentStatus) => { - status = handler.determineStatus ? handler.determineStatus(module, status, canCheck) : status; + status = handler.determineStatus ? handler.determineStatus(module, currentStatus, canCheck) : currentStatus; if (status != CoreConstants.DOWNLOADED) { return status; } @@ -696,7 +703,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { // Has updates, mark the module as outdated. status = CoreConstants.OUTDATED; - return this.filepoolProvider.storePackageStatus(siteId, component, module.id, status).catch(() => { + return this.filepoolProvider.storePackageStatus(siteId, status, component, module.id).catch(() => { // Ignore errors. }).then(() => { return status; @@ -710,13 +717,14 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { }, () => { // Error getting updates, show the stored status. updateStatus = false; + this.storeCourseAndSection(packageId, courseId, sectionId); return currentStatus; }); }); }).then((status) => { if (updateStatus) { - this.updateStatusCache(status, courseId, component, module.id, sectionId); + this.updateStatusCache(status, component, module.id, courseId, sectionId); } return this.determineModuleStatus(module, status, canCheck); @@ -770,11 +778,6 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { promises.push(this.getModuleStatus(module, courseId, updates, refresh).then((modStatus) => { if (modStatus != CoreConstants.NOT_DOWNLOADABLE) { - if (sectionId && sectionId > 0) { - // Store the section ID. - this.statusCache.setValue(packageId, 'sectionId', sectionId); - } - status = this.filepoolProvider.determinePackagesStatus(status, modStatus); result[modStatus].push(module); result.total++; @@ -1123,7 +1126,8 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { // Update status of the module. const packageId = this.filepoolProvider.getPackageId(handler.component, module.id); this.statusCache.setValue(packageId, 'downloadedSize', 0); - this.filepoolProvider.storePackageStatus(siteId, handler.component, module.id, CoreConstants.NOT_DOWNLOADED); + + return this.filepoolProvider.storePackageStatus(siteId, CoreConstants.NOT_DOWNLOADED, handler.component, module.id); } }); } @@ -1144,6 +1148,22 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { } } + /** + * If courseId or sectionId is set, save them in the cache. + * + * @param {string} packageId The package ID. + * @param {number} [courseId] Course ID. + * @param {number} [sectionId] Section ID. + */ + storeCourseAndSection(packageId: string, courseId?: number, sectionId?: number): void { + if (courseId) { + this.statusCache.setValue(packageId, 'courseId', courseId); + } + if (sectionId && sectionId > 0) { + this.statusCache.setValue(packageId, 'sectionId', sectionId); + } + } + /** * Treat the result of the check updates WS call. * @@ -1181,12 +1201,12 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { * Update the status of a module in the "cache". * * @param {string} status New status. - * @param {number} courseId Course ID of the module. * @param {string} component Package's component. * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {number} [courseId] Course ID of the module. * @param {number} [sectionId] Section ID of the module. */ - updateStatusCache(status: string, courseId: number, component: string, componentId?: string | number, sectionId?: number) + updateStatusCache(status: string, component: string, componentId?: string | number, courseId?: number, sectionId?: number) : void { const packageId = this.filepoolProvider.getPackageId(component, componentId), cachedStatus = this.statusCache.getValue(packageId, 'status', true); @@ -1195,7 +1215,13 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { // If the status has changed, notify that the section has changed. notify = typeof cachedStatus != 'undefined' && cachedStatus !== status; + // If courseId/sectionId is set, store it. + this.storeCourseAndSection(packageId, courseId, sectionId); + if (notify) { + if (!courseId) { + courseId = this.statusCache.getValue(packageId, 'courseId', true); + } if (!sectionId) { sectionId = this.statusCache.getValue(packageId, 'sectionId', true); } @@ -1205,8 +1231,6 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { this.statusCache.setValue(packageId, 'status', status); if (sectionId) { - this.statusCache.setValue(packageId, 'sectionId', sectionId); - this.eventsProvider.trigger(CoreEventsProvider.SECTION_STATUS_CHANGED, { sectionId: sectionId, courseId: courseId From 6af7e4195d6569f74b63927e2b2327fe7de461f4 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 2 Feb 2018 13:23:29 +0100 Subject: [PATCH 07/11] MOBILE-2335 contextmenu: Sort context menu items --- src/components/context-menu/context-menu.ts | 5 +++++ src/core/contentlinks/providers/delegate.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/context-menu/context-menu.ts b/src/components/context-menu/context-menu.ts index 89857016b..7c96cfcd6 100644 --- a/src/components/context-menu/context-menu.ts +++ b/src/components/context-menu/context-menu.ts @@ -43,6 +43,11 @@ export class CoreContextMenuComponent implements OnInit { this.hideMenu = !this.items.some((item) => { return !item.hidden; }); + + // Sort the items by priority. + this.items.sort((a, b) => { + return a.priority <= b.priority ? 1 : -1; + }); }); } diff --git a/src/core/contentlinks/providers/delegate.ts b/src/core/contentlinks/providers/delegate.ts index dc293023a..5aecc1f5a 100644 --- a/src/core/contentlinks/providers/delegate.ts +++ b/src/core/contentlinks/providers/delegate.ts @@ -301,7 +301,7 @@ export class CoreContentLinksDelegate { // Sort by priority. actions = actions.sort((a, b) => { - return a.priority >= b.priority ? 1 : -1; + return a.priority <= b.priority ? 1 : -1; }); // Fill result array. From 577a0a65988ba0e980f8a41f5b2d3bf1adb77b84 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 5 Feb 2018 12:31:48 +0100 Subject: [PATCH 08/11] MOBILE-2335 contextmenu: Merge menus in navbuttons component --- src/components/context-menu/context-menu.ts | 67 +++++++++++--- .../navbar-buttons/navbar-buttons.ts | 88 +++++++++++++++---- src/core/course/pages/section/section.html | 4 +- src/providers/utils/dom.ts | 52 +++++++++++ 4 files changed, 182 insertions(+), 29 deletions(-) diff --git a/src/components/context-menu/context-menu.ts b/src/components/context-menu/context-menu.ts index 7c96cfcd6..6ef697cc4 100644 --- a/src/components/context-menu/context-menu.ts +++ b/src/components/context-menu/context-menu.ts @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnInit, OnDestroy, ElementRef } from '@angular/core'; import { PopoverController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; +import { CoreDomUtilsProvider } from '../../providers/utils/dom'; import { CoreContextMenuItemComponent } from './context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu-popover'; import { Subject } from 'rxjs'; @@ -26,7 +27,7 @@ import { Subject } from 'rxjs'; selector: 'core-context-menu', templateUrl: 'context-menu.html' }) -export class CoreContextMenuComponent implements OnInit { +export class CoreContextMenuComponent implements OnInit, OnDestroy { @Input() icon?: string; // Icon to be shown on the navigation bar. Default: Kebab menu icon. @Input() title?: string; // Aria label and text to be shown on the top of the popover. @@ -34,8 +35,11 @@ export class CoreContextMenuComponent implements OnInit { ariaLabel: string; protected items: CoreContextMenuItemComponent[] = []; protected itemsChangedStream: Subject; // Stream to update the hideMenu boolean when items change. + protected instanceId: string; + protected parentContextMenu: CoreContextMenuComponent; - constructor(private translate: TranslateService, private popoverCtrl: PopoverController) { + constructor(private translate: TranslateService, private popoverCtrl: PopoverController, private elementRef: ElementRef, + private domUtils: CoreDomUtilsProvider) { // Create the stream and subscribe to it. We ignore successive changes during 250ms. this.itemsChangedStream = new Subject(); this.itemsChangedStream.auditTime(250).subscribe(() => { @@ -49,6 +53,8 @@ export class CoreContextMenuComponent implements OnInit { return a.priority <= b.priority ? 1 : -1; }); }); + + this.instanceId = this.domUtils.storeInstanceByElement(elementRef.nativeElement, this); } /** @@ -65,15 +71,44 @@ export class CoreContextMenuComponent implements OnInit { * @param {CoreContextMenuItemComponent} item The item to add. */ addItem(item: CoreContextMenuItemComponent): void { - this.items.push(item); - this.itemsChanged(); + if (this.parentContextMenu) { + // All items were moved to the "parent" menu. Add the item in there. + this.parentContextMenu.addItem(item); + } else { + this.items.push(item); + this.itemsChanged(); + } } /** * Function called when the items change. */ itemsChanged(): void { - this.itemsChangedStream.next(); + 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 {CoreContextMenuComponent} 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++) { + contextMenu.addItem(this.items[i]); + } + + // Remove all items from the current menu. + this.items = []; + this.itemsChanged(); } /** @@ -82,11 +117,16 @@ export class CoreContextMenuComponent implements OnInit { * @param {CoreContextMenuItemComponent} item The item to remove. */ removeItem(item: CoreContextMenuItemComponent): void { - const index = this.items.indexOf(item); - if (index >= 0) { - this.items.splice(index, 1); + if (this.parentContextMenu) { + // All items were moved to the "parent" menu. Remove the item from there. + this.parentContextMenu.removeItem(item); + } else { + const index = this.items.indexOf(item); + if (index >= 0) { + this.items.splice(index, 1); + } + this.itemsChanged(); } - this.itemsChanged(); } /** @@ -100,4 +140,11 @@ export class CoreContextMenuComponent implements OnInit { ev: event }); } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.domUtils.removeInstanceById(this.instanceId); + } } diff --git a/src/components/navbar-buttons/navbar-buttons.ts b/src/components/navbar-buttons/navbar-buttons.ts index 98edb3421..3bdb50e26 100644 --- a/src/components/navbar-buttons/navbar-buttons.ts +++ b/src/components/navbar-buttons/navbar-buttons.ts @@ -14,6 +14,7 @@ import { Component, Input, OnInit, ContentChildren, ElementRef, QueryList } from '@angular/core'; import { Button } from 'ionic-angular'; +import { CoreLoggerProvider } from '../../providers/logger'; import { CoreDomUtilsProvider } from '../../providers/utils/dom'; /** @@ -63,41 +64,80 @@ export class CoreNavBarButtonsComponent implements OnInit { protected element: HTMLElement; protected _buttons: QueryList + + + + + + - + +
diff --git a/src/core/course/components/module/module.ts b/src/core/course/components/module/module.ts index b450093b1..15a66286b 100644 --- a/src/core/course/components/module/module.ts +++ b/src/core/course/components/module/module.ts @@ -12,9 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core'; import { NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCourseHelperProvider } from '../../providers/helper'; import { CoreCourseModuleHandlerButton } from '../../providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler } from '../../providers/module-prefetch-delegate'; +import { CoreConstants } from '../../../constants'; /** * Component to display a module entry in a list of modules. @@ -27,12 +33,43 @@ import { CoreCourseModuleHandlerButton } from '../../providers/module-delegate'; selector: 'core-course-module', templateUrl: 'module.html' }) -export class CoreCourseModuleComponent implements OnInit { +export class CoreCourseModuleComponent implements OnInit, OnDestroy { @Input() module: any; // The module to render. @Input() courseId: number; // The course the module belongs to. + @Input('downloadEnabled') set enabled(value: boolean) { + this.downloadEnabled = value; + + if (this.module.handlerData.showDownloadButton && this.downloadEnabled && !this.statusObserver) { + // First time that the download is enabled. Initialize the data. + this.spinner = true; // Show spinner while calculating the status. + + this.prefetchHandler = this.prefetchDelegate.getPrefetchHandlerFor(this.module); + + // Get current status to decide which icon should be shown. + this.prefetchDelegate.getModuleStatus(this.module, this.courseId).then(this.showStatus.bind(this)); + + // Listen for changes on this module status. + this.statusObserver = this.eventsProvider.on(CoreEventsProvider.PACKAGE_STATUS_CHANGED, (data) => { + if (data.componentId === this.module.id && this.prefetchHandler && + data.component === this.prefetchHandler.component) { + this.showStatus(data.status); + } + }, this.sitesProvider.getCurrentSiteId()); + } + } @Output() completionChanged?: EventEmitter; // Will emit an event when the module completion changes. - constructor(private navCtrl: NavController) { + showDownload: boolean; // Whether to display the download button. + showRefresh: boolean; // Whether to display the refresh button. + spinner: boolean; // Whether to display a spinner. + downloadEnabled: boolean; // Whether the download of sections and modules is enabled. + + protected prefetchHandler: CoreCourseModulePrefetchHandler; + protected statusObserver; + + constructor(protected navCtrl: NavController, protected prefetchDelegate: CoreCourseModulePrefetchDelegate, + protected domUtils: CoreDomUtilsProvider, protected courseHelper: CoreCourseHelperProvider, + protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider) { this.completionChanged = new EventEmitter(); } @@ -68,4 +105,55 @@ export class CoreCourseModuleComponent implements OnInit { button.action(event, this.navCtrl, this.module, this.courseId); } } + + /** + * Download the module. + * + * @param {Event} event Click event. + * @param {boolean} refresh Whether it's refreshing. + */ + download(event: Event, refresh: boolean): void { + event.preventDefault(); + event.stopPropagation(); + + if (!this.prefetchHandler) { + return; + } + + // Show spinner since this operation might take a while. + this.spinner = true; + + // Get download size to ask for confirm if it's high. + this.prefetchHandler.getDownloadSize(module, this.courseId).then((size) => { + this.courseHelper.prefetchModule(this.prefetchHandler, this.module, size, this.courseId, refresh).catch((error) => { + // Error or cancelled. + this.spinner = false; + }); + }).catch((error) => { + // Error getting download size, hide spinner. + this.spinner = false; + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + }); + } + + /** + * Show download buttons according to module status. + * + * @param {string} status Module status. + */ + protected showStatus(status: string): void { + if (status) { + this.spinner = status === CoreConstants.DOWNLOADING; + this.showDownload = status === CoreConstants.NOT_DOWNLOADED; + this.showRefresh = status === CoreConstants.OUTDATED || + (!this.prefetchDelegate.canCheckUpdates() && status === CoreConstants.DOWNLOADED); + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.statusObserver && this.statusObserver.off(); + } } diff --git a/src/core/course/providers/module-delegate.ts b/src/core/course/providers/module-delegate.ts index 1fbc7e2be..1a2e8c057 100644 --- a/src/core/course/providers/module-delegate.ts +++ b/src/core/course/providers/module-delegate.ts @@ -68,6 +68,14 @@ export interface CoreCourseModuleHandlerData { */ class?: string; + /** + * Whether to display a button to download/refresh the module if it's downloadable. + * If it's set to true, the app will show a download/refresh button when needed and will handle the download of the + * module using CoreCourseModulePrefetchDelegate. + * @type {boolean} + */ + showDownloadButton?: boolean; + /** * The buttons to display in the module item. * @type {CoreCourseModuleHandlerButton[]} From 15edcbb94ffc208f11d330dbb0bac29a40bd91c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 8 Feb 2018 16:26:37 +0100 Subject: [PATCH 11/11] MOBILE-2335 course: Add styles to module handler --- src/addon/mod/label/label.scss | 3 + src/core/course/components/format/format.html | 8 +- src/core/course/components/format/format.scss | 6 + src/core/course/components/module/module.html | 53 ++++---- src/core/course/components/module/module.scss | 117 +++++++++++++++--- 5 files changed, 143 insertions(+), 44 deletions(-) create mode 100644 src/addon/mod/label/label.scss create mode 100644 src/core/course/components/format/format.scss diff --git a/src/addon/mod/label/label.scss b/src/addon/mod/label/label.scss new file mode 100644 index 000000000..aa867a8f1 --- /dev/null +++ b/src/addon/mod/label/label.scss @@ -0,0 +1,3 @@ +a.core-course-module-handler.addon-mod-label-handler { + align-items: center; +} diff --git a/src/core/course/components/format/format.html b/src/core/course/components/format/format.html index 80df9237c..4c8d1d43a 100644 --- a/src/core/course/components/format/format.html +++ b/src/core/course/components/format/format.html @@ -12,7 +12,7 @@ -
+
{{section.formattedName || section.name}} @@ -63,7 +63,7 @@ -
+
- - {{section.count}} / {{section.total}} + +
diff --git a/src/core/course/components/format/format.scss b/src/core/course/components/format/format.scss new file mode 100644 index 000000000..11fd5d99a --- /dev/null +++ b/src/core/course/components/format/format.scss @@ -0,0 +1,6 @@ +ion-badge.core-course-download-section-progress { + display: block; + float: left; + margin-top: 12px; + margin-right: 12px; +} \ No newline at end of file diff --git a/src/core/course/components/module/module.html b/src/core/course/components/module/module.html index d4d63e0e7..84cd92959 100644 --- a/src/core/course/components/module/module.html +++ b/src/core/course/components/module/module.html @@ -1,36 +1,39 @@ +
+ - + +
+ + - -
- - +
+ + - - + + - - + + - - + + +
+
- - +
+ {{ 'core.course.hiddenfromstudents' | translate }} + +
- -
- {{ 'core.course.hiddenfromstudents' | translate }} - -
- +
\ No newline at end of file diff --git a/src/core/course/components/module/module.scss b/src/core/course/components/module/module.scss index 8eeee189e..c76d9e6f1 100644 --- a/src/core/course/components/module/module.scss +++ b/src/core/course/components/module/module.scss @@ -2,29 +2,116 @@ core-course-module { a.core-course-module-handler { align-items: flex-start; - item-inner { + min-height: 52px; + + &.item .item-inner { padding-right: 0; } + .label { + margin-top: 0; + margin-right: 0; + margin-bottom: 0; + } + .core-module-icon { + align-items: flex-start; + } } - .core-module-icon { - align-items: flex-start; - } - - .core-module-buttons { + .core-module-title { display: flex; flex-flow: row; - align-items: center; - z-index: 1; - cursor: pointer; - pointer-events: auto; - position: absolute; - right: 0; - top: 4px; + align-items: flex-start; - .spinner { - right: 7px; + core-format-text { + flex-grow: 2; + } + .core-module-buttons { + margin: 0; + } + + .core-module-buttons, + .core-module-buttons-more { + display: flex; + flex-flow: row; + align-items: center; + z-index: 1; + } + + .core-module-buttons core-course-module-completion, + .core-module-buttons-more button { + cursor: pointer; + pointer-events: auto; + } + + .core-module-buttons-more .spinner { + right: 13px; position: absolute; } } +} + +.md core-course-module { + .core-module-description, + .core-module-description .core-show-more { + padding-right: $label-md-margin-end; + } + + a.core-course-module-handler .core-module-icon { + margin-top: $label-md-margin-top; + margin-bottom: $label-md-margin-bottom; + } + + .core-module-title core-format-text { + padding-top: $label-md-margin-top + 3; + } + .button-md { + margin-top: 8px; + margin-bottom: 8px; + } + .core-module-buttons-more { + min-height: 52px; + min-width: 53px; + } +} + +.ios core-course-module { + .core-module-description, + .core-module-description .core-show-more { + padding-right: $label-ios-margin-end; + } + + a.core-course-module-handler .core-module-icon { + margin-top: $label-ios-margin-top; + margin-bottom: $label-ios-margin-bottom; + } + + .core-module-title core-format-text { + padding-top: $label-ios-margin-top + 3; + } + + .core-module-buttons-more { + min-height: 53px; + min-width: 58px; + } +} + +.wp core-course-module { + .core-module-description, + .core-module-description .core-show-more { + padding-right: ($item-wp-padding-end / 2); + } + + a.core-course-module-handler .core-module-icon { + margin-top: $item-wp-padding-top; + margin-bottom: $item-wp-padding-bottom; + } + + .core-module-title core-format-text { + padding-top: $item-wp-padding-top + 3; + } + + .button-wp { + margin-top: 8px; + margin-bottom: 8px; + } } \ No newline at end of file