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