From 4e7c9eb6e80f7e6ae6dc7c68267f5daa6b86723d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 29 Mar 2021 16:06:33 +0200 Subject: [PATCH 1/9] MOBILE-3639 choice: Implement services --- src/addons/mod/choice/choice.module.ts | 61 ++ .../mod/choice/services/choice-offline.ts | 150 +++++ src/addons/mod/choice/services/choice-sync.ts | 236 +++++++ src/addons/mod/choice/services/choice.ts | 603 ++++++++++++++++++ .../mod/choice/services/database/choice.ts | 73 +++ .../choice/services/handlers/index-link.ts | 33 + .../mod/choice/services/handlers/list-link.ts | 33 + .../mod/choice/services/handlers/module.ts | 83 +++ .../mod/choice/services/handlers/prefetch.ts | 157 +++++ .../mod/choice/services/handlers/sync-cron.ts | 51 ++ src/addons/mod/mod.module.ts | 2 + src/core/features/compile/services/compile.ts | 4 +- 12 files changed, 1484 insertions(+), 2 deletions(-) create mode 100644 src/addons/mod/choice/choice.module.ts create mode 100644 src/addons/mod/choice/services/choice-offline.ts create mode 100644 src/addons/mod/choice/services/choice-sync.ts create mode 100644 src/addons/mod/choice/services/choice.ts create mode 100644 src/addons/mod/choice/services/database/choice.ts create mode 100644 src/addons/mod/choice/services/handlers/index-link.ts create mode 100644 src/addons/mod/choice/services/handlers/list-link.ts create mode 100644 src/addons/mod/choice/services/handlers/module.ts create mode 100644 src/addons/mod/choice/services/handlers/prefetch.ts create mode 100644 src/addons/mod/choice/services/handlers/sync-cron.ts diff --git a/src/addons/mod/choice/choice.module.ts b/src/addons/mod/choice/choice.module.ts new file mode 100644 index 000000000..fd2ca332a --- /dev/null +++ b/src/addons/mod/choice/choice.module.ts @@ -0,0 +1,61 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule, Type } from '@angular/core'; + +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModChoiceProvider } from './services/choice'; +import { AddonModChoiceOfflineProvider } from './services/choice-offline'; +import { AddonModChoiceSyncProvider } from './services/choice-sync'; +import { OFFLINE_SITE_SCHEMA } from './services/database/choice'; +import { AddonModChoiceIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModChoiceListLinkHandler } from './services/handlers/list-link'; +import { AddonModChoiceModuleHandler } from './services/handlers/module'; +import { AddonModChoicePrefetchHandler } from './services/handlers/prefetch'; +import { AddonModChoiceSyncCronHandler } from './services/handlers/sync-cron'; + +export const ADDON_MOD_CHOICE_SERVICES: Type[] = [ + AddonModChoiceProvider, + AddonModChoiceOfflineProvider, + AddonModChoiceSyncProvider, +]; + +@NgModule({ + imports: [ + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.registerHandler(AddonModChoiceModuleHandler.instance); + CoreCourseModulePrefetchDelegate.registerHandler(AddonModChoicePrefetchHandler.instance); + CoreCronDelegate.register(AddonModChoiceSyncCronHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModChoiceIndexLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModChoiceListLinkHandler.instance); + }, + }, + ], +}) +export class AddonModChoiceModule {} diff --git a/src/addons/mod/choice/services/choice-offline.ts b/src/addons/mod/choice/services/choice-offline.ts new file mode 100644 index 000000000..9d7738935 --- /dev/null +++ b/src/addons/mod/choice/services/choice-offline.ts @@ -0,0 +1,150 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { makeSingleton } from '@singletons'; +import { AddonModChoiceResponsesDBRecord, RESPONSES_TABLE_NAME } from './database/choice'; + +/** + * Service to handle offline choices. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModChoiceOfflineProvider { + + /** + * Delete a response. + * + * @param choiceId Choice ID to remove. + * @param siteId Site ID. If not defined, current site. + * @param userId User the responses belong to. If not defined, current user in site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteResponse(choiceId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + await site.getDb().deleteRecords(RESPONSES_TABLE_NAME, { choiceid: choiceId, userid: userId }); + } + + /** + * Get all offline responses. + * + * @param siteId Site ID. If not defined, current site. + * @return Promi[se resolved with responses. + */ + async getResponses(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const records = await site.getDb().getRecords(RESPONSES_TABLE_NAME); + + return records.map((record) => this.parseResponse(record)); + } + + /** + * Check if there are offline responses to send. + * + * @param choiceId Choice ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User the responses belong to. If not defined, current user in site. + * @return Promise resolved with boolean: true if has offline answers, false otherwise. + */ + async hasResponse(choiceId: number, siteId?: string, userId?: number): Promise { + try { + const response = await this.getResponse(choiceId, siteId, userId); + + return !!response.choiceid; + } catch (error) { + // No offline data found, return false. + return false; + } + } + + /** + * Get response to be synced. + * + * @param choiceId Choice ID to get. + * @param siteId Site ID. If not defined, current site. + * @param userId User the responses belong to. If not defined, current user in site. + * @return Promise resolved with the object to be synced. + */ + async getResponse(choiceId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + const record = await site.getDb().getRecord(RESPONSES_TABLE_NAME, { + choiceid: choiceId, + userid: userId, + }); + + return this.parseResponse(record); + } + + /** + * Parse responses. + * + * @param entry Entry to parse. + * @return Parsed entry. + */ + protected parseResponse(entry: AddonModChoiceResponsesDBRecord): AddonModChoiceOfflineResponses { + return { + ...entry, + responses: CoreTextUtils.parseJSON(entry.responses, []), + }; + } + + /** + * Offline version for sending a response to a choice to Moodle. + * + * @param choiceId Choice ID. + * @param name Choice name. + * @param courseId Course ID the choice belongs to. + * @param responses IDs of selected options. + * @param deleting If true, the user is deleting responses, if false, submitting. + * @param siteId Site ID. If not defined, current site. + * @param userId User the responses belong to. If not defined, current user in site. + * @return Promise resolved when results are successfully submitted. + */ + async saveResponse( + choiceId: number, + name: string, + courseId: number, + responses: number[], + deleting: boolean, + siteId?: string, + userId?: number, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const entry: AddonModChoiceResponsesDBRecord = { + choiceid: choiceId, + name: name, + courseid: courseId, + userid: userId || site.getUserId(), + responses: JSON.stringify(responses), + deleting: deleting ? 1 : 0, + timecreated: Date.now(), + }; + + await site.getDb().insertRecord(RESPONSES_TABLE_NAME, entry); + } + +} + +export const AddonModChoiceOffline = makeSingleton(AddonModChoiceOfflineProvider); + +export type AddonModChoiceOfflineResponses = Omit & { + responses: number[]; +}; diff --git a/src/addons/mod/choice/services/choice-sync.ts b/src/addons/mod/choice/services/choice-sync.ts new file mode 100644 index 000000000..d933c29c2 --- /dev/null +++ b/src/addons/mod/choice/services/choice-sync.ts @@ -0,0 +1,236 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModChoice, AddonModChoiceProvider } from './choice'; +import { AddonModChoiceOffline } from './choice-offline'; +import { AddonModChoicePrefetchHandler } from './handlers/prefetch'; + +/** + * Service to sync choices. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModChoiceSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_choice_autom_synced'; + + protected componentTranslatableString = 'choice'; + + constructor() { + super('AddonModChoiceSyncProvider'); + } + + /** + * Get the ID of a choice sync. + * + * @param choiceId Choice ID. + * @param userId User the responses belong to. + * @return Sync ID. + */ + protected getSyncId(choiceId: number, userId: number): string { + return choiceId + '#' + userId; + } + + /** + * Try to synchronize all the choices in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllChoices(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all choices', this.syncAllChoicesFunc.bind(this, !!force), siteId); + } + + /** + * Sync all pending choices on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllChoicesFunc(force: boolean, siteId: string): Promise { + const responses = await AddonModChoiceOffline.getResponses(siteId); + + // Sync all responses. + await Promise.all(responses.map(async (response) => { + const result = force ? + await this.syncChoice(response.choiceid, response.userid, siteId) : + await this.syncChoiceIfNeeded(response.choiceid, response.userid, siteId); + + if (result?.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonModChoiceSyncProvider.AUTO_SYNCED, { + choiceId: response.choiceid, + userId: response.userid, + warnings: result.warnings, + }, siteId); + } + })); + } + + /** + * Sync an choice only if a certain time has passed since the last time. + * + * @param choiceId Choice ID to be synced. + * @param userId User the answers belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the choice is synced or it doesn't need to be synced. + */ + async syncChoiceIfNeeded(choiceId: number, userId: number, siteId?: string): Promise { + const syncId = this.getSyncId(choiceId, userId); + + const needed = await this.isSyncNeeded(syncId, siteId); + + if (needed) { + return this.syncChoice(choiceId, userId, siteId); + } + } + + /** + * Synchronize a choice. + * + * @param choiceId Choice ID to be synced. + * @param userId User the answers belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncChoice(choiceId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + userId = userId || site.getUserId(); + siteId = site.getId(); + + const syncId = this.getSyncId(choiceId, userId); + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(syncId, siteId)!; + } + + this.logger.debug(`Try to sync choice '${choiceId}' for user '${userId}'`); + + return this.addOngoingSync(syncId, this.performSync(choiceId, userId, siteId), siteId); + } + + /** + * Synchronize a choice. + * + * @param choiceId Choice ID to be synced. + * @param userId User the answers belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async performSync(choiceId: number, userId: number, siteId?: string): Promise { + const syncId = this.getSyncId(choiceId, userId); + const result: AddonModChoiceSyncResult = { + warnings: [], + updated: false, + }; + + // Sync offline logs. + await CoreUtils.ignoreErrors(CoreCourseLogHelper.syncActivity(AddonModChoiceProvider.COMPONENT, choiceId, siteId)); + + const data = await CoreUtils.ignoreErrors(AddonModChoiceOffline.getResponse(choiceId, siteId, userId)); + + if (!data || !data.choiceid) { + // Nothing to sync. Set sync time. + await this.setSyncTime(syncId, siteId); + + return result; + } + + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + const courseId = data.courseid; + + try { + // Send the responses. + if (data.deleting) { + // A user has deleted some responses. + await AddonModChoice.deleteResponsesOnline(choiceId, data.responses, siteId); + } else { + // A user has added some responses. + await AddonModChoice.submitResponseOnline(choiceId, data.responses, siteId); + } + + result.updated = true; + + await AddonModChoiceOffline.deleteResponse(choiceId, siteId, userId); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, reject. + throw error; + } + + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + await AddonModChoiceOffline.deleteResponse(choiceId, siteId, userId); + + // Responses deleted, add a warning. + result.warnings.push(Translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.name, + error: CoreTextUtils.getErrorMessageFromError(error), + })); + } + + // Data has been sent to server, prefetch choice if needed. + try { + const module = await CoreCourse.getModuleBasicInfoByInstance(choiceId, 'choice', siteId); + + await this.prefetchAfterUpdate(AddonModChoicePrefetchHandler.instance, module, courseId, undefined, siteId); + } catch { + // Ignore errors. + } + + // Sync finished, set sync time. + await this.setSyncTime(syncId, siteId); + + return result; + } + +} + +export const AddonModChoiceSync = makeSingleton(AddonModChoiceSyncProvider); + +/** + * Data returned by a choice sync. + */ +export type AddonModChoiceSyncResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether some data was sent to the server or offline data was updated. +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModChoiceAutoSyncData = { + choiceId: number; + userId: number; + warnings: string[]; +}; diff --git a/src/addons/mod/choice/services/choice.ts b/src/addons/mod/choice/services/choice.ts new file mode 100644 index 000000000..97570f95b --- /dev/null +++ b/src/addons/mod/choice/services/choice.ts @@ -0,0 +1,603 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreWSError } from '@classes/errors/wserror'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModChoiceOffline } from './choice-offline'; +import { AddonModChoiceAutoSyncData, AddonModChoiceSyncProvider } from './choice-sync'; + +const ROOT_CACHE_KEY = 'mmaModChoice:'; + +/** + * Service that provides some features for choices. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModChoiceProvider { + + static readonly COMPONENT = 'mmaModChoice'; + + static readonly RESULTS_NOT = 0; + static readonly RESULTS_AFTER_ANSWER = 1; + static readonly RESULTS_AFTER_CLOSE = 2; + static readonly RESULTS_ALWAYS = 3; + + static readonly PUBLISH_ANONYMOUS = false; + static readonly PUBLISH_NAMES = true; + + /** + * Check if results can be seen by a student. The student can see the results if: + * - they're always published, OR + * - they're published after the choice is closed and it's closed, OR + * - they're published after answering and the user has answered. + * + * @param choice Choice to check. + * @param hasAnswered True if user has answered the choice, false otherwise. + * @return True if the students can see the results. + */ + canStudentSeeResults(choice: AddonModChoiceChoice, hasAnswered: boolean): boolean { + const now = Date.now(); + + return choice.showresults === AddonModChoiceProvider.RESULTS_ALWAYS || + choice.showresults === AddonModChoiceProvider.RESULTS_AFTER_CLOSE && choice.timeclose && choice.timeclose <= now || + choice.showresults === AddonModChoiceProvider.RESULTS_AFTER_ANSWER && hasAnswered; + } + + /** + * Delete responses from a choice. + * + * @param choiceId Choice ID. + * @param name Choice name. + * @param courseId Course ID the choice belongs to. + * @param responses IDs of the answers. If not defined, delete all the answers of the current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if response was sent to server, false if stored in device. + */ + async deleteResponses( + choiceId: number, + name: string, + courseId: number, + responses?: number[], + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + responses = responses || []; + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModChoiceOffline.saveResponse(choiceId, name, courseId, responses!, true, siteId); + + return false; + }; + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a response to be sent to the server, discard it first. + await AddonModChoiceOffline.deleteResponse(choiceId, siteId); + + try { + await this.deleteResponsesOnline(choiceId, responses, siteId); + + return true; + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + } + } + + /** + * Delete responses from a choice. It will fail if offline or cannot connect. + * + * @param choiceId Choice ID. + * @param responses IDs of the answers. If not defined, delete all the answers of the current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when responses are successfully deleted. + */ + async deleteResponsesOnline(choiceId: number, responses?: number[], siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModChoiceDeleteChoiceResponsesWSParams = { + choiceid: choiceId, + responses: responses, + }; + + const response = await site.write('mod_choice_delete_choice_responses', params); + + // Other errors ocurring. + if (response.status === false) { + if (response.warnings?.[0]) { + throw new CoreWSError(response.warnings[0]); + } + + throw new CoreError('Cannot delete responses.'); + } + + // Invalidate related data. + await CoreUtils.ignoreErrors(Promise.all([ + this.invalidateOptions(choiceId, site.id), + this.invalidateResults(choiceId, site.id), + ])); + } + + /** + * Get cache key for choice data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getChoiceDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'choice:' + courseId; + } + + /** + * Get cache key for choice options WS calls. + * + * @param choiceId Choice ID. + * @return Cache key. + */ + protected getChoiceOptionsCacheKey(choiceId: number): string { + return ROOT_CACHE_KEY + 'options:' + choiceId; + } + + /** + * Get cache key for choice results WS calls. + * + * @param choiceId Choice ID. + * @return Cache key. + */ + protected getChoiceResultsCacheKey(choiceId: number): string { + return ROOT_CACHE_KEY + 'results:' + choiceId; + } + + /** + * Get a choice with key=value. If more than one is found, only the first will be returned. + * + * @param courseId Course ID. + * @param key Name of the property to check. + * @param value Value to search. + * @param options Other options. + * @return Promise resolved when the choice is retrieved. + */ + protected async getChoiceByDataKey( + courseId: number, + key: string, + value: unknown, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModChoiceGetChoicesByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getChoiceDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModChoiceProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_choice_get_choices_by_courses', + params, + preSets, + ); + + const currentChoice = response.choices.find((choice) => choice[key] == value); + if (currentChoice) { + return currentChoice; + } + + throw new CoreError('Choice not found.'); + } + + /** + * Get a choice by course module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the choice is retrieved. + */ + getChoice(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getChoiceByDataKey(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a choice by ID. + * + * @param courseId Course ID. + * @param choiceId Choice ID. + * @param options Other options. + * @return Promise resolved when the choice is retrieved. + */ + getChoiceById(courseId: number, choiceId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getChoiceByDataKey(courseId, 'id', choiceId, options); + } + + /** + * Get choice options. + * + * @param choiceId Choice ID. + * @param options Other options. + * @return Promise resolved with choice options. + */ + async getOptions(choiceId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModChoiceGetChoiceOptionsWSParams = { + choiceid: choiceId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getChoiceOptionsCacheKey(choiceId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModChoiceProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_choice_get_choice_options', + params, + preSets, + ); + + return response.options; + } + + /** + * Get choice results. + * + * @param choiceId Choice ID. + * @param options Other options. + * @return Promise resolved with choice results. + */ + async getResults(choiceId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModChoiceGetChoiceResultsWSParams = { + choiceid: choiceId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getChoiceOptionsCacheKey(choiceId), + component: AddonModChoiceProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_choice_get_choice_results', + params, + preSets, + ); + + return response.options; + } + + /** + * Invalidate choice data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateChoiceData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getChoiceDataCacheKey(courseId)); + } + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const choice = await this.getChoice(courseId, moduleId); + + await Promise.all([ + this.invalidateChoiceData(courseId), + this.invalidateOptions(choice.id), + this.invalidateResults(choice.id), + CoreFilepool.invalidateFilesByComponent(siteId, AddonModChoiceProvider.COMPONENT, moduleId), + ]); + } + + /** + * Invalidate choice options. + * + * @param choiceId Choice ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateOptions(choiceId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getChoiceOptionsCacheKey(choiceId)); + } + + /** + * Invalidate choice results. + * + * @param choiceId Choice ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateResults(choiceId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getChoiceResultsCacheKey(choiceId)); + } + + /** + * Report the choice as being viewed. + * + * @param id Choice ID. + * @param name Name of the choice. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logView(id: number, name?: string, siteId?: string): Promise { + const params: AddonModChoiceViewChoiceWSParams = { + choiceid: id, + }; + + return CoreCourseLogHelper.logSingle( + 'mod_choice_view_choice', + params, + AddonModChoiceProvider.COMPONENT, + id, + name, + 'choice', + {}, + siteId, + ); + } + + /** + * Send a response to a choice to Moodle. + * + * @param choiceId Choice ID. + * @param name Choice name. + * @param courseId Course ID the choice belongs to. + * @param responses IDs of selected options. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if response was sent to server, false if stored in device. + */ + async submitResponse(choiceId: number, name: string, courseId: number, responses: number[], siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModChoiceOffline.saveResponse(choiceId, name, courseId, responses, false, siteId); + + return false; + }; + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a response to be sent to the server, discard it first. + await AddonModChoiceOffline.deleteResponse(choiceId, siteId); + + try { + await this.submitResponseOnline(choiceId, responses, siteId); + + return true; + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } + + // Couldn't connect to server, store it offline. + return storeOffline(); + } + } + + /** + * Send a response to a choice to Moodle. It will fail if offline or cannot connect. + * + * @param choiceId Choice ID. + * @param responses IDs of selected options. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when responses are successfully submitted. + */ + async submitResponseOnline(choiceId: number, responses: number[], siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModChoiceSubmitChoiceResponseWSParams = { + choiceid: choiceId, + responses: responses, + }; + + await site.write('mod_choice_submit_choice_response', params); + + // Invalidate related data. + await CoreUtils.ignoreErrors(Promise.all([ + this.invalidateOptions(choiceId, siteId), + this.invalidateResults(choiceId, siteId), + ])); + } + +} + +export const AddonModChoice = makeSingleton(AddonModChoiceProvider); + +/** + * Params of mod_choice_get_choices_by_courses WS. + */ +export type AddonModChoiceGetChoicesByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_choice_get_choices_by_courses WS. + */ +export type AddonModChoiceGetChoicesByCoursesWSResponse = { + choices: AddonModChoiceChoice[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Choice returned by mod_choice_get_choices_by_courses. + */ +export type AddonModChoiceChoice = { + id: number; // Choice instance id. + coursemodule: number; // Course module id. + course: number; // Course id. + name: string; // Choice name. + intro: string; // The choice intro. + introformat: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + introfiles?: CoreWSExternalFile[]; // @since 3.2. + publish?: boolean; // If choice is published. + showresults?: number; // 0 never, 1 after answer, 2 after close, 3 always. + display?: number; // Display mode (vertical, horizontal). + allowupdate?: boolean; // Allow update. + allowmultiple?: boolean; // Allow multiple choices. + showunanswered?: boolean; // Show users who not answered yet. + includeinactive?: boolean; // Include inactive users. + limitanswers?: boolean; // Limit unswers. + timeopen?: number; // Date of opening validity. + timeclose?: number; // Date of closing validity. + showpreview?: boolean; // Show preview before timeopen. + timemodified?: number; // Time of last modification. + completionsubmit?: boolean; // Completion on user submission. + showavailable?: boolean; // Show available spaces. @since 3.10 + section?: number; // Course section id. + visible?: boolean; // Visible. + groupmode?: number; // Group mode. + groupingid?: number; // Group id. +}; + +/** + * Params of mod_choice_delete_choice_responses WS. + */ +export type AddonModChoiceDeleteChoiceResponsesWSParams = { + choiceid: number; // Choice instance id. + responses?: number[]; // Array of response ids, empty for deleting all the current user responses. +}; + +/** + * Params of mod_choice_get_choice_options WS. + */ +export type AddonModChoiceGetChoiceOptionsWSParams = { + choiceid: number; // Choice instance id. +}; + +/** + * Data returned by mod_choice_get_choice_options WS. + */ +export type AddonModChoiceGetChoiceOptionsWSResponse = { + options: AddonModChoiceOption[]; // Options. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Option returned by mod_choice_get_choice_options. + */ +export type AddonModChoiceOption = { + id: number; // Option id. + text: string; // Text of the choice. + maxanswers: number; // Maximum number of answers. + displaylayout: boolean; // True for orizontal, otherwise vertical. + countanswers: number; // Number of answers. + checked: boolean; // We already answered. + disabled: boolean; // Option disabled. +}; + +/** + * Params of mod_choice_get_choice_results WS. + */ +export type AddonModChoiceGetChoiceResultsWSParams = { + choiceid: number; // Choice instance id. +}; + +/** + * Data returned by mod_choice_get_choice_results WS. + */ +export type AddonModChoiceGetChoiceResultsWSResponse = { + options: AddonModChoiceResult[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result returned by mod_choice_get_choice_results. + */ +export type AddonModChoiceResult = { + id: number; // Choice instance id. + text: string; // Text of the choice. + maxanswer: number; // Maximum number of answers. + userresponses: { + userid: number; // User id. + fullname: string; // User full name. + profileimageurl: string; // Profile user image url. + answerid?: number; // Answer id. + timemodified?: number; // Time of modification. + }[]; + numberofuser: number; // Number of users answers. + percentageamount: number; // Percentage of users answers. +}; + +/** + * Params of mod_choice_view_choice WS. + */ +export type AddonModChoiceViewChoiceWSParams = { + choiceid: number; // Choice instance id. +}; + +/** + * Params of mod_choice_submit_choice_response WS. + */ +export type AddonModChoiceSubmitChoiceResponseWSParams = { + choiceid: number; // Choice instance id. + responses: number[]; // Array of response ids. +}; + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [AddonModChoiceSyncProvider.AUTO_SYNCED]: AddonModChoiceAutoSyncData; + } + +} diff --git a/src/addons/mod/choice/services/database/choice.ts b/src/addons/mod/choice/services/database/choice.ts new file mode 100644 index 000000000..973c5a6b6 --- /dev/null +++ b/src/addons/mod/choice/services/database/choice.ts @@ -0,0 +1,73 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for AddonModChoiceOfflineProvider. + */ +export const RESPONSES_TABLE_NAME = 'addon_mod_choice_responses'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModChoiceOfflineProvider', + version: 1, + tables: [ + { + name: RESPONSES_TABLE_NAME, + columns: [ + { + name: 'choiceid', + type: 'INTEGER', + }, + { + name: 'name', + type: 'TEXT', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'responses', + type: 'TEXT', + }, + { + name: 'deleting', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + ], + primaryKeys: ['choiceid', 'userid'], + }, + ], +}; + +/** + * Response data. + */ +export type AddonModChoiceResponsesDBRecord = { + choiceid: number; + userid: number; + courseid: number; + name: string; + responses: string; + deleting: number; + timecreated: number; +}; diff --git a/src/addons/mod/choice/services/handlers/index-link.ts b/src/addons/mod/choice/services/handlers/index-link.ts new file mode 100644 index 000000000..d7d32ff52 --- /dev/null +++ b/src/addons/mod/choice/services/handlers/index-link.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to choice index. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModChoiceIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModChoiceIndexLinkHandler'; + + constructor() { + super('AddonModChoice', 'choice'); + } + +} + +export const AddonModChoiceIndexLinkHandler = makeSingleton(AddonModChoiceIndexLinkHandlerService); diff --git a/src/addons/mod/choice/services/handlers/list-link.ts b/src/addons/mod/choice/services/handlers/list-link.ts new file mode 100644 index 000000000..2c0cf955e --- /dev/null +++ b/src/addons/mod/choice/services/handlers/list-link.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to choice list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModChoiceListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModChoiceListLinkHandler'; + + constructor() { + super('AddonModChoice', 'choice'); + } + +} + +export const AddonModChoiceListLinkHandler = makeSingleton(AddonModChoiceListLinkHandlerService); diff --git a/src/addons/mod/choice/services/handlers/module.ts b/src/addons/mod/choice/services/handlers/module.ts new file mode 100644 index 000000000..5f6d333cb --- /dev/null +++ b/src/addons/mod/choice/services/handlers/module.ts @@ -0,0 +1,83 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreConstants } from '@/core/constants'; +import { Injectable, Type } from '@angular/core'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonModChoiceIndexComponent } from '../../components/index'; + +/** + * Handler to support choice modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModChoiceModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_choice'; + + name = 'AddonModChoice'; + modName = 'choice'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: false, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: false, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + }; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_choice-handler', + showDownloadButton: true, + action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + const routeParams = '/' + courseId + '/' + module.id; + + CoreNavigator.navigateToSitePath(AddonModChoiceModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * @inheritdoc + */ + async getMainComponent(): Promise> { + return AddonModChoiceIndexComponent; + } + +} + +export const AddonModChoiceModuleHandler = makeSingleton(AddonModChoiceModuleHandlerService); diff --git a/src/addons/mod/choice/services/handlers/prefetch.ts b/src/addons/mod/choice/services/handlers/prefetch.ts new file mode 100644 index 000000000..0ead84f9a --- /dev/null +++ b/src/addons/mod/choice/services/handlers/prefetch.ts @@ -0,0 +1,157 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreUser } from '@features/user/services/user'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModChoice, AddonModChoiceProvider } from '../choice'; +import { AddonModChoiceSync, AddonModChoiceSyncResult } from '../choice-sync'; + +/** + * Handler to prefetch choices. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModChoicePrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModChoice'; + modName = 'choice'; + component = AddonModChoiceProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^answers$/; + + /** + * @inheritdoc + */ + prefetch(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise { + return this.prefetchPackage(module, courseId, this.prefetchChoice.bind(this, module, courseId, !!single)); + } + + /** + * Prefetch a choice. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchChoice( + module: CoreCourseAnyModuleData, + courseId: number, + single: boolean, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const commonOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + const modOptions = { + cmId: module.id, + ...commonOptions, // Include all common options. + }; + + const choice = await AddonModChoice.getChoice(courseId, module.id, commonOptions); + + // Get the intro files. + const introFiles = this.getIntroFilesFromInstance(module, choice); + + await Promise.all([ + AddonModChoice.getOptions(choice.id, modOptions), + this.prefetchResults(choice.id, courseId, modOptions), + CoreFilepool.addFilesToQueue(siteId, introFiles, AddonModChoiceProvider.COMPONENT, module.id), + ]); + } + + /** + * Prefetch choice results. + * + * @param choiceId Choice ID. + * @param modOptions Options. + * @return Promise resolved when done. + */ + protected async prefetchResults( + choiceId: number, + courseId: number, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + const options = await AddonModChoice.getResults(choiceId, modOptions); + + // If we can see the users that answered, prefetch their profile and avatar. + const promises: Promise[] = []; + + options.forEach((option) => { + option.userresponses.forEach((response) => { + if (response.userid) { + promises.push(CoreUser.getProfile(response.userid, courseId, false, modOptions.siteId)); + } + if (response.profileimageurl) { + promises.push(CoreFilepool.addToQueueByUrl(modOptions.siteId!, response.profileimageurl).catch(() => { + // Ignore failures. + })); + } + }); + }); + + await Promise.all(promises); + } + + /** + * @inheritdoc + */ + async getIntroFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + const choice = await CoreUtils.ignoreErrors(AddonModChoice.getChoice(courseId, module.id)); + + return this.getIntroFilesFromInstance(module, choice); + } + + /** + * @inheritdoc + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return AddonModChoice.invalidateContent(moduleId, courseId); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when invalidated. + */ + invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + return AddonModChoice.invalidateChoiceData(courseId); + } + + /** + * Sync a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + return AddonModChoiceSync.syncChoice(module.instance!, undefined, siteId); + } + +} + +export const AddonModChoicePrefetchHandler = makeSingleton(AddonModChoicePrefetchHandlerService); diff --git a/src/addons/mod/choice/services/handlers/sync-cron.ts b/src/addons/mod/choice/services/handlers/sync-cron.ts new file mode 100644 index 000000000..d59dc5aed --- /dev/null +++ b/src/addons/mod/choice/services/handlers/sync-cron.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModChoiceSync } from '../choice-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModChoiceSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModChoiceSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModChoiceSync.syncAllChoices(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModChoiceSync.syncInterval; + } + +} + +export const AddonModChoiceSyncCronHandler = makeSingleton(AddonModChoiceSyncCronHandlerService); diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index 0ee59c1c3..f16e25ea1 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -29,6 +29,7 @@ import { AddonModLtiModule } from './lti/lti.module'; import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module'; import { AddonModSurveyModule } from './survey/survey.module'; import { AddonModScormModule } from './scorm/scorm.module'; +import { AddonModChoiceModule } from './choice/choice.module'; @NgModule({ declarations: [], @@ -48,6 +49,7 @@ import { AddonModScormModule } from './scorm/scorm.module'; AddonModH5PActivityModule, AddonModSurveyModule, AddonModScormModule, + AddonModChoiceModule, ], providers: [], exports: [], diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 173c46c4f..4738f4538 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -124,7 +124,7 @@ import { ADDON_MESSAGES_SERVICES } from '@addons/messages/messages.module'; import { ADDON_MOD_ASSIGN_SERVICES } from '@addons/mod/assign/assign.module'; import { ADDON_MOD_BOOK_SERVICES } from '@addons/mod/book/book.module'; // @todo import { ADDON_MOD_CHAT_SERVICES } from '@addons/mod/chat/chat.module'; -// @todo import { ADDON_MOD_CHOICE_SERVICES } from '@addons/mod/choice/choice.module'; +import { ADDON_MOD_CHOICE_SERVICES } from '@addons/mod/choice/choice.module'; // @todo import { ADDON_MOD_FEEDBACK_SERVICES } from '@addons/mod/feedback/feedback.module'; import { ADDON_MOD_FOLDER_SERVICES } from '@addons/mod/folder/folder.module'; import { ADDON_MOD_FORUM_SERVICES } from '@addons/mod/forum/forum.module'; @@ -289,7 +289,7 @@ export class CoreCompileProvider { ...ADDON_MOD_ASSIGN_SERVICES, ...ADDON_MOD_BOOK_SERVICES, // @todo ...ADDON_MOD_CHAT_SERVICES, - // @todo ...ADDON_MOD_CHOICE_SERVICES, + ...ADDON_MOD_CHOICE_SERVICES, // @todo ...ADDON_MOD_FEEDBACK_SERVICES, ...ADDON_MOD_FOLDER_SERVICES, ...ADDON_MOD_FORUM_SERVICES, From 18757924bb7edaaaa25fb1763727e32cef8150ed Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 30 Mar 2021 13:01:23 +0200 Subject: [PATCH 2/9] MOBILE-3639 core: Migrate chart component --- package-lock.json | 49 ++++++ package.json | 2 + src/core/components/chart/chart.scss | 8 + src/core/components/chart/chart.ts | 189 ++++++++++++++++++++++ src/core/components/chart/core-chart.html | 8 + src/core/components/components.module.ts | 3 + 6 files changed, 259 insertions(+) create mode 100644 src/core/components/chart/chart.scss create mode 100644 src/core/components/chart/chart.ts create mode 100644 src/core/components/chart/core-chart.html diff --git a/package-lock.json b/package-lock.json index 018ea41b6..f4fe50746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3749,6 +3749,14 @@ "@babel/types": "^7.3.0" } }, + "@types/chart.js": { + "version": "2.9.31", + "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.31.tgz", + "integrity": "sha512-hzS6phN/kx3jClk3iYqEHNnYIRSi4RZrIGJ8CDLjgatpHoftCezvC44uqB3o3OUm9ftU1m7sHG8+RLyPTlACrA==", + "requires": { + "moment": "^2.10.2" + } + }, "@types/cordova": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", @@ -5930,6 +5938,47 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" }, + "chart.js": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz", + "integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==", + "requires": { + "chartjs-color": "^2.1.0", + "moment": "^2.10.2" + } + }, + "chartjs-color": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz", + "integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==", + "requires": { + "chartjs-color-string": "^0.6.0", + "color-convert": "^1.9.3" + }, + "dependencies": { + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + } + } + }, + "chartjs-color-string": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz", + "integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==", + "requires": { + "color-name": "^1.0.0" + } + }, "check-es-compat": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/check-es-compat/-/check-es-compat-1.1.1.tgz", diff --git a/package.json b/package.json index c978d7314..606761052 100644 --- a/package.json +++ b/package.json @@ -68,9 +68,11 @@ "@ionic/angular": "^5.6.3", "@ngx-translate/core": "^13.0.0", "@ngx-translate/http-loader": "^6.0.0", + "@types/chart.js": "^2.9.31", "@types/cordova": "0.0.34", "@types/cordova-plugin-file-transfer": "^1.6.2", "@types/dom-mediacapture-record": "^1.0.7", + "chart.js": "^2.9.4", "com-darryncampbell-cordova-plugin-intent": "^1.3.0", "cordova": "^10.0.0", "cordova-android": "^8.1.0", diff --git a/src/core/components/chart/chart.scss b/src/core/components/chart/chart.scss new file mode 100644 index 000000000..b3f3f186a --- /dev/null +++ b/src/core/components/chart/chart.scss @@ -0,0 +1,8 @@ +:host { + display: block; + + canvas { + max-width: 500px; + margin: 0 auto; + } +} diff --git a/src/core/components/chart/chart.ts b/src/core/components/chart/chart.ts new file mode 100644 index 000000000..5021bd313 --- /dev/null +++ b/src/core/components/chart/chart.ts @@ -0,0 +1,189 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnDestroy, OnInit, ElementRef, OnChanges, ViewChild, SimpleChange } from '@angular/core'; +import { CoreFilter } from '@features/filter/services/filter'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; +import { CoreUtils } from '@services/utils/utils'; +import { Chart, ChartLegendLabelItem, ChartLegendOptions } from 'chart.js'; + +/** + * This component shows a chart using chart.js. + * Documentation can be found at http://www.chartjs.org/docs/. + * It only supports changes on these properties: data and labels. + * + * Example usage: + * + */ +@Component({ + selector: 'core-chart', + templateUrl: 'core-chart.html', + styleUrls: ['chart.scss'], +}) +export class CoreChartComponent implements OnDestroy, OnInit, OnChanges { + + // The first 6 colors will be the app colors, the following will be randomly generated. + // It will use the same colors in the whole session. + protected static backgroundColors = [ + 'rgba(0,100,210, 0.6)', + 'rgba(203,61,77, 0.6)', + 'rgba(0,121,130, 0.6)', + 'rgba(249,128,18, 0.6)', + 'rgba(94,129,0, 0.6)', + 'rgba(251,173,26, 0.6)', + ]; + + @Input() data: number[] = []; // Chart data. + @Input() labels: string[] = []; // Labels of the data. + @Input() type?: string; // Type of chart. + @Input() legend?: ChartLegendOptions; // Legend options. + @Input() height = 300; // Height of the chart element. + @Input() filter?: boolean | string; // Whether to filter labels. If not defined, true if contextLevel and instanceId are set. + @Input() contextLevel?: string; // The context level of the text. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters. + @Input() wsNotFiltered?: boolean | string; // If true it means the WS didn't filter the labels for some reason. + @ViewChild('canvas') canvas?: ElementRef; + + chart?: ChartWithLegend; + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + let legend: ChartLegendOptions = {}; + if (typeof this.legend == 'undefined') { + legend = { + display: false, + labels: { + generateLabels: (chart: Chart): ChartLegendLabelItem[] => { + const data = chart.data; + if (data.labels?.length) { + const datasets = data.datasets![0]; + + return data.labels.map((label, i) => ({ + text: label + ': ' + datasets.data![i], + fillStyle: datasets.backgroundColor![i], + })); + } + + return []; + }, + }, + }; + } else { + legend = Object.assign({}, this.legend); + } + + if (this.type == 'bar' && this.data.length >= 5) { + this.type = 'horizontalBar'; + } + + // Format labels if needed. + await this.formatLabels(); + + const context = this.canvas!.nativeElement.getContext('2d')!; + this.chart = new Chart(context, { + type: this.type, + data: { + labels: this.labels, + datasets: [{ + data: this.data, + backgroundColor: this.getRandomColors(this.data.length), + }], + }, + options: { legend }, + }); + } + + /** + * @inheritdoc + */ + async ngOnChanges(changes: Record): Promise { + if (!this.chart || !changes.labels || !changes.data) { + return; + } + + if (changes.labels) { + // Format labels if needed. + await this.formatLabels(); + } + + this.chart.data.datasets![0] = { + data: this.data, + backgroundColor: this.getRandomColors(this.data.length), + }; + this.chart.data.labels = this.labels; + this.chart.update(); + } + + /** + * Format labels if needed. + * + * @return Promise resolved when done. + */ + protected async formatLabels(): Promise { + if (!this.contextLevel || !this.contextInstanceId || this.filter === false) { + return; + } + + const options = { + clean: true, + singleLine: true, + courseId: this.courseId, + wsNotFiltered: CoreUtils.isTrueOrOne(this.wsNotFiltered), + }; + + const filters = await CoreFilterHelper.getFilters(this.contextLevel, this.contextInstanceId, options); + + await Promise.all(this.labels.map(async (label, i) => { + this.labels[i] = await CoreFilter.formatText(label, options, filters); + })); + } + + /** + * Generate random colors if needed. + * + * @param n Number of colors needed. + * @return Array with the number of background colors requested. + */ + protected getRandomColors(n: number): string[] { + while (CoreChartComponent.backgroundColors.length < n) { + const red = Math.floor(Math.random() * 255); + const green = Math.floor(Math.random() * 255); + const blue = Math.floor(Math.random() * 255); + CoreChartComponent.backgroundColors.push('rgba(' + red + ', ' + green + ', ' + blue + ', 0.6)'); + } + + return CoreChartComponent.backgroundColors.slice(0, n); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + if (this.chart) { + this.chart.destroy(); + this.chart = undefined; + } + } + +} + +// For some reason the legend property isn't defined in TS, define it ourselves. +type ChartWithLegend = Chart & { + legend?: { + legendItems?: ChartLegendLabelItem[]; + }; +}; diff --git a/src/core/components/chart/core-chart.html b/src/core/components/chart/core-chart.html new file mode 100644 index 000000000..79c617a77 --- /dev/null +++ b/src/core/components/chart/core-chart.html @@ -0,0 +1,8 @@ + + + + + + {{data.text}} + + diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 8e1f2bfe6..e6f72957f 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -53,6 +53,7 @@ import { CoreFilesComponent } from './files/files'; import { CoreLocalFileComponent } from './local-file/local-file'; import { CoreBSTooltipComponent } from './bs-tooltip/bs-tooltip'; import { CoreSitePickerComponent } from './site-picker/site-picker'; +import { CoreChartComponent } from './chart/chart'; @NgModule({ declarations: [ @@ -88,6 +89,7 @@ import { CoreSitePickerComponent } from './site-picker/site-picker'; CoreLocalFileComponent, CoreBSTooltipComponent, CoreSitePickerComponent, + CoreChartComponent, ], imports: [ CommonModule, @@ -130,6 +132,7 @@ import { CoreSitePickerComponent } from './site-picker/site-picker'; CoreLocalFileComponent, CoreBSTooltipComponent, CoreSitePickerComponent, + CoreChartComponent, ], }) export class CoreComponentsModule {} From 53d808ad76d2bca5fb7d338d787f149a6a2168e8 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 30 Mar 2021 13:05:54 +0200 Subject: [PATCH 3/9] MOBILE-3639 choice: Implement index page --- src/addons/mod/choice/choice-lazy.module.ts | 38 ++ src/addons/mod/choice/choice.module.ts | 14 +- .../choice/components/components.module.ts | 34 ++ .../index/addon-mod-choice-index.html | 175 +++++++ .../mod/choice/components/index/index.ts | 479 ++++++++++++++++++ src/addons/mod/choice/lang.json | 28 + src/addons/mod/choice/pages/index/index.html | 21 + src/addons/mod/choice/pages/index/index.ts | 30 ++ 8 files changed, 818 insertions(+), 1 deletion(-) create mode 100644 src/addons/mod/choice/choice-lazy.module.ts create mode 100644 src/addons/mod/choice/components/components.module.ts create mode 100644 src/addons/mod/choice/components/index/addon-mod-choice-index.html create mode 100644 src/addons/mod/choice/components/index/index.ts create mode 100644 src/addons/mod/choice/lang.json create mode 100644 src/addons/mod/choice/pages/index/index.html create mode 100644 src/addons/mod/choice/pages/index/index.ts diff --git a/src/addons/mod/choice/choice-lazy.module.ts b/src/addons/mod/choice/choice-lazy.module.ts new file mode 100644 index 000000000..a5a0627e5 --- /dev/null +++ b/src/addons/mod/choice/choice-lazy.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModChoiceComponentsModule } from './components/components.module'; +import { AddonModChoiceIndexPage } from './pages/index/index'; + +const routes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModChoiceIndexPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModChoiceComponentsModule, + ], + declarations: [ + AddonModChoiceIndexPage, + ], +}) +export class AddonModChoiceLazyModule {} diff --git a/src/addons/mod/choice/choice.module.ts b/src/addons/mod/choice/choice.module.ts index fd2ca332a..dffa84ac7 100644 --- a/src/addons/mod/choice/choice.module.ts +++ b/src/addons/mod/choice/choice.module.ts @@ -13,19 +13,22 @@ // limitations under the License. import { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { Routes } from '@angular/router'; import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; import { CoreCronDelegate } from '@services/cron'; import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModChoiceComponentsModule } from './components/components.module'; import { AddonModChoiceProvider } from './services/choice'; import { AddonModChoiceOfflineProvider } from './services/choice-offline'; import { AddonModChoiceSyncProvider } from './services/choice-sync'; import { OFFLINE_SITE_SCHEMA } from './services/database/choice'; import { AddonModChoiceIndexLinkHandler } from './services/handlers/index-link'; import { AddonModChoiceListLinkHandler } from './services/handlers/list-link'; -import { AddonModChoiceModuleHandler } from './services/handlers/module'; +import { AddonModChoiceModuleHandler, AddonModChoiceModuleHandlerService } from './services/handlers/module'; import { AddonModChoicePrefetchHandler } from './services/handlers/prefetch'; import { AddonModChoiceSyncCronHandler } from './services/handlers/sync-cron'; @@ -35,8 +38,17 @@ export const ADDON_MOD_CHOICE_SERVICES: Type[] = [ AddonModChoiceSyncProvider, ]; +const routes: Routes = [ + { + path: AddonModChoiceModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./choice-lazy.module').then(m => m.AddonModChoiceLazyModule), + }, +]; + @NgModule({ imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModChoiceComponentsModule, ], providers: [ { diff --git a/src/addons/mod/choice/components/components.module.ts b/src/addons/mod/choice/components/components.module.ts new file mode 100644 index 000000000..9a1aeb7fe --- /dev/null +++ b/src/addons/mod/choice/components/components.module.ts @@ -0,0 +1,34 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { AddonModChoiceIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModChoiceIndexComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + ], + providers: [ + ], + exports: [ + AddonModChoiceIndexComponent, + ], +}) +export class AddonModChoiceComponentsModule {} diff --git a/src/addons/mod/choice/components/index/addon-mod-choice-index.html b/src/addons/mod/choice/components/index/addon-mod-choice-index.html new file mode 100644 index 000000000..ad9b5a810 --- /dev/null +++ b/src/addons/mod/choice/components/index/addon-mod-choice-index.html @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ 'addon.mod_choice.previewonly' | translate:{$a: openTimeReadable} }}

+

{{ 'addon.mod_choice.notopenyet' | translate:{$a: openTimeReadable} }}

+
+
+
+ + + + + +

+ {{ 'addon.mod_choice.yourselection' | translate }} + + +

+

{{ 'addon.mod_choice.expired' | translate:{$a: closeTimeReadable} }}

+
+
+
+ + + + + + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} + + + + + + + + {{ publishInfo | translate }} + + + + + + + + + + + + + + + + + + + + + + + {{ 'addon.mod_choice.savemychoice' | translate }} + + + {{ 'addon.mod_choice.removemychoice' | translate }} + + + + +
+ + +

{{ 'addon.mod_choice.responses' | translate }}

+
+
+ + + + + + {{ 'addon.mod_choice.resultsnotsynced' | translate }} + + + + + + + + + + + + +

+ + +

+

+ {{ 'addon.mod_choice.numberofuser' | translate }}: {{ result.numberofuser }} + ({{ 'core.percentagenumber' | translate: {$a: result.percentageamountfixed} }}) +

+

+ {{ 'addon.mod_choice.limita' | translate:{$a: result.maxanswer} }} +

+
+
+ + +

{{user.fullname}}

+
+
+
+
+
+
+ + + + +

{{ 'addon.mod_choice.noresultsviewable' | translate }}

+
+
+
+ + + +

+ + + + {{ 'addon.mod_choice.full' | translate }} + +

+ +

{{ 'addon.mod_choice.responsesa' | translate:{$a: option.countanswers} }}

+

{{ 'addon.mod_choice.limita' | translate:{$a: option.maxanswers} }}

+
+
diff --git a/src/addons/mod/choice/components/index/index.ts b/src/addons/mod/choice/components/index/index.ts new file mode 100644 index 000000000..171ebf8fa --- /dev/null +++ b/src/addons/mod/choice/components/index/index.ts @@ -0,0 +1,479 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Optional, OnInit } from '@angular/core'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { IonContent } from '@ionic/angular'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTimeUtils } from '@services/utils/time'; +import { Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { + AddonModChoice, + AddonModChoiceChoice, + AddonModChoiceOption, + AddonModChoiceProvider, + AddonModChoiceResult, +} from '../../services/choice'; +import { AddonModChoiceOffline } from '../../services/choice-offline'; +import { + AddonModChoiceAutoSyncData, + AddonModChoiceSync, + AddonModChoiceSyncProvider, + AddonModChoiceSyncResult, +} from '../../services/choice-sync'; +import { AddonModChoicePrefetchHandler } from '../../services/handlers/prefetch'; + +/** + * Component that displays a choice. + */ +@Component({ + selector: 'addon-mod-choice-index', + templateUrl: 'addon-mod-choice-index.html', +}) +export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit { + + component = AddonModChoiceProvider.COMPONENT; + moduleName = 'choice'; + + choice?: AddonModChoiceChoice; + options: AddonModChoiceOption[] = []; + selectedOption: {id: number} = { id: -1 }; + choiceNotOpenYet = false; + choiceClosed = false; + canEdit = false; + canDelete = false; + canSeeResults = false; + data: number[] = []; + labels: string[] = []; + results: AddonModChoiceResultFormatted[] = []; + publishInfo?: string; // Message explaining the user what will happen with his choices. + openTimeReadable?: string; + closeTimeReadable?: string; + + protected userId?: number; + protected syncEventName = AddonModChoiceSyncProvider.AUTO_SYNCED; + protected hasAnsweredOnline = false; + protected now = Date.now(); + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModChoiceIndexComponent', content, courseContentsPage); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + this.userId = CoreSites.getCurrentSiteUserId(); + + await this.loadContent(false, true); + + if (!this.choice) { + return; + } + + try { + await AddonModChoice.logView(this.choice.id, this.choice.name); + + await CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } catch { + // Ignore errors. + } + } + + /** + * @inheritdoc + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModChoice.invalidateChoiceData(this.courseId)); + + if (this.choice) { + promises.push(AddonModChoice.invalidateOptions(this.choice.id)); + promises.push(AddonModChoice.invalidateResults(this.choice.id)); + } + + await Promise.all(promises); + } + + /** + * @inheritdoc + */ + protected isRefreshSyncNeeded(syncEventData: AddonModChoiceAutoSyncData): boolean { + if (this.choice && syncEventData.choiceId == this.choice.id && syncEventData.userId == this.userId) { + this.content?.scrollToTop(); + + return true; + } + + return false; + } + + /** + * @inheritdoc + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + this.now = Date.now(); + + try { + this.choice = await AddonModChoice.getChoice(this.courseId, this.module.id); + + if (sync) { + // Try to synchronize the choice. + const updated = await this.syncActivity(showErrors); + + if (updated) { + // Responses were sent, update the choice. + this.choice = await AddonModChoice.getChoice(this.courseId, this.module.id); + } + } + + this.choice.timeopen = (this.choice.timeopen || 0) * 1000; + this.choice.timeclose = (this.choice.timeclose || 0) * 1000; + this.openTimeReadable = CoreTimeUtils.userDate(this.choice.timeopen); + this.closeTimeReadable = CoreTimeUtils.userDate(this.choice.timeclose); + + this.description = this.choice.intro; + this.choiceNotOpenYet = !!this.choice.timeopen && this.choice.timeopen > this.now; + this.choiceClosed = !!this.choice.timeclose && this.choice.timeclose <= this.now; + + this.dataRetrieved.emit(this.choice); + + // Check if there are responses stored in offline. + this.hasOffline = await AddonModChoiceOffline.hasResponse(this.choice.id); + + // We need fetchOptions to finish before calling fetchResults because it needs hasAnsweredOnline variable. + await this.fetchOptions(this.choice); + + await this.fetchResults(this.choice); + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Convenience function to get choice options. + * + * @param choice Choice data. + * @return Promise resolved when done. + */ + protected async fetchOptions(choice: AddonModChoiceChoice): Promise { + let options = await AddonModChoice.getOptions(choice.id, { cmId: this.module.id }); + + // Check if the user has answered (synced) to allow show results. + this.hasAnsweredOnline = options.some((option) => option.checked); + + if (this.hasOffline) { + options = await this.getOfflineResponses(choice, options); + } + + const isOpen = this.isChoiceOpen(choice); + + this.selectedOption = { id: -1 }; // Single choice model. + const hasAnswered = options.some((option) => { + if (!option.checked) { + return false; + } + + if (!choice.allowmultiple) { + this.selectedOption.id = option.id; + } + + return true; + }); + + this.canEdit = isOpen && (choice.allowupdate! || !hasAnswered); + this.canDelete = isOpen && choice.allowupdate! && hasAnswered; + this.options = options; + + if (!this.canEdit) { + return; + } + + // Calculate the publish info message. + switch (choice.showresults) { + case AddonModChoiceProvider.RESULTS_NOT: + this.publishInfo = 'addon.mod_choice.publishinfonever'; + break; + + case AddonModChoiceProvider.RESULTS_AFTER_ANSWER: + if (choice.publish == AddonModChoiceProvider.PUBLISH_ANONYMOUS) { + this.publishInfo = 'addon.mod_choice.publishinfoanonafter'; + } else { + this.publishInfo = 'addon.mod_choice.publishinfofullafter'; + } + break; + + case AddonModChoiceProvider.RESULTS_AFTER_CLOSE: + if (choice.publish == AddonModChoiceProvider.PUBLISH_ANONYMOUS) { + this.publishInfo = 'addon.mod_choice.publishinfoanonclose'; + } else { + this.publishInfo = 'addon.mod_choice.publishinfofullclose'; + } + break; + + default: + // No need to inform the user since it's obvious that the results are being published. + this.publishInfo = ''; + } + } + + /** + * Get offline responses. + * + * @param choice Choice. + * @param options Online options. + * @return Promise resolved with the options. + */ + protected async getOfflineResponses( + choice: AddonModChoiceChoice, + options: AddonModChoiceOption[], + ): Promise { + const response = await AddonModChoiceOffline.getResponse(choice.id); + + const optionsMap: {[id: number]: AddonModChoiceOption} = {}; + options.forEach((option) => { + optionsMap[option.id] = option; + }); + + // Update options with the offline data. + if (response.deleting) { + // Uncheck selected options. + if (response.responses.length > 0) { + // Uncheck all options selected in responses. + response.responses.forEach((selected) => { + if (optionsMap[selected] && optionsMap[selected].checked) { + optionsMap[selected].checked = false; + optionsMap[selected].countanswers--; + } + }); + } else { + // On empty responses, uncheck all selected. + Object.keys(optionsMap).forEach((key) => { + if (optionsMap[key].checked) { + optionsMap[key].checked = false; + optionsMap[key].countanswers--; + } + }); + } + } else { + // Uncheck all options to check again the offlines'. + Object.keys(optionsMap).forEach((key) => { + if (optionsMap[key].checked) { + optionsMap[key].checked = false; + optionsMap[key].countanswers--; + } + }); + // Then check selected ones. + response.responses.forEach((selected) => { + if (optionsMap[selected]) { + optionsMap[selected].checked = true; + optionsMap[selected].countanswers++; + } + }); + } + + // Convert it again to array. + return Object.keys(optionsMap).map((key) => optionsMap[key]); + } + + /** + * Convenience function to get choice results. + * + * @param choice Choice. + * @return Resolved when done. + */ + protected async fetchResults(choice: AddonModChoiceChoice): Promise { + if (this.choiceNotOpenYet) { + // Cannot see results yet. + this.canSeeResults = false; + + return; + } + + const results = await AddonModChoice.getResults(choice.id, { cmId: this.module.id }); + + let hasVotes = false; + this.data = []; + this.labels = []; + + this.results = results.map((result: AddonModChoiceResultFormatted) => { + if (result.numberofuser > 0) { + hasVotes = true; + } + this.data.push(result.numberofuser); + this.labels.push(result.text); + + return Object.assign(result, { percentageamountfixed: result.percentageamount.toFixed(1) }); + }); + this.canSeeResults = hasVotes || AddonModChoice.canStudentSeeResults(choice, this.hasAnsweredOnline); + } + + /** + * Check if a choice is open. + * + * @param choice Choice data. + * @return True if choice is open, false otherwise. + */ + protected isChoiceOpen(choice: AddonModChoiceChoice): boolean { + return (!choice.timeopen || choice.timeopen <= this.now) && (!choice.timeclose || choice.timeclose > this.now); + } + + /** + * Return true if the user has selected at least one option. + * + * @return True if the user has responded. + */ + canSave(): boolean { + if (!this.choice) { + return false; + } + + if (this.choice.allowmultiple) { + return this.options.some((option) => option.checked); + } else { + return this.selectedOption.id !== -1; + } + } + + /** + * Save options selected. + */ + async save(): Promise { + const choice = this.choice!; + + // Only show confirm if choice doesn't allow update. + if (!choice.allowupdate) { + await CoreDomUtils.showConfirm(Translate.instant('core.areyousure')); + } + + const responses: number[] = []; + if (choice.allowmultiple) { + this.options.forEach((option) => { + if (option.checked) { + responses.push(option.id); + } + }); + } else { + responses.push(this.selectedOption.id); + } + + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + try { + const online = await AddonModChoice.submitResponse(choice.id, choice.name, this.courseId, responses); + + this.content?.scrollToTop(); + + if (online) { + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: this.moduleName }); + // Check completion since it could be configured to complete once the user answers the choice. + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } + + await this.dataUpdated(online); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_choice.cannotsubmit', true); + } finally { + modal.dismiss(); + } + } + + /** + * Delete options selected. + */ + async delete(): Promise { + try { + await CoreDomUtils.showDeleteConfirm(); + } catch { + // User cancelled. + return; + } + + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + try { + await AddonModChoice.deleteResponses(this.choice!.id, this.choice!.name, this.courseId); + + this.content?.scrollToTop(); + + // Refresh the data. Don't call dataUpdated because deleting an answer doesn't mark the choice as outdated. + await this.refreshContent(false); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_choice.cannotsubmit', true); + } finally { + modal.dismiss(); + } + } + + /** + * Function to call when some data has changed. It will refresh/prefetch data. + * + * @param online Whether the data was sent to server or stored in offline. + * @return Promise resolved when done. + */ + protected async dataUpdated(online: boolean): Promise { + if (!online || !this.isPrefetched) { + // Not downloaded, just refresh the data. + return this.refreshContent(false); + } + + try { + // The choice is downloaded, update the data. + await AddonModChoiceSync.prefetchAfterUpdate(AddonModChoicePrefetchHandler.instance, this.module, this.courseId); + + // Update the view. + this.showLoadingAndFetch(false, false); + } catch { + // Prefetch failed, refresh the data. + return this.refreshContent(false); + } + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected sync(): Promise { + return AddonModChoiceSync.syncChoice(this.choice!.id, this.userId); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return Whether it succeed or not. + */ + protected hasSyncSucceed(result: AddonModChoiceSyncResult): boolean { + return result.updated; + } + +} + +/** + * Choice result with some calculated data. + */ +export type AddonModChoiceResultFormatted = AddonModChoiceResult & { + percentageamountfixed: string; // Percentage of users answers with fixed decimals. +}; diff --git a/src/addons/mod/choice/lang.json b/src/addons/mod/choice/lang.json new file mode 100644 index 000000000..7adee8f2e --- /dev/null +++ b/src/addons/mod/choice/lang.json @@ -0,0 +1,28 @@ +{ + "cannotsubmit": "Sorry, there was a problem submitting your choice. Please try again.", + "choiceoptions": "Choice options", + "errorgetchoice": "Error getting choice data.", + "expired": "This activity closed on {{$a}}.", + "full": "(Full)", + "limita": "Limit: {{$a}}", + "modulenameplural": "Choices", + "noresultsviewable": "The results are not currently viewable.", + "notopenyet": "This activity is not available until {{$a}}.", + "numberofuser": "Number of responses", + "numberofuserinpercentage": "Percentage of responses", + "previewonly": "This is just a preview of the available options for this activity. You will not be able to submit your choice until {{$a}}.", + "publishinfoanonafter": "Anonymous results will be published after you answer.", + "publishinfoanonclose": "Anonymous results will be published after the activity is closed.", + "publishinfofullafter": "Full results, showing everyone's choices, will be published after you answer.", + "publishinfofullclose": "Full results, showing everyone's choices, will be published after the activity is closed.", + "publishinfonever": "The results of this activity will not be published after you answer.", + "removemychoice": "Remove my choice", + "responses": "Responses", + "responsesa": "Responses: {{$a}}", + "responsesresultgraphdescription": "{{number}}% of the users chose the option: {{text}}.", + "responsesresultgraphheader": "Graph display", + "resultsnotsynced": "Your last response must be synchronised before it is included in the results.", + "savemychoice": "Save my choice", + "userchoosethisoption": "Users who chose this option", + "yourselection": "Your selection" +} \ No newline at end of file diff --git a/src/addons/mod/choice/pages/index/index.html b/src/addons/mod/choice/pages/index/index.html new file mode 100644 index 000000000..9a8c599b8 --- /dev/null +++ b/src/addons/mod/choice/pages/index/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/choice/pages/index/index.ts b/src/addons/mod/choice/pages/index/index.ts new file mode 100644 index 000000000..ab61f4730 --- /dev/null +++ b/src/addons/mod/choice/pages/index/index.ts @@ -0,0 +1,30 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChild } from '@angular/core'; +import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page'; +import { AddonModChoiceIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a choice. + */ +@Component({ + selector: 'page-addon-mod-choice-index', + templateUrl: 'index.html', +}) +export class AddonModChoiceIndexPage extends CoreCourseModuleMainActivityPage { + + @ViewChild(AddonModChoiceIndexComponent) activityComponent?: AddonModChoiceIndexComponent; + +} From 76611fabaec3db4924a2b428f061edb357ce01c1 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 30 Mar 2021 13:24:45 +0200 Subject: [PATCH 4/9] MOBILE-3639 core: Remove Network warnings in browser --- src/core/services/app.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/core/services/app.ts b/src/core/services/app.ts index f0e715315..05b6a8181 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -334,10 +334,14 @@ export class CoreAppProvider { return false; } + if (!this.isMobile()) { + return navigator.onLine; + } + let online = Network.type !== null && Network.type != Network.Connection.NONE && Network.type != Network.Connection.UNKNOWN; - // Double check we are not online because we cannot rely 100% in Cordova APIs. Also, check it in browser. + // Double check we are not online because we cannot rely 100% in Cordova APIs. if (!online && navigator.onLine) { online = true; } @@ -351,9 +355,7 @@ export class CoreAppProvider { * @return Whether the device uses a limited connection. */ isNetworkAccessLimited(): boolean { - const type = Network.type; - if (type === null) { - // Plugin not defined, probably in browser. + if (!this.isMobile()) { return false; } @@ -364,7 +366,7 @@ export class CoreAppProvider { Network.Connection.CELL, ]; - return limited.indexOf(type) > -1; + return limited.indexOf(Network.type) > -1; } /** From fc6a95cb340625600711ed341b8d8022134fa69b Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 30 Mar 2021 14:17:17 +0200 Subject: [PATCH 5/9] MOBILE-3639 core: Fix cordova not defined error in browser --- src/core/services/file.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/services/file.ts b/src/core/services/file.ts index 7e903a46d..29506eef7 100644 --- a/src/core/services/file.ts +++ b/src/core/services/file.ts @@ -1235,7 +1235,7 @@ export class CoreFileProvider { * @return Path. */ getWWWAbsolutePath(): string { - if (cordova && cordova.file && cordova.file.applicationDirectory) { + if (window.cordova && cordova.file && cordova.file.applicationDirectory) { return CoreTextUtils.concatenatePaths(cordova.file.applicationDirectory, 'www'); } From 36c2e45921423b2d2c1ede6fba751190b0514650 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 31 Mar 2021 10:14:02 +0200 Subject: [PATCH 6/9] MOBILE-3639 compile: Pass all error classes to compiled instances --- src/core/classes/errors/errors.ts | 37 +++++++++++++++++++ src/core/features/compile/services/compile.ts | 4 ++ 2 files changed, 41 insertions(+) create mode 100644 src/core/classes/errors/errors.ts diff --git a/src/core/classes/errors/errors.ts b/src/core/classes/errors/errors.ts new file mode 100644 index 000000000..3f0923075 --- /dev/null +++ b/src/core/classes/errors/errors.ts @@ -0,0 +1,37 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Type } from '@angular/core'; + +import { CoreError } from './error'; +import { CoreWSError } from './wserror'; +import { CoreCanceledError } from './cancelederror'; +import { CoreSilentError } from './silenterror'; +import { CoreAjaxError } from './ajaxerror'; +import { CoreAjaxWSError } from './ajaxwserror'; +import { CoreCaptureError } from './captureerror'; +import { CoreNetworkError } from './network-error'; +import { CoreSiteError } from './siteerror'; + +export const CORE_ERRORS_CLASSES: Type[] = [ + CoreAjaxError, + CoreAjaxWSError, + CoreCanceledError, + CoreCaptureError, + CoreError, + CoreNetworkError, + CoreSilentError, + CoreSiteError, + CoreWSError, +]; diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 4738f4538..e7738fca3 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -88,6 +88,7 @@ import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/class import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; import { CoreCourseResourcePrefetchHandlerBase } from '@features/course/classes/resource-prefetch-handler'; import { CoreGeolocationError, CoreGeolocationErrorReason } from '@services/geolocation'; +import { CORE_ERRORS_CLASSES } from '@classes/errors/errors'; // Import all core modules that define components, directives and pipes. import { CoreSharedModule } from '@/core/shared.module'; @@ -361,6 +362,9 @@ export class CoreCompileProvider { instance['CoreSitePluginsAssignSubmissionComponent'] = CoreSitePluginsAssignSubmissionComponent; instance['CoreGeolocationError'] = CoreGeolocationError; instance['CoreGeolocationErrorReason'] = CoreGeolocationErrorReason; + CORE_ERRORS_CLASSES.forEach((classDef) => { + instance[classDef.name] = classDef; + }); } /** From a568365344aa3dd1ce1e091e6f936da0c50a2906 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 31 Mar 2021 10:25:01 +0200 Subject: [PATCH 7/9] MOBILE-3639 siteplugins: Fix module routes --- .../classes/handlers/user-handler.ts | 2 +- .../only-title-block/only-title-block.ts | 2 +- .../plugin-content/plugin-content.ts | 2 +- .../directives/call-ws-new-content.ts | 2 +- .../siteplugins/directives/new-content.ts | 2 +- .../siteplugins/siteplugins-lazy.module.ts | 32 ------------------- .../siteplugins/siteplugins.module.ts | 13 ++++++-- 7 files changed, 15 insertions(+), 40 deletions(-) delete mode 100644 src/core/features/siteplugins/siteplugins-lazy.module.ts diff --git a/src/core/features/siteplugins/classes/handlers/user-handler.ts b/src/core/features/siteplugins/classes/handlers/user-handler.ts index 315e198da..12e48e214 100644 --- a/src/core/features/siteplugins/classes/handlers/user-handler.ts +++ b/src/core/features/siteplugins/classes/handlers/user-handler.ts @@ -100,7 +100,7 @@ export class CoreSitePluginsUserProfileHandler extends CoreSitePluginsBaseHandle const hash = Md5.hashAsciiStr(JSON.stringify(args)); CoreNavigator.navigateToSitePath( - `siteplugins/${this.plugin.component}/${this.handlerSchema.method}/${hash}`, + `siteplugins/content/${this.plugin.component}/${this.handlerSchema.method}/${hash}`, { params: { title: this.title, diff --git a/src/core/features/siteplugins/components/only-title-block/only-title-block.ts b/src/core/features/siteplugins/components/only-title-block/only-title-block.ts index 742c392d3..292b36d92 100644 --- a/src/core/features/siteplugins/components/only-title-block/only-title-block.ts +++ b/src/core/features/siteplugins/components/only-title-block/only-title-block.ts @@ -60,7 +60,7 @@ export class CoreSitePluginsOnlyTitleBlockComponent extends CoreBlockBaseCompon const hash = Md5.hashAsciiStr(JSON.stringify(args)); CoreNavigator.navigateToSitePath( - `siteplugins/${handler.plugin.component}/${handler.handlerSchema.method}/${hash}`, + `siteplugins/content/${handler.plugin.component}/${handler.handlerSchema.method}/${hash}`, { params: { title: this.title, diff --git a/src/core/features/siteplugins/components/plugin-content/plugin-content.ts b/src/core/features/siteplugins/components/plugin-content/plugin-content.ts index 24187a1a7..91b3bb2ce 100644 --- a/src/core/features/siteplugins/components/plugin-content/plugin-content.ts +++ b/src/core/features/siteplugins/components/plugin-content/plugin-content.ts @@ -153,7 +153,7 @@ export class CoreSitePluginsPluginContentComponent implements OnInit, DoCheck { args = args || {}; const hash = Md5.hashAsciiStr(JSON.stringify(args)); - CoreNavigator.navigateToSitePath(`siteplugins/${component}/${method}/${hash}`, { + CoreNavigator.navigateToSitePath(`siteplugins/content/${component}/${method}/${hash}`, { params: { title, args, diff --git a/src/core/features/siteplugins/directives/call-ws-new-content.ts b/src/core/features/siteplugins/directives/call-ws-new-content.ts index 54c773fd9..f76432877 100644 --- a/src/core/features/siteplugins/directives/call-ws-new-content.ts +++ b/src/core/features/siteplugins/directives/call-ws-new-content.ts @@ -103,7 +103,7 @@ export class CoreSitePluginsCallWSNewContentDirective extends CoreSitePluginsCal const method = this.method || this.parentContent?.method; const hash = Md5.hashAsciiStr(JSON.stringify(args)); - CoreNavigator.navigateToSitePath(`siteplugins/${component}/${method}/${hash}`, { + CoreNavigator.navigateToSitePath(`siteplugins/content/${component}/${method}/${hash}`, { params: { title: this.title || this.parentContent?.pageTitle, args, diff --git a/src/core/features/siteplugins/directives/new-content.ts b/src/core/features/siteplugins/directives/new-content.ts index 6faee7123..02a9c7909 100644 --- a/src/core/features/siteplugins/directives/new-content.ts +++ b/src/core/features/siteplugins/directives/new-content.ts @@ -100,7 +100,7 @@ export class CoreSitePluginsNewContentDirective implements OnInit { const method = this.method || this.parentContent?.method; const hash = Md5.hashAsciiStr(JSON.stringify(args)); - CoreNavigator.navigateToSitePath(`siteplugins/${component}/${method}/${hash}`, { + CoreNavigator.navigateToSitePath(`siteplugins/content/${component}/${method}/${hash}`, { params: { title: this.title || this.parentContent?.pageTitle, args, diff --git a/src/core/features/siteplugins/siteplugins-lazy.module.ts b/src/core/features/siteplugins/siteplugins-lazy.module.ts deleted file mode 100644 index 7b7e4a3dd..000000000 --- a/src/core/features/siteplugins/siteplugins-lazy.module.ts +++ /dev/null @@ -1,32 +0,0 @@ -// (C) Copyright 2015 Moodle Pty Ltd. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; - -const routes: Routes = [ - { - path: 'module/:courseId/:cmId', - loadChildren: () => import('./pages/module-index/module-index.module').then( m => m.CoreSitePluginsModuleIndexPageModule), - }, - { - path: ':component/:method/:hash', - loadChildren: () => import('./pages/plugin-page/plugin-page.module').then( m => m.CoreSitePluginsPluginPageModule), - }, -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], -}) -export class CoreSitePluginsLazyModule {} diff --git a/src/core/features/siteplugins/siteplugins.module.ts b/src/core/features/siteplugins/siteplugins.module.ts index 730d504e2..7f15ec693 100644 --- a/src/core/features/siteplugins/siteplugins.module.ts +++ b/src/core/features/siteplugins/siteplugins.module.ts @@ -23,8 +23,8 @@ import { CoreSitePluginsHelper } from './services/siteplugins-helper'; const routes: Routes = [ { - path: 'siteplugins', - loadChildren: () => import('@features/siteplugins/siteplugins-lazy.module').then(m => m.CoreSitePluginsLazyModule), + path: 'siteplugins/content/:component/:method/:hash', + loadChildren: () => import('./pages/plugin-page/plugin-page.module').then( m => m.CoreSitePluginsPluginPageModule), }, ]; @@ -36,9 +36,16 @@ const courseIndexRoutes: Routes = [ }, ]; +const moduleRoutes: Routes = [ + { + path: 'siteplugins/module/:courseId/:cmId', + loadChildren: () => import('./pages/module-index/module-index.module').then( m => m.CoreSitePluginsModuleIndexPageModule), + }, +]; + @NgModule({ imports: [ - CoreMainMenuTabRoutingModule.forChild(routes), + CoreMainMenuTabRoutingModule.forChild(moduleRoutes.concat(routes)), CoreCourseIndexRoutingModule.forChild({ children: courseIndexRoutes }), CoreMainMenuHomeRoutingModule.forChild({ children: routes }), CoreSitePluginsComponentsModule, From 8a6954a23cf26a0624569c3950a1f56caba6faf9 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 31 Mar 2021 12:01:43 +0200 Subject: [PATCH 8/9] MOBILE-3639 format-text: Add new inputs to control links --- src/core/directives/format-text.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index d9dc16f63..4ceb07fea 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -69,6 +69,8 @@ export class CoreFormatTextDirective implements OnChanges { @Input() contextInstanceId?: number; // The instance ID related to the context. @Input() courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters. @Input() wsNotFiltered?: boolean | string; // If true it means the WS didn't filter the text for some reason. + @Input() captureLinks?: boolean; // Whether links should tried to be opened inside the app. Defaults to true. + @Input() openLinksInApp?: boolean; // Whether links should be opened in InAppBrowser. /** * Max height in pixels to render the content box. It should be 50 at least to make sense. @@ -489,7 +491,8 @@ export class CoreFormatTextDirective implements OnChanges { anchors.forEach((anchor) => { // Angular 2 doesn't let adding directives dynamically. Create the CoreLinkDirective manually. const linkDir = new CoreLinkDirective(new ElementRef(anchor), this.content); - linkDir.capture = true; + linkDir.capture = this.captureLinks ?? true; + linkDir.inApp = this.openLinksInApp; linkDir.ngOnInit(); this.addExternalContent(anchor); From 5c814909cdbe55464217fdff4f101d3712aa78bb Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 31 Mar 2021 12:20:32 +0200 Subject: [PATCH 9/9] MOBILE-3639 file: Allow reading files from other folders --- src/core/services/file.ts | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/core/services/file.ts b/src/core/services/file.ts index 29506eef7..dc09c9bbc 100644 --- a/src/core/services/file.ts +++ b/src/core/services/file.ts @@ -473,28 +473,39 @@ export class CoreFileProvider { * * @param path Relative path to the file. * @param format Format to read the file. + * @param folder Absolute path to the folder where the file is. Use it to read files outside of the app's data folder. * @return Promise to be resolved when the file is read. */ readFile( path: string, format?: CoreFileFormat.FORMATTEXT | CoreFileFormat.FORMATDATAURL | CoreFileFormat.FORMATBINARYSTRING, + folder?: string, ): Promise; - readFile(path: string, format: CoreFileFormat.FORMATARRAYBUFFER): Promise; - readFile(path: string, format: CoreFileFormat.FORMATJSON): Promise; - readFile(path: string, format: CoreFileFormat = CoreFileFormat.FORMATTEXT): Promise { - // Remove basePath if it's in the path. - path = this.removeStartingSlash(path.replace(this.basePath, '')); - this.logger.debug('Read file ' + path + ' with format ' + format); + readFile(path: string, format: CoreFileFormat.FORMATARRAYBUFFER, folder?: string): Promise; + readFile(path: string, format: CoreFileFormat.FORMATJSON, folder?: string): Promise; + readFile( + path: string, + format: CoreFileFormat = CoreFileFormat.FORMATTEXT, + folder?: string, + ): Promise { + if (!folder) { + folder = this.basePath; + + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + } + + this.logger.debug(`Read file ${path} with format ${format} in folder ${folder}`); switch (format) { case CoreFileFormat.FORMATDATAURL: - return File.readAsDataURL(this.basePath, path); + return File.readAsDataURL(folder, path); case CoreFileFormat.FORMATBINARYSTRING: - return File.readAsBinaryString(this.basePath, path); + return File.readAsBinaryString(folder, path); case CoreFileFormat.FORMATARRAYBUFFER: - return File.readAsArrayBuffer(this.basePath, path); + return File.readAsArrayBuffer(folder, path); case CoreFileFormat.FORMATJSON: - return File.readAsText(this.basePath, path).then((text) => { + return File.readAsText(folder, path).then((text) => { const parsed = CoreTextUtils.parseJSON(text, null); if (parsed == null && text != null) { @@ -504,7 +515,7 @@ export class CoreFileProvider { return parsed; }); default: - return File.readAsText(this.basePath, path); + return File.readAsText(folder, path); } }