Merge pull request #1291 from dpalou/MOBILE-2348

Mobile 2348
main
Juan Leyva 2018-04-13 15:36:48 +02:00 committed by GitHub
commit 1346f86372
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
142 changed files with 13027 additions and 1213 deletions

4285
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -78,7 +78,7 @@
"zone.js": "0.8.18"
},
"devDependencies": {
"@ionic/app-scripts": "^3.1.5",
"@ionic/app-scripts": "^3.1.8",
"gulp": "^3.9.1",
"gulp-clip-empty-files": "^0.1.2",
"gulp-rename": "^1.2.2",

View File

@ -518,18 +518,18 @@ export class AddonMessagesDiscussionPage implements OnDestroy {
this.domUtils.showConfirm(this.translate.instant(langKey)).then(() => {
const modal = this.domUtils.showModalLoading('core.deleting', true);
this.messagesProvider.deleteMessage(message).then(() => {
return this.messagesProvider.deleteMessage(message).then(() => {
// Remove message from the list without having to wait for re-fetch.
this.messages.splice(index, 1);
this.removeMessage(message.hash);
this.notifyNewMessage();
this.fetchData(); // Re-fetch messages to update cached data.
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.messages.errordeletemessage', true);
}).finally(() => {
modal.dismiss();
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.messages.errordeletemessage', true);
});
}

View File

@ -34,12 +34,12 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider {
static AUTO_SYNCED = 'addon_messages_autom_synced';
constructor(protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider,
protected appProvider: CoreAppProvider, private messagesOffline: AddonMessagesOfflineProvider,
private eventsProvider: CoreEventsProvider, private messagesProvider: AddonMessagesProvider,
private userProvider: CoreUserProvider, private translate: TranslateService, private utils: CoreUtilsProvider,
syncProvider: CoreSyncProvider, protected textUtils: CoreTextUtilsProvider) {
super('AddonMessagesSync', sitesProvider, loggerProvider, appProvider, syncProvider, textUtils);
constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider,
translate: TranslateService, syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider,
private messagesOffline: AddonMessagesOfflineProvider, private eventsProvider: CoreEventsProvider,
private messagesProvider: AddonMessagesProvider, private userProvider: CoreUserProvider,
private utils: CoreUtilsProvider) {
super('AddonMessagesSync', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate);
}
/**

View File

@ -100,8 +100,6 @@ export class AddonMessagesAddContactUserHandler implements CoreUserProfileHandle
return this.domUtils.showConfirm(template, title, title).then(() => {
return this.messagesProvider.removeContact(user.id);
}, () => {
// Ignore on cancel.
});
} else {
return this.messagesProvider.addContact(user.id);

View File

@ -103,8 +103,6 @@ export class AddonMessagesBlockContactUserHandler implements CoreUserProfileHand
return this.domUtils.showConfirm(template, title, title).then(() => {
return this.messagesProvider.blockContact(user.id);
}, () => {
// Ignore on cancel.
});
}
}).catch((error) => {

View File

@ -0,0 +1,30 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { AddonModQuizAccessDelayBetweenAttemptsHandler } from './providers/handler';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
@NgModule({
declarations: [
],
providers: [
AddonModQuizAccessDelayBetweenAttemptsHandler
]
})
export class AddonModQuizAccessDelayBetweenAttemptsModule {
constructor(accessRuleDelegate: AddonModQuizAccessRuleDelegate, handler: AddonModQuizAccessDelayBetweenAttemptsHandler) {
accessRuleDelegate.registerHandler(handler);
}
}

View File

@ -0,0 +1,52 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { AddonModQuizAccessRuleHandler } from '../../../providers/access-rules-delegate';
/**
* Handler to support delay between attempts access rule.
*/
@Injectable()
export class AddonModQuizAccessDelayBetweenAttemptsHandler implements AddonModQuizAccessRuleHandler {
name = 'AddonModQuizAccessDelayBetweenAttempts';
ruleName = 'quizaccess_delaybetweenattempts';
constructor() {
// Nothing to do.
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return true;
}
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean> {
return false;
}
}

View File

@ -0,0 +1,30 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { AddonModQuizAccessIpAddressHandler } from './providers/handler';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
@NgModule({
declarations: [
],
providers: [
AddonModQuizAccessIpAddressHandler
]
})
export class AddonModQuizAccessIpAddressModule {
constructor(accessRuleDelegate: AddonModQuizAccessRuleDelegate, handler: AddonModQuizAccessIpAddressHandler) {
accessRuleDelegate.registerHandler(handler);
}
}

View File

@ -0,0 +1,52 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { AddonModQuizAccessRuleHandler } from '../../../providers/access-rules-delegate';
/**
* Handler to support IP address access rule.
*/
@Injectable()
export class AddonModQuizAccessIpAddressHandler implements AddonModQuizAccessRuleHandler {
name = 'AddonModQuizAccessIpAddress';
ruleName = 'quizaccess_ipaddress';
constructor() {
// Nothing to do.
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return true;
}
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean> {
return false;
}
}

View File

@ -0,0 +1,30 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { AddonModQuizAccessNumAttemptsHandler } from './providers/handler';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
@NgModule({
declarations: [
],
providers: [
AddonModQuizAccessNumAttemptsHandler
]
})
export class AddonModQuizAccessNumAttemptsModule {
constructor(accessRuleDelegate: AddonModQuizAccessRuleDelegate, handler: AddonModQuizAccessNumAttemptsHandler) {
accessRuleDelegate.registerHandler(handler);
}
}

View File

@ -0,0 +1,52 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { AddonModQuizAccessRuleHandler } from '../../../providers/access-rules-delegate';
/**
* Handler to support num attempts access rule.
*/
@Injectable()
export class AddonModQuizAccessNumAttemptsHandler implements AddonModQuizAccessRuleHandler {
name = 'AddonModQuizAccessNumAttempts';
ruleName = 'quizaccess_numattempts';
constructor() {
// Nothing to do.
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return true;
}
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean> {
return false;
}
}

View File

@ -0,0 +1,4 @@
<ion-item text-wrap>
<p class="item-heading">{{ 'core.settings.synchronization' | translate }}</p>
<p>{{ 'addon.mod_quiz.confirmcontinueoffline' | translate:{$a: quiz.syncTimeReadable} }}</p>
</ion-item>

View File

@ -0,0 +1,42 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, Input } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
/**
* Component to render the preflight for offline attempts.
*/
@Component({
selector: 'addon-mod-quiz-acess-offline-attempts',
templateUrl: 'offlineattempts.html'
})
export class AddonModQuizAccessOfflineAttemptsComponent implements OnInit {
@Input() quiz: any; // The quiz the rule belongs to.
@Input() attempt: any; // The attempt being started/continued.
@Input() prefetch: boolean; // Whether the user is prefetching the quiz.
@Input() siteId: string; // Site ID.
@Input() form: FormGroup; // Form where to add the form control.
constructor(private fb: FormBuilder) { }
/**
* Component being initialized.
*/
ngOnInit(): void {
// Always set confirmdatasaved to 1. Sending the data means the user accepted.
this.form.addControl('confirmdatasaved', this.fb.control(1));
}
}

View File

@ -0,0 +1,46 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { AddonModQuizAccessOfflineAttemptsHandler } from './providers/handler';
import { AddonModQuizAccessOfflineAttemptsComponent } from './component/offlineattempts';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
@NgModule({
declarations: [
AddonModQuizAccessOfflineAttemptsComponent
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
],
providers: [
AddonModQuizAccessOfflineAttemptsHandler
],
exports: [
AddonModQuizAccessOfflineAttemptsComponent
],
entryComponents: [
AddonModQuizAccessOfflineAttemptsComponent
]
})
export class AddonModQuizAccessOfflineAttemptsModule {
constructor(accessRuleDelegate: AddonModQuizAccessRuleDelegate, handler: AddonModQuizAccessOfflineAttemptsHandler) {
accessRuleDelegate.registerHandler(handler);
}
}

View File

@ -0,0 +1,91 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable, Injector } from '@angular/core';
import { AddonModQuizAccessRuleHandler } from '../../../providers/access-rules-delegate';
import { AddonModQuizSyncProvider } from '../../../providers/quiz-sync';
import { AddonModQuizAccessOfflineAttemptsComponent } from '../component/offlineattempts';
/**
* Handler to support offline attempts access rule.
*/
@Injectable()
export class AddonModQuizAccessOfflineAttemptsHandler implements AddonModQuizAccessRuleHandler {
name = 'AddonModQuizAccessOfflineAttempts';
ruleName = 'quizaccess_offlineattempts';
constructor() {
// Nothing to do.
}
/**
* Add preflight data that doesn't require user interaction. The data should be added to the preflightData param.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} preflightData Object where to add the preflight data.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {void|Promise<any>} Promise resolved when done if async, void if it's synchronous.
*/
getFixedPreflightData(quiz: any, preflightData: any, attempt?: any, prefetch?: boolean, siteId?: string): void | Promise<any> {
preflightData.confirmdatasaved = 1;
}
/**
* Return the Component to use to display the access rule preflight.
* Implement this if your access rule requires a preflight check with user interaction.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @param {Injector} injector Injector.
* @return {any|Promise<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getPreflightComponent(injector: Injector): any | Promise<any> {
return AddonModQuizAccessOfflineAttemptsComponent;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return true;
}
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean> {
if (prefetch) {
// Don't show the warning if the user is prefetching.
return false;
}
if (!attempt) {
// User is starting a new attempt, show the warning.
return true;
}
// Show warning if last sync was a while ago.
return Date.now() - AddonModQuizSyncProvider.SYNC_TIME > quiz.syncTime;
}
}

View File

@ -0,0 +1,30 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { AddonModQuizAccessOpenCloseDateHandler } from './providers/handler';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
@NgModule({
declarations: [
],
providers: [
AddonModQuizAccessOpenCloseDateHandler
]
})
export class AddonModQuizAccessOpenCloseDateModule {
constructor(accessRuleDelegate: AddonModQuizAccessRuleDelegate, handler: AddonModQuizAccessOpenCloseDateHandler) {
accessRuleDelegate.registerHandler(handler);
}
}

View File

@ -0,0 +1,75 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { AddonModQuizAccessRuleHandler } from '../../../providers/access-rules-delegate';
import { AddonModQuizProvider } from '../../../providers/quiz';
/**
* Handler to support open/close date access rule.
*/
@Injectable()
export class AddonModQuizAccessOpenCloseDateHandler implements AddonModQuizAccessRuleHandler {
name = 'AddonModQuizAccessOpenCloseDate';
ruleName = 'quizaccess_openclosedate';
constructor() {
// Nothing to do.
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return true;
}
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean> {
return false;
}
/**
* Whether or not the time left of an attempt should be displayed.
*
* @param {any} attempt The attempt.
* @param {number} endTime The attempt end time (in seconds).
* @param {number} timeNow The current time in seconds.
* @return {boolean} Whether it should be displayed.
*/
shouldShowTimeLeft(attempt: any, endTime: number, timeNow: number): boolean {
// If this is a teacher preview after the close date, do not show the time.
if (attempt.preview && timeNow > endTime) {
return false;
}
// Show the time left only if it's less than QUIZ_SHOW_TIME_BEFORE_DEADLINE.
if (timeNow > endTime - AddonModQuizProvider.QUIZ_SHOW_TIME_BEFORE_DEADLINE) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,9 @@
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_quiz.quizpassword' | translate }}</p>
<p>{{ 'addon.mod_quiz.requirepasswordmessage' | translate}}</p>
</ion-item>
<ion-item [formGroup]="form">
<core-show-password item-content [name]="'quizpassword'">
<ion-input id="addon-mod_quiz-accessrule-password-input" name="quizpassword" type="password" placeholder="{{ 'addon.mod_quiz.quizpassword' | translate }}" [formControlName]="'quizpassword'"></ion-input>
</core-show-password>
</ion-item>

View File

@ -0,0 +1,42 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, Input } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
/**
* Component to render the preflight for password.
*/
@Component({
selector: 'addon-mod-quiz-acess-password',
templateUrl: 'password.html'
})
export class AddonModQuizAccessPasswordComponent implements OnInit {
@Input() quiz: any; // The quiz the rule belongs to.
@Input() attempt: any; // The attempt being started/continued.
@Input() prefetch: boolean; // Whether the user is prefetching the quiz.
@Input() siteId: string; // Site ID.
@Input() form: FormGroup; // Form where to add the form control.
constructor(private fb: FormBuilder) { }
/**
* Component being initialized.
*/
ngOnInit(): void {
// Add the control for the password.
this.form.addControl('quizpassword', this.fb.control(''));
}
}

View File

@ -0,0 +1,48 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { AddonModQuizAccessPasswordHandler } from './providers/handler';
import { AddonModQuizAccessPasswordComponent } from './component/password';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
@NgModule({
declarations: [
AddonModQuizAccessPasswordComponent
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule
],
providers: [
AddonModQuizAccessPasswordHandler
],
exports: [
AddonModQuizAccessPasswordComponent
],
entryComponents: [
AddonModQuizAccessPasswordComponent
]
})
export class AddonModQuizAccessPasswordModule {
constructor(accessRuleDelegate: AddonModQuizAccessRuleDelegate, handler: AddonModQuizAccessPasswordHandler) {
accessRuleDelegate.registerHandler(handler);
}
}

View File

@ -0,0 +1,201 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable, Injector } from '@angular/core';
import { CoreSitesProvider } from '@providers/sites';
import { AddonModQuizAccessRuleHandler } from '../../../providers/access-rules-delegate';
import { AddonModQuizAccessPasswordComponent } from '../component/password';
/**
* Handler to support password access rule.
*/
@Injectable()
export class AddonModQuizAccessPasswordHandler implements AddonModQuizAccessRuleHandler {
name = 'AddonModQuizAccessPassword';
ruleName = 'quizaccess_password';
// Variables for database.
protected PASSWORD_TABLE = 'mod_quiz_access_password';
protected tableSchema = {
name: this.PASSWORD_TABLE,
columns: [
{
name: 'id',
type: 'INTEGER',
primaryKey: true
},
{
name: 'password',
type: 'TEXT'
},
{
name: 'timemodified',
type: 'INTEGER'
}
]
};
constructor(private sitesProvider: CoreSitesProvider) {
this.sitesProvider.createTableFromSchema(this.tableSchema);
}
/**
* Add preflight data that doesn't require user interaction. The data should be added to the preflightData param.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} preflightData Object where to add the preflight data.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {void|Promise<any>} Promise resolved when done if async, void if it's synchronous.
*/
getFixedPreflightData(quiz: any, preflightData: any, attempt?: any, prefetch?: boolean, siteId?: string): void | Promise<any> {
if (quiz && quiz.id && typeof preflightData.quizpassword == 'undefined') {
// Try to get a password stored. If it's found, use it.
return this.getPasswordEntry(quiz.id, siteId).then((entry) => {
preflightData.quizpassword = entry.password;
}).catch(() => {
// Don't reject.
});
}
}
/**
* Get a password stored in DB.
*
* @param {number} quizId Quiz ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the DB entry on success.
*/
protected getPasswordEntry(quizId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().getRecord(this.PASSWORD_TABLE, {id: quizId});
});
}
/**
* Return the Component to use to display the access rule preflight.
* Implement this if your access rule requires a preflight check with user interaction.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @param {Injector} injector Injector.
* @return {any|Promise<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getPreflightComponent(injector: Injector): any | Promise<any> {
return AddonModQuizAccessPasswordComponent;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return true;
}
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean> {
// If there's a password stored don't require the preflight since we'll use the stored one.
return this.getPasswordEntry(quiz.id, siteId).then(() => {
return false;
}).catch(() => {
// Not stored.
return true;
});
}
/**
* Function called when the preflight check has passed. This is a chance to record that fact in some way.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} attempt The attempt started/continued.
* @param {any} preflightData Preflight data gathered.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {void|Promise<any>} Promise resolved when done if async, void if it's synchronous.
*/
notifyPreflightCheckPassed(quiz: any, attempt: any, preflightData: any, prefetch?: boolean, siteId?: string)
: void | Promise<any> {
// The password is right, store it to use it automatically in following executions.
if (quiz && quiz.id && typeof preflightData.quizpassword != 'undefined') {
return this.storePassword(quiz.id, preflightData.quizpassword, siteId);
}
}
/**
* Function called when the preflight check fails. This is a chance to record that fact in some way.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} attempt The attempt started/continued.
* @param {any} preflightData Preflight data gathered.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {void|Promise<any>} Promise resolved when done if async, void if it's synchronous.
*/
notifyPreflightCheckFailed?(quiz: any, attempt: any, preflightData: any, prefetch?: boolean, siteId?: string)
: void | Promise<any> {
// The password is wrong, remove it from DB if it's there.
if (quiz && quiz.id) {
return this.removePassword(quiz.id, siteId);
}
}
/**
* Remove a password from DB.
*
* @param {number} quizId Quiz ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
protected removePassword(quizId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.getDb().deleteRecords(this.PASSWORD_TABLE, {id: quizId});
});
}
/**
* Store a password in DB.
*
* @param {number} quizId Quiz ID.
* @param {string} password Password.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
protected storePassword(quizId: number, password: string, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const entry = {
id: quizId,
password: password,
timemodified: Date.now()
};
return site.getDb().insertRecord(this.PASSWORD_TABLE, entry);
});
}
}

View File

@ -0,0 +1,52 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { AddonModQuizAccessRuleHandler } from '../../../providers/access-rules-delegate';
/**
* Handler to support safe address access rule.
*/
@Injectable()
export class AddonModQuizAccessSafeBrowserHandler implements AddonModQuizAccessRuleHandler {
name = 'AddonModQuizAccessSafeBrowser';
ruleName = 'quizaccess_safebrowser';
constructor() {
// Nothing to do.
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return true;
}
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean> {
return false;
}
}

View File

@ -0,0 +1,30 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { AddonModQuizAccessSafeBrowserHandler } from './providers/handler';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
@NgModule({
declarations: [
],
providers: [
AddonModQuizAccessSafeBrowserHandler
]
})
export class AddonModQuizAccessSafeBrowserModule {
constructor(accessRuleDelegate: AddonModQuizAccessRuleDelegate, handler: AddonModQuizAccessSafeBrowserHandler) {
accessRuleDelegate.registerHandler(handler);
}
}

View File

@ -0,0 +1,52 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { AddonModQuizAccessRuleHandler } from '../../../providers/access-rules-delegate';
/**
* Handler to support secure window access rule.
*/
@Injectable()
export class AddonModQuizAccessSecureWindowHandler implements AddonModQuizAccessRuleHandler {
name = 'AddonModQuizAccessSecureWindow';
ruleName = 'quizaccess_securewindow';
constructor() {
// Nothing to do.
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return true;
}
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean> {
return false;
}
}

View File

@ -0,0 +1,30 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { AddonModQuizAccessSecureWindowHandler } from './providers/handler';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
@NgModule({
declarations: [
],
providers: [
AddonModQuizAccessSecureWindowHandler
]
})
export class AddonModQuizAccessSecureWindowModule {
constructor(accessRuleDelegate: AddonModQuizAccessRuleDelegate, handler: AddonModQuizAccessSecureWindowHandler) {
accessRuleDelegate.registerHandler(handler);
}
}

View File

@ -0,0 +1,4 @@
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_quiz.confirmstartheader' | translate }}</p>
<p>{{ 'addon.mod_quiz.confirmstart' | translate:{$a: quiz.readableTimeLimit} }}</p>
</ion-item>

View File

@ -0,0 +1,36 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input } from '@angular/core';
import { FormGroup } from '@angular/forms';
/**
* Component to render the preflight for time limit.
*/
@Component({
selector: 'addon-mod-quiz-acess-time-limit',
templateUrl: 'timelimit.html'
})
export class AddonModQuizAccessTimeLimitComponent {
@Input() quiz: any; // The quiz the rule belongs to.
@Input() attempt: any; // The attempt being started/continued.
@Input() prefetch: boolean; // Whether the user is prefetching the quiz.
@Input() siteId: string; // Site ID.
@Input() form: FormGroup; // Form where to add the form control.
constructor() {
// Nothing to do, we don't need to send anything for time limit.
}
}

View File

@ -0,0 +1,79 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable, Injector } from '@angular/core';
import { AddonModQuizAccessRuleHandler } from '../../../providers/access-rules-delegate';
import { AddonModQuizAccessTimeLimitComponent } from '../component/timelimit';
/**
* Handler to support time limit access rule.
*/
@Injectable()
export class AddonModQuizAccessTimeLimitHandler implements AddonModQuizAccessRuleHandler {
name = 'AddonModQuizAccessTimeLimit';
ruleName = 'quizaccess_timelimit';
constructor() {
// Nothing to do.
}
/**
* Return the Component to use to display the access rule preflight.
* Implement this if your access rule requires a preflight check with user interaction.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @param {Injector} injector Injector.
* @return {any|Promise<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getPreflightComponent(injector: Injector): any | Promise<any> {
return AddonModQuizAccessTimeLimitComponent;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return true;
}
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean> {
// Warning only required if the attempt is not already started.
return !attempt;
}
/**
* Whether or not the time left of an attempt should be displayed.
*
* @param {any} attempt The attempt.
* @param {number} endTime The attempt end time (in seconds).
* @param {number} timeNow The current time in seconds.
* @return {boolean} Whether it should be displayed.
*/
shouldShowTimeLeft(attempt: any, endTime: number, timeNow: number): boolean {
// If this is a teacher preview after the time limit expires, don't show the time left.
return !(attempt.preview && timeNow > endTime);
}
}

View File

@ -0,0 +1,46 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { AddonModQuizAccessTimeLimitHandler } from './providers/handler';
import { AddonModQuizAccessTimeLimitComponent } from './component/timelimit';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
@NgModule({
declarations: [
AddonModQuizAccessTimeLimitComponent
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
],
providers: [
AddonModQuizAccessTimeLimitHandler
],
exports: [
AddonModQuizAccessTimeLimitComponent
],
entryComponents: [
AddonModQuizAccessTimeLimitComponent
]
})
export class AddonModQuizAccessTimeLimitModule {
constructor(accessRuleDelegate: AddonModQuizAccessRuleDelegate, handler: AddonModQuizAccessTimeLimitHandler) {
accessRuleDelegate.registerHandler(handler);
}
}

View File

@ -0,0 +1,227 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { PopoverController, Popover } from 'ionic-angular';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
import { AddonModQuizProvider } from '../providers/quiz';
import { AddonModQuizConnectionErrorComponent } from '../components/connection-error/connection-error';
import { BehaviorSubject } from 'rxjs';
/**
* Class to support auto-save in quiz. Every certain seconds, it will check if there are changes in the current page answers
* and, if so, it will save them automatically.
*/
export class AddonModQuizAutoSave {
protected CHECK_CHANGES_INTERVAL = 5000;
protected logger;
protected checkChangesInterval; // Interval to check if there are changes in the answers.
protected loadPreviousAnswersTimeout; // Timeout to load previous answers.
protected autoSaveTimeout; // Timeout to auto-save the answers.
protected popover: Popover; // Popover to display there's been an error.
protected popoverShown = false; // Whether the popover is shown.
protected previousAnswers: any; // The previous answers. It is used to check if answers have changed.
protected errorObservable: BehaviorSubject<boolean>; // An observable to notify if there's been an error.
/**
* Constructor.
*
* @param {string} formName Name of the form where the answers are stored.
* @param {string} buttonSelector Selector to find the button to show the connection error.
* @param {CoreLoggerProvider} loggerProvider CoreLoggerProvider instance.
* @param {PopoverController} popoverCtrl PopoverController instance.
* @param {CoreQuestionHelperProvider} questionHelper CoreQuestionHelperProvider instance.
* @param {AddonModQuizProvider} quizProvider AddonModQuizProvider instance.
*/
constructor(protected formName: string, protected buttonSelector: string, loggerProvider: CoreLoggerProvider,
protected popoverCtrl: PopoverController, protected questionHelper: CoreQuestionHelperProvider,
protected quizProvider: AddonModQuizProvider) {
this.logger = loggerProvider.getInstance('AddonModQuizAutoSave');
// Create the popover.
this.popover = this.popoverCtrl.create(AddonModQuizConnectionErrorComponent);
this.popover.onDidDismiss(() => {
this.popoverShown = false;
});
// Create the observable to notify if an error happened.
this.errorObservable = new BehaviorSubject<boolean>(false);
}
/**
* Cancel a pending auto save.
*/
cancelAutoSave(): void {
clearTimeout(this.autoSaveTimeout);
this.autoSaveTimeout = undefined;
}
/**
* Check if the answers have changed in a page.
*
* @param {any} quiz Quiz.
* @param {any} attempt Attempt.
* @param {any} preflightData Preflight data.
* @param {boolean} [offline] Whether the quiz is being attempted in offline mode.
*/
checkChanges(quiz: any, attempt: any, preflightData: any, offline?: boolean): void {
if (this.autoSaveTimeout) {
// We already have an auto save pending, no need to check changes.
return;
}
const answers = this.getAnswers();
if (!this.previousAnswers) {
// Previous answers isn't set, set it now.
this.previousAnswers = answers;
} else {
// Check if answers have changed.
let equal = true;
for (const name in answers) {
if (this.previousAnswers[name] != answers[name]) {
equal = false;
break;
}
}
if (!equal) {
this.setAutoSaveTimer(quiz, attempt, preflightData, offline);
}
this.previousAnswers = answers;
}
}
/**
* Get answers from a form.
*
* @return {any} Answers.
*/
protected getAnswers(): any {
return this.questionHelper.getAnswersFromForm(document.forms[this.formName]);
}
/**
* Hide the auto save error.
*/
hideAutoSaveError(): void {
this.errorObservable.next(false);
this.popover.dismiss();
}
/**
* Returns an observable that will notify when an error happens or stops.
* It will send true when there's an error, and false when the error has been ammended.
*
* @return {BehaviorSubject<boolean>} Observable.
*/
onError(): BehaviorSubject<boolean> {
return this.errorObservable;
}
/**
* Schedule an auto save process if it's not scheduled already.
*
* @param {any} quiz Quiz.
* @param {any} attempt Attempt.
* @param {any} preflightData Preflight data.
* @param {boolean} [offline] Whether the quiz is being attempted in offline mode.
*/
setAutoSaveTimer(quiz: any, attempt: any, preflightData: any, offline?: boolean): void {
// Don't schedule if already shceduled or quiz is almost closed.
if (quiz.autosaveperiod && !this.autoSaveTimeout && !this.quizProvider.isAttemptTimeNearlyOver(quiz, attempt)) {
// Schedule save.
this.autoSaveTimeout = setTimeout(() => {
const answers = this.getAnswers();
this.cancelAutoSave();
this.previousAnswers = answers; // Update previous answers to match what we're sending to the server.
this.quizProvider.saveAttempt(quiz, attempt, answers, preflightData, offline).then(() => {
// Save successful, we can hide the connection error if it was shown.
this.hideAutoSaveError();
}).catch((error) => {
// Error auto-saving. Show error and set timer again.
this.logger.warn('Error auto-saving data.', error);
// If there was no error already, show the error message.
if (!this.errorObservable.getValue()) {
this.errorObservable.next(true);
this.showAutoSaveError();
}
// Try again.
this.setAutoSaveTimer(quiz, attempt, preflightData, offline);
});
}, quiz.autosaveperiod * 1000);
}
}
/**
* Show an error popover due to an auto save error.
*/
showAutoSaveError(ev?: Event): void {
// Don't show popover if it was already shown.
if (!this.popoverShown) {
this.popoverShown = true;
// If no event is provided, simulate it targeting the button.
this.popover.present({
ev: ev || { target: document.querySelector(this.buttonSelector) }
});
}
}
/**
* Start a process to periodically check changes in answers.
*
* @param {any} quiz Quiz.
* @param {any} attempt Attempt.
* @param {any} preflightData Preflight data.
* @param {boolean} [offline] Whether the quiz is being attempted in offline mode.
*/
startCheckChangesProcess(quiz: any, attempt: any, preflightData: any, offline?: boolean): void {
if (this.checkChangesInterval || !quiz.autosaveperiod) {
// We already have the interval in place or the quiz has autosave disabled.
return;
}
this.previousAnswers = undefined;
// Load initial answers in 2.5 seconds so the first check interval finds them already loaded.
this.loadPreviousAnswersTimeout = setTimeout(() => {
this.checkChanges(quiz, attempt, preflightData, offline);
}, 2500);
// Check changes every certain time.
this.checkChangesInterval = setInterval(() => {
this.checkChanges(quiz, attempt, preflightData, offline);
}, this.CHECK_CHANGES_INTERVAL);
}
/**
* Stops the periodical check for changes.
*/
stopCheckChangesProcess(): void {
clearTimeout(this.loadPreviousAnswersTimeout);
clearInterval(this.checkChangesInterval);
this.loadPreviousAnswersTimeout = undefined;
this.checkChangesInterval = undefined;
}
}

View File

@ -0,0 +1,49 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreCourseComponentsModule } from '@core/course/components/components.module';
import { AddonModQuizIndexComponent } from './index/index';
import { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error';
@NgModule({
declarations: [
AddonModQuizIndexComponent,
AddonModQuizConnectionErrorComponent
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreComponentsModule,
CoreDirectivesModule,
CoreCourseComponentsModule
],
providers: [
],
exports: [
AddonModQuizIndexComponent,
AddonModQuizConnectionErrorComponent
],
entryComponents: [
AddonModQuizIndexComponent,
AddonModQuizConnectionErrorComponent
]
})
export class AddonModQuizComponentsModule {}

View File

@ -0,0 +1,7 @@
addon-mod-quiz-connection-error {
background-color: $red-light;
.item {
background-color: $red-light;
}
}

View File

@ -0,0 +1,29 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
/**
* Component that displays a quiz entry page.
*/
@Component({
selector: 'addon-mod-quiz-connection-error',
template: '<ion-item text-wrap>{{ "addon.mod_quiz.connectionerror" | translate }}</ion-item>',
})
export class AddonModQuizConnectionErrorComponent {
constructor() {
// Nothing to do.
}
}

View File

@ -0,0 +1,132 @@
<!-- Buttons to add to the header. -->
<core-navbar-buttons end>
<core-context-menu>
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate" [href]="externalUrl" [iconAction]="'open'"></core-context-menu-item>
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate" (action)="expandDescription()" [iconAction]="'arrow-forward'"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch()" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="size" [iconDescription]="'cube'" (action)="removeFiles()" [iconAction]="'trash'"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"></core-course-module-description>
<!-- Access rules description messages. -->
<ion-card *ngIf="(quiz && quiz.gradeMethodReadable) || (accessRules && accessRules.length) || syncTime">
<ion-list>
<ion-item text-wrap *ngFor="let rule of accessRules">
<p>{{ rule }}</p>
</ion-item>
<ion-item text-wrap *ngIf="quiz && quiz.gradeMethodReadable">
<p class="item-heading">{{ 'addon.mod_quiz.grademethod' | translate }}</p>
<p>{{ quiz.gradeMethodReadable }}</p>
</ion-item>
<ion-item text-wrap *ngIf="syncTime">
<p class="item-heading">{{ 'core.lastsync' | translate }}</p>
<p>{{ syncTime }}</p>
</ion-item>
</ion-list>
</ion-card>
<!-- List of user attempts. -->
<ion-card class="addon-mod_quiz-table" *ngIf="attempts && attempts.length">
<ion-card-header text-wrap>
<h2>{{ 'addon.mod_quiz.summaryofattempts' | translate }}</h2>
</ion-card-header>
<ion-list>
<!-- "Header" of the table -->
<ion-item text-wrap class="addon-mod_quiz-table-header" detail-push>
<ion-row align-items-center>
<ion-col text-center class="hidden-phone" *ngIf="quiz.showAttemptColumn"><b>{{ 'addon.mod_quiz.attemptnumber' | translate }}</b></ion-col>
<ion-col text-center class="hidden-tablet" *ngIf="quiz.showAttemptColumn"><b>#</b></ion-col>
<ion-col col-7><b>{{ 'addon.mod_quiz.attemptstate' | translate }}</b></ion-col>
<ion-col text-center class="hidden-phone" *ngIf="quiz.showMarkColumn"><b>{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz.sumGradesFormatted }}</b></ion-col>
<ion-col text-center *ngIf="quiz.showGradeColumn"><b>{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz.gradeFormatted }}</b></ion-col>
</ion-row>
</ion-item>
<!-- List of attempts. -->
<a ion-item text-wrap *ngFor="let attempt of attempts" [ngClass]='{"addon-mod_quiz-highlighted core-white-push-arrow": attempt.highlightGrade}' [navPush]="'AddonModQuizAttemptPage'" [navParams]="{courseId: courseId, quizId: quiz.id, attemptId: attempt.id}" [attr.aria-label]="'core.seemoredetail' | translate">
<ion-row align-items-center>
<ion-col text-center *ngIf="quiz.showAttemptColumn && attempt.preview">{{ 'addon.mod_quiz.preview' | translate }}</ion-col>
<ion-col text-center *ngIf="quiz.showAttemptColumn && !attempt.preview">{{ attempt.attempt }}</ion-col>
<ion-col col-7>
<p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
</ion-col>
<ion-col text-center class="hidden-phone" *ngIf="quiz.showMarkColumn"><p>{{ attempt.readableMark }}</p></ion-col>
<ion-col text-center *ngIf="quiz.showGradeColumn"><p>{{ attempt.readableGrade }}</p></ion-col>
</ion-row>
</a>
</ion-list>
</ion-card>
<!-- Result info. -->
<ion-card *ngIf="showResults && (gradeResult || gradeOverridden || gradebookFeedback || (quiz.showFeedbackColumn && overallFeedback))">
<ion-list>
<ion-item text-wrap *ngIf="gradeResult">{{ gradeResult }}</ion-item>
<ion-item text-wrap *ngIf="gradeOverridden">{{ 'core.course.overriddennotice' | translate }}</ion-item>
<ion-item text-wrap *ngIf="gradebookFeedback">
<p class="item-heading">{{ 'addon.mod_quiz.comment' | translate }}</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="gradebookFeedback"></core-format-text></p>
</ion-item>
<ion-item text-wrap *ngIf="quiz.showFeedbackColumn && overallFeedback">
<p class="item-heading">{{ 'addon.mod_quiz.overallfeedback' | translate }}</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="overallFeedback"></core-format-text></p>
</ion-item>
</ion-list>
</ion-card>
<!-- More data and button to start/continue. -->
<ion-card *ngIf="quiz">
<ion-list>
<!-- Error messages. -->
<ion-item text-wrap class="core-danger-item" *ngFor="let message of preventMessages">
<p>{{ message }}</p>
</ion-item>
<ion-item text-wrap class="core-danger-item" *ngIf="quiz.hasquestions === 0">
<p>{{ 'addon.mod_quiz.noquestions' | translate }}</p>
</ion-item>
<ion-item text-wrap class="core-danger-item" *ngIf="unsupportedQuestions && unsupportedQuestions.length">
<p>{{ 'addon.mod_quiz.errorquestionsnotsupported' | translate }}</p>
<p *ngFor="let type of unsupportedQuestions">{{ type }}</p>
</ion-item>
<ion-item text-wrap class="core-danger-item" *ngIf="unsupportedRules && unsupportedRules.length">
<p>{{ 'addon.mod_quiz.errorrulesnotsupported' | translate }}</p>
<p *ngFor="let name of unsupportedRules">{{ name }}</p>
</ion-item>
<ion-item text-wrap class="core-danger-item" *ngIf="behaviourSupported === false">
<p>{{ 'addon.mod_quiz.errorbehaviournotsupported' | translate }}</p>
<p>{{ quiz.preferredbehaviour }}</p>
</ion-item>
<!-- Synchronization warning. -->
<div class="core-warning-card" icon-start *ngIf="buttonText && hasOffline && !showStatusSpinner">
<ion-icon name="warning"></ion-icon>
{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}
</div>
<!-- Button to start/continue. -->
<ion-item *ngIf="buttonText && !showStatusSpinner">
<button ion-button block (click)="attemptQuiz()">
{{ buttonText | translate }}
</button>
</ion-item>
<!-- Button to open in browser if it cannot be attempted in the app. -->
<ion-item *ngIf="!buttonText && ((unsupportedQuestions && unsupportedQuestions.length) || (unsupportedRules && unsupportedRules.length) || behaviourSupported === false)">
<a ion-button block [href]="externalUrl" core-link icon-end>
{{ 'core.openinbrowser' | translate }}
<ion-icon name="open"></ion-icon>
</a>
</ion-item>
<!-- Spinner shown while downloading or calculating. -->
<ion-item text-center *ngIf="showStatusSpinner">
<ion-spinner></ion-spinner>
</ion-item>
</ion-list>
</ion-card>
</core-loading>

View File

@ -0,0 +1,34 @@
addon-mod-quiz-index {
.addon-mod_quiz-table {
.addon-mod_quiz-table-header .item-inner {
background-image: none;
font-size: 0.9em;
}
.item-inner ion-label {
margin-right: 0;
}
.item {
padding-left: 0;
}
.label {
margin-top: 0;
margin-bottom: 0;
}
.item:nth-child(even) {
background-color: $gray-lighter;
}
.addon-mod_quiz-highlighted,
.item.addon-mod_quiz-highlighted,
.addon-mod_quiz-highlighted p,
.item.addon-mod_quiz-highlighted p {
background-color: $blue-light;
color: $blue-dark;
}
}
}

View File

@ -0,0 +1,607 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Optional, Injector } from '@angular/core';
import { Content, NavController } from 'ionic-angular';
import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component';
import { CoreQuestionBehaviourDelegate } from '@core/question/providers/behaviour-delegate';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { AddonModQuizProvider } from '../../providers/quiz';
import { AddonModQuizHelperProvider } from '../../providers/helper';
import { AddonModQuizOfflineProvider } from '../../providers/quiz-offline';
import { AddonModQuizSyncProvider } from '../../providers/quiz-sync';
import { AddonModQuizPrefetchHandler } from '../../providers/prefetch-handler';
import { CoreConstants } from '@core/constants';
/**
* Component that displays a quiz entry page.
*/
@Component({
selector: 'addon-mod-quiz-index',
templateUrl: 'index.html',
})
export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComponent {
component = AddonModQuizProvider.COMPONENT;
moduleName = 'quiz';
quiz: any; // The quiz.
now: number; // Current time.
syncTime: string; // Last synchronization time.
hasOffline: boolean; // Whether the quiz has offline data.
accessRules: string[]; // List of access rules of the quiz.
unsupportedRules: string[]; // List of unsupported access rules of the quiz.
unsupportedQuestions: string[]; // List of unsupported question types of the quiz.
behaviourSupported: boolean; // Whether the quiz behaviour is supported.
showResults: boolean; // Whether to show the result of the quiz (grade, etc.).
gradeOverridden: boolean; // Whether grade has been overridden.
gradebookFeedback: string; // The feedback in the gradebook.
gradeResult: string; // Message with the grade.
overallFeedback: string; // The feedback for the grade.
buttonText: string; // Text to display in the start/continue button.
preventMessages: string[]; // List of messages explaining why the quiz cannot be attempted.
showStatusSpinner = true; // Whether to show a spinner due to quiz status.
protected fetchContentDefaultError = 'addon.mod_quiz.errorgetquiz'; // Default error to show when loading contents.
protected syncEventName = AddonModQuizSyncProvider.AUTO_SYNCED;
protected quizData: any; // Quiz instance. This variable will store the quiz instance until it's ready to be shown
protected autoReview: any; // Data to auto-review an attempt. It's used to automatically open the review page after finishing.
protected quizAccessInfo: any; // Quiz access info.
protected attemptAccessInfo: any; // Last attempt access info.
protected attempts: any[]; // List of attempts the user has made.
protected moreAttempts: boolean; // Whether user can create/continue attempts.
protected options: any; // Combined review options.
protected bestGrade: any; // Best grade data.
protected gradebookData: {grade: number, feedback?: string}; // The gradebook grade and feedback.
protected overallStats: boolean; // Equivalent to overallstats in mod_quiz_view_object in Moodle.
protected finishedObserver: any; // It will observe attempt finished events.
protected hasPlayed = false; // Whether the user has gone to the quiz player (attempted).
constructor(injector: Injector, protected quizProvider: AddonModQuizProvider, @Optional() protected content: Content,
protected quizHelper: AddonModQuizHelperProvider, protected quizOffline: AddonModQuizOfflineProvider,
protected quizSync: AddonModQuizSyncProvider, protected behaviourDelegate: CoreQuestionBehaviourDelegate,
protected prefetchHandler: AddonModQuizPrefetchHandler, protected navCtrl: NavController,
protected prefetchDelegate: CoreCourseModulePrefetchDelegate) {
super(injector);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
super.ngOnInit();
this.loadContent(false, true).then(() => {
if (!this.quizData) {
return;
}
this.quizProvider.logViewQuiz(this.quizData.id).then(() => {
this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
}).catch((error) => {
// Ignore errors.
});
});
// Listen for attempt finished events.
this.finishedObserver = this.eventsProvider.on(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, (data) => {
// Go to review attempt if an attempt in this quiz was finished and synced.
if (this.quizData && data.quizId == this.quizData.id) {
this.autoReview = data;
}
}, this.siteId);
}
/**
* Attempt the quiz.
*/
attemptQuiz(): void {
if (this.showStatusSpinner) {
// Quiz is being downloaded or synchronized, abort.
return;
}
if (this.quizProvider.isQuizOffline(this.quizData)) {
// Quiz supports offline, check if it needs to be downloaded.
// If the site doesn't support check updates, always prefetch it because we cannot tell if there's something new.
const isDownloaded = this.currentStatus == CoreConstants.DOWNLOADED;
if (!isDownloaded || !this.prefetchDelegate.canCheckUpdates()) {
// Prefetch the quiz.
this.showStatusSpinner = true;
this.prefetchHandler.prefetch(this.module, this.courseId, true).then(() => {
// Success downloading, open quiz.
this.openQuiz();
}).catch((error) => {
if (this.hasOffline || (isDownloaded && !this.prefetchDelegate.canCheckUpdates())) {
// Error downloading but there is something offline, allow continuing it.
// If the site doesn't support check updates, continue too because we cannot tell if there's something new.
this.openQuiz();
} else {
this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true);
}
}).finally(() => {
this.showStatusSpinner = false;
});
} else {
// Already downloaded, open it.
this.openQuiz();
}
} else {
// Quiz isn't offline, just open it.
this.openQuiz();
}
}
/**
* Get the quiz data.
*
* @param {boolean} [refresh=false] If it's refreshing content.
* @param {boolean} [sync=false] If the refresh is needs syncing.
* @param {boolean} [showErrors=false] If show errors to the user of hide them.
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<any> {
// First get the quiz instance.
return this.quizProvider.getQuiz(this.courseId, this.module.id).then((quizData) => {
this.quizData = quizData;
this.quizData.gradeMethodReadable = this.quizProvider.getQuizGradeMethod(this.quizData.grademethod);
this.now = new Date().getTime();
this.dataRetrieved.emit(this.quizData);
this.description = this.quizData.intro || this.description;
// Try to get warnings from automatic sync.
return this.quizSync.getSyncWarnings(this.quizData.id).then((warnings) => {
if (warnings && warnings.length) {
// Show warnings and delete them so they aren't shown again.
this.domUtils.showErrorModal(this.textUtils.buildMessage(warnings));
return this.quizSync.setSyncWarnings(this.quizData.id, []);
}
});
}).then(() => {
if (this.quizProvider.isQuizOffline(this.quizData)) {
// Try to sync the quiz.
return this.syncActivity(showErrors).catch(() => {
// Ignore errors, keep getting data even if sync fails.
this.autoReview = undefined;
});
} else {
this.autoReview = undefined;
this.showStatusSpinner = false;
}
}).then(() => {
if (this.quizProvider.isQuizOffline(this.quizData)) {
// Handle status.
this.setStatusListener();
// Get last synchronization time and check if sync button should be seen.
// No need to return these promises, they should be faster than the rest.
this.quizSync.getReadableSyncTime(this.quizData.id).then((syncTime) => {
this.syncTime = syncTime;
});
this.quizSync.hasDataToSync(this.quizData.id).then((hasOffline) => {
this.hasOffline = hasOffline;
});
}
// Get quiz access info.
return this.quizProvider.getQuizAccessInformation(this.quizData.id).then((info) => {
this.quizAccessInfo = info;
this.quizData.showReviewColumn = info.canreviewmyattempts;
this.accessRules = info.accessrules;
this.unsupportedRules = this.quizProvider.getUnsupportedRules(info.activerulenames);
if (this.quizData.preferredbehaviour) {
this.behaviourSupported = this.behaviourDelegate.isBehaviourSupported(this.quizData.preferredbehaviour);
}
// Get question types in the quiz.
return this.quizProvider.getQuizRequiredQtypes(this.quizData.id).then((types) => {
this.unsupportedQuestions = this.quizProvider.getUnsupportedQuestions(types);
return this.getAttempts();
});
});
}).then(() => {
// All data obtained, now fill the context menu.
this.fillContextMenu(refresh);
// Quiz is ready to be shown, move it to the variable that is displayed.
this.quiz = this.quizData;
});
}
/**
* Get the user attempts in the quiz and the result info.
*
* @return {Promise<void>} Promise resolved when done.
*/
protected getAttempts(): Promise<void> {
// Get access information of last attempt (it also works if no attempts made).
return this.quizProvider.getAttemptAccessInformation(this.quizData.id, 0).then((info) => {
this.attemptAccessInfo = info;
// Get attempts.
return this.quizProvider.getUserAttempts(this.quizData.id).then((atts) => {
return this.treatAttempts(atts).then((atts) => {
this.attempts = atts;
// Check if user can create/continue attempts.
if (this.attempts.length) {
const last = this.attempts[this.attempts.length - 1];
this.moreAttempts = !this.quizProvider.isAttemptFinished(last.state) || !this.attemptAccessInfo.isfinished;
} else {
this.moreAttempts = !this.attemptAccessInfo.isfinished;
}
this.getButtonText();
return this.getResultInfo();
});
});
});
}
/**
* Get the text to show in the button. It also sets restriction messages if needed.
*/
protected getButtonText(): void {
this.buttonText = '';
if (this.quizData.hasquestions !== 0) {
if (this.attempts.length && !this.quizProvider.isAttemptFinished(this.attempts[this.attempts.length - 1].state)) {
// Last attempt is unfinished.
if (this.quizAccessInfo.canattempt) {
this.buttonText = 'addon.mod_quiz.continueattemptquiz';
} else if (this.quizAccessInfo.canpreview) {
this.buttonText = 'addon.mod_quiz.continuepreview';
}
} else {
// Last attempt is finished or no attempts.
if (this.quizAccessInfo.canattempt) {
this.preventMessages = this.attemptAccessInfo.preventnewattemptreasons;
if (!this.preventMessages.length) {
if (!this.attempts.length) {
this.buttonText = 'addon.mod_quiz.attemptquiznow';
} else {
this.buttonText = 'addon.mod_quiz.reattemptquiz';
}
}
} else if (this.quizAccessInfo.canpreview) {
this.buttonText = 'addon.mod_quiz.previewquiznow';
}
}
}
if (this.buttonText) {
// So far we think a button should be printed, check if they will be allowed to access it.
this.preventMessages = this.quizAccessInfo.preventaccessreasons;
if (!this.moreAttempts) {
this.buttonText = '';
} else if (this.quizAccessInfo.canattempt && this.preventMessages.length) {
this.buttonText = '';
} else if (this.unsupportedQuestions.length || this.unsupportedRules.length || !this.behaviourSupported) {
this.buttonText = '';
}
}
}
/**
* Get result info to show.
*
* @return {Promise<void>} Promise resolved when done.
*/
protected getResultInfo(): Promise<void> {
if (this.attempts.length && this.quizData.showGradeColumn && this.bestGrade.hasgrade &&
typeof this.gradebookData.grade != 'undefined') {
const formattedGradebookGrade = this.quizProvider.formatGrade(this.gradebookData.grade, this.quizData.decimalpoints),
formattedBestGrade = this.quizProvider.formatGrade(this.bestGrade.grade, this.quizData.decimalpoints);
let gradeToShow = formattedGradebookGrade; // By default we show the grade in the gradebook.
this.showResults = true;
this.gradeOverridden = formattedGradebookGrade != formattedBestGrade;
this.gradebookFeedback = this.gradebookData.feedback;
if (this.bestGrade.grade > this.gradebookData.grade && this.gradebookData.grade == this.quizData.grade) {
// The best grade is higher than the max grade for the quiz.
// We'll do like Moodle web and show the best grade instead of the gradebook grade.
this.gradeOverridden = false;
gradeToShow = formattedBestGrade;
}
if (this.overallStats) {
// Show the quiz grade. The message shown is different if the quiz is finished.
if (this.moreAttempts) {
this.gradeResult = this.translate.instant('addon.mod_quiz.gradesofar', {$a: {
method: this.quizData.gradeMethodReadable,
mygrade: gradeToShow,
quizgrade: this.quizData.gradeFormatted
}});
} else {
const outOfShort = this.translate.instant('addon.mod_quiz.outofshort', {$a: {
grade: gradeToShow,
maxgrade: this.quizData.gradeFormatted
}});
this.gradeResult = this.translate.instant('addon.mod_quiz.yourfinalgradeis', {$a: outOfShort});
}
}
if (this.quizData.showFeedbackColumn) {
// Get the quiz overall feedback.
return this.quizProvider.getFeedbackForGrade(this.quizData.id, this.gradebookData.grade).then((response) => {
this.overallFeedback = response.feedbacktext;
});
}
} else {
this.showResults = false;
}
return Promise.resolve();
}
/**
* Go to review an attempt that has just been finished.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected goToAutoReview(): Promise<any> {
// If we go to auto review it means an attempt was finished. Check completion status.
this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
// Verify that user can see the review.
const attemptId = this.autoReview.attemptId;
if (this.quizAccessInfo.canreviewmyattempts) {
return this.quizProvider.getAttemptReview(attemptId, -1).then(() => {
this.navCtrl.push('AddonModQuizReviewPage', {courseId: this.courseId, quizId: this.quizData.id, attemptId});
}).catch(() => {
// Ignore errors.
});
}
return Promise.resolve();
}
/**
* Checks if sync has succeed from result sync data.
*
* @param {any} result Data returned on the sync function.
* @return {boolean} If suceed or not.
*/
protected hasSyncSucceed(result: any): boolean {
if (result.attemptFinished) {
// An attempt was finished, check completion status.
this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
}
// If the sync call isn't rejected it means the sync was successful.
return result.answersSent;
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
super.ionViewDidEnter();
if (this.hasPlayed) {
this.hasPlayed = false;
// Update data when we come back from the player since the attempt status could have changed.
let promise;
// Check if we need to go to review an attempt automatically.
if (this.autoReview && this.autoReview.synced) {
promise = this.goToAutoReview();
this.autoReview = undefined;
} else {
promise = Promise.resolve();
}
// Refresh data.
this.loaded = false;
this.refreshIcon = 'spinner';
this.syncIcon = 'spinner';
this.content.scrollToTop();
promise.then(() => {
this.refreshContent().finally(() => {
this.loaded = true;
this.refreshIcon = 'refresh';
this.syncIcon = 'sync';
});
});
} else {
this.autoReview = undefined;
}
}
/**
* User left the page that contains the component.
*/
ionViewDidLeave(): void {
super.ionViewDidLeave();
this.autoReview = undefined;
if (this.navCtrl.getActive().component.name == 'AddonModQuizPlayerPage') {
this.hasPlayed = true;
}
}
/**
* Perform the invalidate content function.
*
* @return {Promise<any>} Resolved when done.
*/
protected invalidateContent(): Promise<any> {
const promises = [];
promises.push(this.quizProvider.invalidateQuizData(this.courseId));
if (this.quizData) {
promises.push(this.quizProvider.invalidateUserAttemptsForUser(this.quizData.id));
promises.push(this.quizProvider.invalidateQuizAccessInformation(this.quizData.id));
promises.push(this.quizProvider.invalidateQuizRequiredQtypes(this.quizData.id));
promises.push(this.quizProvider.invalidateAttemptAccessInformation(this.quizData.id));
promises.push(this.quizProvider.invalidateCombinedReviewOptionsForUser(this.quizData.id));
promises.push(this.quizProvider.invalidateUserBestGradeForUser(this.quizData.id));
promises.push(this.quizProvider.invalidateGradeFromGradebook(this.courseId));
}
return Promise.all(promises);
}
/**
* Compares sync event data with current data to check if refresh content is needed.
*
* @param {any} syncEventData Data receiven on sync observer.
* @return {boolean} True if refresh is needed, false otherwise.
*/
protected isRefreshSyncNeeded(syncEventData: any): boolean {
if (syncEventData.attemptFinished) {
// An attempt was finished, check completion status.
this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
}
if (this.quizData && syncEventData.quizId == this.quizData.id) {
this.content.scrollToTop();
return true;
}
return false;
}
/**
* Open a quiz to attempt it.
*/
protected openQuiz(): void {
this.navCtrl.push('AddonModQuizPlayerPage', {courseId: this.courseId, quizId: this.quiz.id, moduleUrl: this.module.url});
}
/**
* Displays some data based on the current status.
*
* @param {string} status The current status.
* @param {string} [previousStatus] The previous status. If not defined, there is no previous status.
*/
protected showStatus(status: string, previousStatus?: string): void {
this.showStatusSpinner = status == CoreConstants.DOWNLOADING;
}
/**
* Performs the sync of the activity.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected sync(): Promise<any> {
return this.quizSync.syncQuiz(this.quizData, true);
}
/**
* Treat user attempts.
*
* @param {any} attempts The attempts to treat.
* @return {Promise<void>} Promise resolved when done.
*/
protected treatAttempts(attempts: any): Promise<any> {
if (!attempts || !attempts.length) {
// There are no attempts to treat.
return Promise.resolve(attempts);
}
const lastFinished = this.quizProvider.getLastFinishedAttemptFromList(attempts),
promises = [];
if (this.autoReview && lastFinished && lastFinished.id >= this.autoReview.attemptId) {
// User just finished an attempt in offline and it seems it's been synced, since it's finished in online.
// Go to the review of this attempt if the user hasn't left this view.
if (!this.isDestroyed && this.isCurrentView) {
promises.push(this.goToAutoReview());
}
this.autoReview = undefined;
}
// Load flag to show if attempts are finished but not synced.
promises.push(this.quizProvider.loadFinishedOfflineData(attempts));
// Get combined review options.
promises.push(this.quizProvider.getCombinedReviewOptions(this.quizData.id).then((result) => {
this.options = result;
}));
// Get best grade.
promises.push(this.quizProvider.getUserBestGrade(this.quizData.id).then((best) => {
this.bestGrade = best;
// Get gradebook grade.
return this.quizProvider.getGradeFromGradebook(this.courseId, this.module.id).then((data) => {
this.gradebookData = {
grade: data.graderaw,
feedback: data.feedback
};
}).catch(() => {
// Fallback to quiz best grade if failure or not found.
this.gradebookData = {
grade: this.bestGrade.grade
};
});
}));
return Promise.all(promises).then(() => {
const grade: number = typeof this.gradebookData.grade != 'undefined' ? this.gradebookData.grade : this.bestGrade.grade,
quizGrade = this.quizProvider.formatGrade(grade, this.quizData.decimalpoints);
// Calculate data to construct the header of the attempts table.
this.quizHelper.setQuizCalculatedData(this.quizData, this.options);
this.overallStats = lastFinished && this.options.alloptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX;
// Calculate data to show for each attempt.
attempts.forEach((attempt) => {
// Highlight the highest grade if appropriate.
const shouldHighlight = this.overallStats && this.quizData.grademethod == AddonModQuizProvider.GRADEHIGHEST &&
attempts.length > 1;
this.quizHelper.setAttemptCalculatedData(this.quizData, attempt, shouldHighlight, quizGrade);
});
return attempts;
});
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
super.ngOnDestroy();
this.finishedObserver && this.finishedObserver.off();
}
}

View File

@ -0,0 +1,79 @@
{
"attemptfirst": "First attempt",
"attemptlast": "Last attempt",
"attemptnumber": "Attempt",
"attemptquiznow": "Attempt quiz now",
"attemptstate": "State",
"cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:",
"comment": "Comment",
"completedon": "Completed on",
"confirmclose": "Once you submit, you will no longer be able to change your answers for this attempt.",
"confirmcontinueoffline": "This attempt has not been synchronised since {{$a}}. If you have continued this attempt in another device since then, you may lose data.",
"confirmleavequizonerror": "An error occurred while saving the answers. Are you sure you want to leave the quiz?",
"confirmstart": "The quiz has a time limit of {{$a}}. Time will count down from the moment you start your attempt and you must submit before it expires. Are you sure that you wish to start now?",
"confirmstartheader": "Timed quiz",
"connectionerror": "Network connection lost. (Autosave failed).\n\nMake a note of any responses entered on this page in the last few minutes, then try to re-connect.\n\nOnce connection has been re-established, your responses should be saved and this message will disappear.",
"continueattemptquiz": "Continue the last attempt",
"continuepreview": "Continue the last preview",
"errorbehaviournotsupported": "This quiz can't be attempted in the app because the question behaviour is not supported by the app:",
"errordownloading": "Error downloading required data.",
"errorgetattempt": "Error getting attempt data.",
"errorgetquestions": "Error getting questions.",
"errorgetquiz": "Error getting quiz data.",
"errorparsequestions": "An error occurred while reading the questions. Please attempt this quiz in a web browser.",
"errorquestionsnotsupported": "This quiz can't be attempted in the app because it contains questions not supported by the app:",
"errorrulesnotsupported": "This quiz can't be attempted in the app because it has access rules not supported by the app:",
"errorsaveattempt": "An error occurred while saving the attempt data.",
"feedback": "Feedback",
"finishattemptdots": "Finish attempt...",
"finishnotsynced": "Finished but not synchronised",
"grade": "Grade",
"gradeaverage": "Average grade",
"gradehighest": "Highest grade",
"grademethod": "Grading method",
"gradesofar": "{{$a.method}}: {{$a.mygrade}} / {{$a.quizgrade}}.",
"hasdatatosync": "This quiz has offline data to be synchronised.",
"marks": "Marks",
"mustbesubmittedby": "This attempt must be submitted by {{$a}}.",
"noquestions": "No questions have been added yet",
"noreviewattempt": "You are not allowed to review this attempt.",
"notyetgraded": "Not yet graded",
"opentoc": "Open navigation popover",
"outof": "{{$a.grade}} out of {{$a.maxgrade}}",
"outofpercent": "{{$a.grade}} out of {{$a.maxgrade}} ({{$a.percent}}%)",
"outofshort": "{{$a.grade}}/{{$a.maxgrade}}",
"overallfeedback": "Overall feedback",
"overdue": "Overdue",
"overduemustbesubmittedby": "This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {{$a}}. If you do not submit it by then, no marks from this attempt will be counted.",
"preview": "Preview",
"previewquiznow": "Preview quiz now",
"question": "Question",
"quiznavigation": "Quiz navigation",
"quizpassword": "Quiz password",
"reattemptquiz": "Re-attempt quiz",
"requirepasswordmessage": "To attempt this quiz you need to know the quiz password",
"returnattempt": "Return to attempt",
"review": "Review",
"reviewofattempt": "Review of attempt {{$a}}",
"reviewofpreview": "Review of preview",
"showall": "Show all questions on one page",
"showeachpage": "Show one page at a time",
"startattempt": "Start attempt",
"startedon": "Started on",
"stateabandoned": "Never submitted",
"statefinished": "Finished",
"statefinisheddetails": "Submitted {{$a}}",
"stateinprogress": "In progress",
"stateoverdue": "Overdue",
"stateoverduedetails": "Must be submitted by {{$a}}",
"status": "Status",
"submitallandfinish": "Submit all and finish",
"summaryofattempt": "Summary of attempt",
"summaryofattempts": "Summary of your previous attempts",
"timeleft": "Time left",
"timetaken": "Time taken",
"warningattemptfinished": "Offline attempt discarded as it was finished on the site or not found.",
"warningdatadiscarded": "Some offline answers were discarded because the questions were modified online.",
"warningdatadiscardedfromfinished": "Attempt unfinished because some offline answers were discarded. Please review your answers then resubmit the attempt.",
"yourfinalgradeis": "Your final grade for this quiz is {{$a}}."
}

View File

@ -0,0 +1,44 @@
<ion-header>
<ion-navbar>
<ion-title><core-format-text *ngIf="quiz" [text]="quiz.name"></core-format-text></ion-title>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-list *ngIf="attempt">
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_quiz.attemptnumber' | translate }}</p>
<p *ngIf="attempt.preview">{{ 'addon.mod_quiz.preview' | translate }}</p>
<p *ngIf="!attempt.preview">{{ attempt.attempt }}</p>
</ion-item>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
</ion-item>
<ion-item text-wrap *ngIf="quiz.showMarkColumn && attempt.readableMark !== ''">
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz.sumGradesFormatted }}</p>
<p>{{ attempt.readableMark }}</p>
</ion-item>
<ion-item text-wrap *ngIf="quiz.showGradeColumn && attempt.readableGrade !== ''">
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz.gradeFormatted }}</p>
<p>{{ attempt.readableGrade }}</p>
</ion-item>
<ion-item text-wrap *ngIf="quiz.showFeedbackColumn && attempt.feedback">
<p class="item-heading">{{ 'addon.mod_quiz.feedback' | translate }}</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="attempt.feedback"></core-format-text></p>
</ion-item>
<ion-item *ngIf="quiz.showReviewColumn && attempt.finished">
<button ion-button block icon-start [navPush]="'AddonModQuizReviewPage'" [navParams]="{courseId: courseId, quizId: quiz.id, attemptId: attempt.id}">
<ion-icon name="search"></ion-icon>
{{ 'addon.mod_quiz.review' | translate }}
</button>
</ion-item>
<ion-item text-wrap class="core-danger-item" *ngIf="!quiz.showReviewColumn">
<p>{{ 'addon.mod_quiz.noreviewattempt' | translate }}</p>
</ion-item>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModQuizAttemptPage } from './attempt';
@NgModule({
declarations: [
AddonModQuizAttemptPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
IonicPageModule.forChild(AddonModQuizAttemptPage),
TranslateModule.forChild()
],
})
export class AddonModQuizAttemptPageModule {}

View File

@ -0,0 +1,177 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { IonicPage, NavParams } from 'ionic-angular';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { AddonModQuizProvider } from '../../providers/quiz';
import { AddonModQuizHelperProvider } from '../../providers/helper';
/**
* Page that displays some summary data about an attempt.
*/
@IonicPage({ segment: 'addon-mod-quiz-attempt' })
@Component({
selector: 'page-addon-mod-quiz-attempt',
templateUrl: 'attempt.html',
})
export class AddonModQuizAttemptPage implements OnInit {
courseId: number; // The course ID the quiz belongs to.
quiz: any; // The quiz the attempt belongs to.
attempt: any; // The attempt to view.
component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
componentId: number; // Component ID to use in conjunction with the component.
loaded: boolean; // Whether data has been loaded.
protected attemptId: number; // Attempt to view.
protected quizId: number; // ID of the quiz the attempt belongs to.
constructor(navParams: NavParams, protected domUtils: CoreDomUtilsProvider, protected quizProvider: AddonModQuizProvider,
protected quizHelper: AddonModQuizHelperProvider) {
this.attemptId = navParams.get('attemptId');
this.quizId = navParams.get('quizId');
this.courseId = navParams.get('courseId');
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.fetchQuizData().finally(() => {
this.loaded = true;
});
}
/**
* Refresh the data.
*
* @param {any} refresher Refresher.
*/
doRefresh(refresher: any): void {
this.refreshData().finally(() => {
refresher.complete();
});
}
/**
* Get quiz data and attempt data.
*
* @return {Promise<void>} Promise resolved when done.
*/
protected fetchQuizData(): Promise<void> {
return this.quizProvider.getQuizById(this.courseId, this.quizId).then((quizData) => {
this.quiz = quizData;
this.componentId = this.quiz.coursemodule;
return this.fetchAttempt();
}).catch((message) => {
this.domUtils.showErrorModalDefault(message, 'addon.mod_quiz.errorgetattempt', true);
});
}
/**
* Get the attempt data.
*
* @return {Promise<void>} Promise resolved when done.
*/
protected fetchAttempt(): Promise<void> {
const promises = [];
let options,
accessInfo;
// Get all the attempts and search the one we want.
promises.push(this.quizProvider.getUserAttempts(this.quizId).then((attempts) => {
for (let i = 0; i < attempts.length; i++) {
const attempt = attempts[i];
if (attempt.id == this.attemptId) {
this.attempt = attempt;
break;
}
}
if (!this.attempt) {
// Attempt not found, error.
return Promise.reject(null);
}
// Load flag to show if attempt is finished but not synced.
return this.quizProvider.loadFinishedOfflineData([this.attempt]);
}));
promises.push(this.quizProvider.getCombinedReviewOptions(this.quiz.id).then((opts) => {
options = opts;
}));
// Check if the user can review the attempt.
promises.push(this.quizProvider.getQuizAccessInformation(this.quiz.id).then((quizAccessInfo) => {
accessInfo = quizAccessInfo;
if (accessInfo.canreviewmyattempts) {
return this.quizProvider.getAttemptReview(this.attemptId, -1).catch(() => {
// Error getting the review, assume the user cannot review the attempt.
accessInfo.canreviewmyattempts = false;
});
}
}));
return Promise.all(promises).then(() => {
// Determine fields to show.
this.quizHelper.setQuizCalculatedData(this.quiz, options);
this.quiz.showReviewColumn = accessInfo.canreviewmyattempts;
// Get readable data for the attempt.
this.quizHelper.setAttemptCalculatedData(this.quiz, this.attempt, false);
// Check if the feedback should be displayed.
const grade = Number(this.attempt.rescaledGrade);
if (this.quiz.showFeedbackColumn && this.quizProvider.isAttemptFinished(this.attempt.state) &&
options.someoptions.overallfeedback && !isNaN(grade)) {
// Feedback should be displayed, get the feedback for the grade.
return this.quizProvider.getFeedbackForGrade(this.quiz.id, grade).then((response) => {
this.attempt.feedback = response.feedbacktext;
});
} else {
delete this.attempt.feedback;
}
});
}
/**
* Refresh the data.
*
* @return {Promise<void>} Promise resolved when done.
*/
protected refreshData(): Promise<void> {
const promises = [];
promises.push(this.quizProvider.invalidateQuizData(this.courseId));
promises.push(this.quizProvider.invalidateUserAttemptsForUser(this.quizId));
promises.push(this.quizProvider.invalidateQuizAccessInformation(this.quizId));
promises.push(this.quizProvider.invalidateCombinedReviewOptionsForUser(this.quizId));
promises.push(this.quizProvider.invalidateAttemptReview(this.attemptId));
if (this.attempt && typeof this.attempt.feedback != 'undefined') {
promises.push(this.quizProvider.invalidateFeedback(this.quizId));
}
return Promise.all(promises).catch(() => {
// Ignore errors.
}).then(() => {
return this.fetchQuizData();
});
}
}

View File

@ -0,0 +1,16 @@
<ion-header>
<ion-navbar>
<ion-title><core-format-text [text]="title"></core-format-text></ion-title>
<ion-buttons end>
<!-- The buttons defined by the component will be added in here. -->
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="quizComponent.loaded" (ionRefresh)="quizComponent.doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<addon-mod-quiz-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)"></addon-mod-quiz-index>
</ion-content>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModQuizComponentsModule } from '../../components/components.module';
import { AddonModQuizIndexPage } from './index';
@NgModule({
declarations: [
AddonModQuizIndexPage,
],
imports: [
CoreDirectivesModule,
AddonModQuizComponentsModule,
IonicPageModule.forChild(AddonModQuizIndexPage),
TranslateModule.forChild()
],
})
export class AddonModQuizIndexPageModule {}

View File

@ -0,0 +1,62 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewChild } from '@angular/core';
import { IonicPage, NavParams } from 'ionic-angular';
import { AddonModQuizIndexComponent } from '../../components/index/index';
/**
* Page that displays the quiz entry page.
*/
@IonicPage({ segment: 'addon-mod-quiz-index' })
@Component({
selector: 'page-addon-mod-quiz-index',
templateUrl: 'index.html',
})
export class AddonModQuizIndexPage {
@ViewChild(AddonModQuizIndexComponent) quizComponent: AddonModQuizIndexComponent;
title: string;
module: any;
courseId: number;
constructor(navParams: NavParams) {
this.module = navParams.get('module') || {};
this.courseId = navParams.get('courseId');
this.title = this.module.name;
}
/**
* Update some data based on the quiz instance.
*
* @param {any} quiz Quiz instance.
*/
updateData(quiz: any): void {
this.title = quiz.name || this.title;
}
/**
* User entered the page.
*/
ionViewDidEnter(): void {
this.quizComponent.ionViewDidEnter();
}
/**
* User left the page.
*/
ionViewDidLeave(): void {
this.quizComponent.ionViewDidLeave();
}
}

View File

@ -0,0 +1,41 @@
<ion-header>
<ion-navbar>
<ion-title>{{ 'addon.mod_quiz.quiznavigation' | translate }}</ion-title>
<ion-buttons end>
<button ion-button icon-only (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="close"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content class="addon-mod_quiz-navigation-modal">
<nav>
<ion-list>
<!-- In player, show button to finish attempt. -->
<a ion-item text-wrap *ngIf="!isReview" (click)="loadPage(-1)">
{{ 'addon.mod_quiz.finishattemptdots' | translate }}
</a>
<!-- In review we can toggle between all questions in same page or one page at a time. -->
<a ion-item text-wrap *ngIf="isReview && pageInstance.numPages > 1" (click)="switchMode()">
<span *ngIf="!pageInstance.showAll">{{ 'addon.mod_quiz.showall' | translate }}</span>
<span *ngIf="pageInstance.showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span>
</a>
<a ion-item text-wrap *ngFor="let question of pageInstance.navigation" class="{{question.stateClass}}" [ngClass]='{"addon-mod_quiz-selected": !pageInstance.showSummary && pageInstance.attempt.currentpage == question.page}' (click)="loadPage(question.page, question.slot)">
<span *ngIf="question.number">{{ 'core.question.questionno' | translate:{$a: question.number} }}</span>
<span *ngIf="!question.number">{{ 'core.question.information' | translate }}</span>
</a>
<!-- In player, show button to finish attempt. -->
<a ion-item text-wrap *ngIf="!isReview" (click)="loadPage(-1)">
{{ 'addon.mod_quiz.finishattemptdots' | translate }}
</a>
<!-- In review we can toggle between all questions in same page or one page at a time. -->
<a ion-item text-wrap *ngIf="isReview && pageInstance.numPages > 1" (click)="switchMode()">
<span *ngIf="!pageInstance.showAll">{{ 'addon.mod_quiz.showall' | translate }}</span>
<span *ngIf="pageInstance.showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span>
</a>
</ion-list>
</nav>
</ion-content>

View File

@ -0,0 +1,29 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { AddonModQuizNavigationModalPage } from './navigation-modal';
import { TranslateModule } from '@ngx-translate/core';
@NgModule({
declarations: [
AddonModQuizNavigationModalPage
],
imports: [
IonicPageModule.forChild(AddonModQuizNavigationModalPage),
TranslateModule.forChild()
]
})
export class AddonModQuizNavigationModalPageModule {}

View File

@ -0,0 +1,5 @@
page-addon-mod-quiz-navigation-modal {
.addon-mod_quiz-selected, .item.addon-mod_quiz-selected {
background: $blue-light;
}
}

View File

@ -0,0 +1,69 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { IonicPage, ViewController, NavParams } from 'ionic-angular';
/**
* Modal that renders the quiz navigation.
*/
@IonicPage({ segment: 'addon-mod-quiz-navigation-modal' })
@Component({
selector: 'page-addon-mod-quiz-navigation-modal',
templateUrl: 'navigation-modal.html',
})
export class AddonModQuizNavigationModalPage {
/**
* The instance of the page that opened the modal. We use the instance instead of the needed attributes for these reasons:
* - Some attributes can change dynamically, and we don't want to create the modal everytime the user opens it.
* - The onDidDismiss function takes a while to be called, making the app seem slow. This way we can directly call
* the functions we need without having to wait for the modal to be dismissed.
* @type {any}
*/
pageInstance: any;
isReview: boolean; // Whether the user is reviewing the attempt.
constructor(params: NavParams, protected viewCtrl: ViewController) {
this.isReview = !!params.get('isReview');
this.pageInstance = params.get('page');
}
/**
* Close modal.
*/
closeModal(): void {
this.viewCtrl.dismiss();
}
/**
* Load a certain page.
*
* @param {number} page The page to load.
* @param {number} [slot] Slot of the question to scroll to.
*/
loadPage(page: number, slot: number): void {
this.pageInstance.changePage && this.pageInstance.changePage(page, true, slot);
this.closeModal();
}
/**
* Switch mode in review.
*/
switchMode(): void {
this.pageInstance.switchMode && this.pageInstance.switchMode();
this.closeModal();
}
}

View File

@ -0,0 +1,143 @@
<ion-header>
<ion-navbar>
<ion-title><core-format-text *ngIf="quiz" [text]="quiz.name"></core-format-text></ion-title>
<ion-buttons end>
<button id="addon-mod_quiz-connection-error-button" ion-button icon-only [hidden]="!autoSaveError" (click)="showConnectionError($event)" [attr.aria-label]="'core.error' | translate">
<ion-icon name="alert"></ion-icon>
</button>
<button *ngIf="navigation && navigation.length" ion-button icon-only [attr.aria-label]="'addon.mod_quiz.opentoc' | translate" (click)="navigationModal.present()">
<ion-icon name="bookmark"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content>
<!-- Navigation arrows and time left. -->
<ion-toolbar *ngIf="loaded && endTime && questions && questions.length && !quizAborted && !showSummary" color="light" ion-fixed>
<ion-title>
<core-timer [endTime]="endTime" (finished)="timeUp()" [timerText]="'addon.mod_quiz.timeleft' | translate" align="center"></core-timer>
</ion-title>
<ion-buttons end>
<a ion-button icon-only *ngIf="previousPage >= 0" (click)="changePage(previousPage)" [title]="'core.previous' | translate">
<ion-icon name="arrow-back" md="ios-arrow-back"></ion-icon>
</a>
<a ion-button icon-only *ngIf="nextPage >= -1" (click)="changePage(nextPage)" [title]="'core.next' | translate">
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
</a>
</ion-buttons>
</ion-toolbar>
<core-loading [hideUntil]="loaded" [class.core-has-fixed-timer]="endTime">
<!-- Navigation arrows and time left. -->
<ion-toolbar *ngIf="!endTime && questions && questions.length && !quizAborted && !showSummary" color="light">
<ion-buttons end>
<a ion-button icon-only *ngIf="previousPage >= 0" (click)="changePage(previousPage)" [title]="'core.previous' | translate">
<ion-icon name="arrow-back" md="ios-arrow-back"></ion-icon>
</a>
<a ion-button icon-only *ngIf="nextPage >= -1" (click)="changePage(nextPage)" [title]="'core.next' | translate">
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
</a>
</ion-buttons>
</ion-toolbar>
<!-- Button to start attempting. -->
<div padding *ngIf="!attempt">
<button ion-button block (click)="start()">{{ 'addon.mod_quiz.startattempt' | translate }}</button>
</div>
<!-- Questions -->
<form name="addon-mod_quiz-player-form" *ngIf="questions && questions.length && !quizAborted && !showSummary">
<div *ngFor="let question of questions">
<ion-card id="addon-mod_quiz-question-{{question.slot}}">
<!-- "Header" of the question. -->
<ion-item-divider color="light">
<h2 *ngIf="question.number" class="inline">{{ 'core.question.questionno' | translate:{$a: question.number} }}</h2>
<h2 *ngIf="!question.number" class="inline">{{ 'core.question.information' | translate }}</h2>
<ion-note text-wrap item-end *ngIf="question.status || question.readableMark">
<p *ngIf="question.status" class="block">{{question.status}}</p>
<p *ngIf="question.readableMark"><core-format-text [text]="question.readableMark"></core-format-text></p>
</ion-note>
</ion-item-divider>
<!-- Body of the question. -->
<core-question text-wrap [question]="question" [component]="component" [componentId]="quiz.coursemodule" [attemptId]="attempt.id" [offlineEnabled]="offline" (onAbort)="abortQuiz()" (buttonClicked)="behaviourButtonClicked($event)"></core-question>
</ion-card>
</div>
</form>
<!-- Go to next or previous page. -->
<ion-grid text-wrap *ngIf="questions && questions.length && !quizAborted && !showSummary">
<ion-row>
<ion-col *ngIf="previousPage >= 0" >
<button ion-button block icon-start (click)="changePage(previousPage)">
<ion-icon name="arrow-back" md="ios-arrow-back"></ion-icon>
{{ 'core.previous' | translate }}
</button>
</ion-col>
<ion-col *ngIf="nextPage >= -1">
<button ion-button block icon-end (click)="changePage(nextPage)">
{{ 'core.next' | translate }}
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
</button>
</ion-col>
</ion-row>
</ion-grid>
<!-- Summary -->
<ion-card *ngIf="!quizAborted && showSummary && summaryQuestions && summaryQuestions.length" class="addon-mod_quiz-table">
<ion-card-header text-wrap>
<h2>{{ 'addon.mod_quiz.summaryofattempt' | translate }}</h2>
</ion-card-header>
<!-- "Header" of the summary table. -->
<ion-item text-wrap>
<ion-row align-items-center>
<ion-col col-3 text-center><b>{{ 'addon.mod_quiz.question' | translate }}</b></ion-col>
<ion-col col-9 text-center><b>{{ 'addon.mod_quiz.status' | translate }}</b></ion-col>
</ion-row>
</ion-item>
<!-- Lift of questions of the summary table. -->
<ng-container *ngFor="let question of summaryQuestions">
<a ion-item (click)="changePage(question.page, false, question.slot)" *ngIf="question.number" [attr.aria-label]="'core.question.questionno' | translate:{$a: question.number}" [attr.detail-push]="!quiz.isSequential && canReturn ? true : null">
<ion-row align-items-center>
<ion-col col-3 text-center>{{ question.number }}</ion-col>
<ion-col col-9 text-center>{{ question.status }}</ion-col>
</ion-row>
</a>
</ng-container>
<!-- Button to return to last page seen. -->
<ion-item *ngIf="canReturn">
<a ion-button block (click)="changePage(attempt.currentpage)">{{ 'addon.mod_quiz.returnattempt' | translate }}</a>
</ion-item>
<!-- Due date warning. -->
<ion-item text-wrap *ngIf="attempt.dueDateWarning">
{{ attempt.dueDateWarning }}
</ion-item>
<!-- Time left (if quiz is timed). -->
<core-timer *ngIf="endTime" [endTime]="endTime" (finished)="timeUp()" [timerText]="'addon.mod_quiz.timeleft' | translate"></core-timer>
<!-- List of messages explaining why the quiz cannot be submitted. -->
<ion-item text-wrap *ngIf="preventSubmitMessages.length">
<p class="item-heading">{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}</p>
<p *ngFor="let message of preventSubmitMessages">{{message}}</p>
<a ion-button block icon-end [href]="moduleUrl" core-link>
<ion-icon name="open"></ion-icon>
{{ 'core.openinbrowser' | translate }}
</a>
</ion-item>
<!-- Button to submit the quiz. -->
<ion-item *ngIf="!attempt.finishedOffline && !preventSubmitMessages.length">
<a ion-button block (click)="finishAttempt(true)">{{ 'addon.mod_quiz.submitallandfinish' | translate }}</a>
</ion-item>
</ion-card>
<!-- Quiz aborted -->
<ion-card *ngIf="attempt && (((!questions || !questions.length) && !showSummary) || quizAborted)">
<ion-item text-wrap>
<p>{{ 'addon.mod_quiz.errorparsequestions' | translate }}</p>
</ion-item>
<ion-item>
<a ion-button block icon-end [href]="moduleUrl" core-link>
<ion-icon name="open"></ion-icon>
{{ 'core.openinbrowser' | translate }}
</a>
</ion-item>
</ion-card>
</core-loading>
</ion-content>

View File

@ -0,0 +1,35 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreQuestionComponentsModule } from '@core/question/components/components.module';
import { AddonModQuizPlayerPage } from './player';
@NgModule({
declarations: [
AddonModQuizPlayerPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
CoreQuestionComponentsModule,
IonicPageModule.forChild(AddonModQuizPlayerPage),
TranslateModule.forChild()
],
})
export class AddonModQuizPlayerPageModule {}

View File

@ -0,0 +1,10 @@
page-addon-mod-quiz-player {
.toolbar {
padding-top: 0;
padding-bottom: 0;
}
.core-has-fixed-timer form {
padding-top: 56px;
}
}

View File

@ -0,0 +1,580 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core';
import { IonicPage, NavParams, Content, PopoverController, ModalController, Modal, NavController } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreEventsProvider } from '@providers/events';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
import { AddonModQuizProvider } from '../../providers/quiz';
import { AddonModQuizSyncProvider } from '../../providers/quiz-sync';
import { AddonModQuizHelperProvider } from '../../providers/helper';
import { AddonModQuizAutoSave } from '../../classes/auto-save';
import { Subscription } from 'rxjs';
/**
* Page that allows attempting a quiz.
*/
@IonicPage({ segment: 'addon-mod-quiz-player' })
@Component({
selector: 'page-addon-mod-quiz-player',
templateUrl: 'player.html',
})
export class AddonModQuizPlayerPage implements OnInit, OnDestroy {
@ViewChild(Content) content: Content;
quiz: any; // The quiz the attempt belongs to.
attempt: any; // The attempt being attempted.
moduleUrl: string; // URL to the module in the site.
component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
loaded: boolean; // Whether data has been loaded.
quizAborted: boolean; // Whether the quiz was aborted due to an error.
offline: boolean; // Whether the quiz is being attempted in offline mode.
navigation: any[]; // List of questions to navigate them.
questions: any[]; // Questions of the current page.
nextPage: number; // Next page.
previousPage: number; // Previous page.
showSummary: boolean; // Whether the attempt summary should be displayed.
summaryQuestions: any[]; // The questions to display in the summary.
canReturn: boolean; // Whether the user can return to a page after seeing the summary.
preventSubmitMessages: string[]; // List of messages explaining why the quiz cannot be submitted.
endTime: number; // The time when the attempt must be finished.
autoSaveError: boolean; // Whether there's been an error in auto-save.
navigationModal: Modal; // Modal to navigate through the questions.
protected courseId: number; // The course ID the quiz belongs to.
protected quizId: number; // Quiz ID to attempt.
protected preflightData: any = {}; // Preflight data to attempt the quiz.
protected quizAccessInfo: any; // Quiz access information.
protected attemptAccessInfo: any; // Attempt access info.
protected lastAttempt: any; // Last user attempt before a new one is created (if needed).
protected newAttempt: boolean; // Whether the user is starting a new attempt.
protected quizDataLoaded: boolean; // Whether the quiz data has been loaded.
protected timeUpCalled: boolean; // Whether the time up function has been called.
protected autoSave: AddonModQuizAutoSave; // Class to auto-save answers every certain time.
protected autoSaveErrorSubscription: Subscription; // To be notified when an error happens in auto-save.
protected forceLeave = false; // If true, don't perform any check when leaving the view.
constructor(navParams: NavParams, logger: CoreLoggerProvider, protected translate: TranslateService,
protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider,
protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, popoverCtrl: PopoverController,
protected timeUtils: CoreTimeUtilsProvider, protected quizProvider: AddonModQuizProvider,
protected quizHelper: AddonModQuizHelperProvider, protected quizSync: AddonModQuizSyncProvider,
protected questionHelper: CoreQuestionHelperProvider, protected cdr: ChangeDetectorRef,
modalCtrl: ModalController, protected navCtrl: NavController) {
this.quizId = navParams.get('quizId');
this.courseId = navParams.get('courseId');
this.moduleUrl = navParams.get('moduleUrl');
// Block the quiz so it cannot be synced.
this.syncProvider.blockOperation(AddonModQuizProvider.COMPONENT, this.quizId);
// Create the auto save instance.
this.autoSave = new AddonModQuizAutoSave('addon-mod_quiz-player-form', '#addon-mod_quiz-connection-error-button',
logger, popoverCtrl, questionHelper, quizProvider);
// Create the navigation modal.
this.navigationModal = modalCtrl.create('AddonModQuizNavigationModalPage', {
page: this
});
}
/**
* Component being initialized.
*/
ngOnInit(): void {
// Start the player when the page is loaded.
this.start();
// Listen for errors on auto-save.
this.autoSaveErrorSubscription = this.autoSave.onError().subscribe((error) => {
this.autoSaveError = error;
this.cdr.detectChanges();
});
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
// Stop auto save.
this.autoSave.cancelAutoSave();
this.autoSave.stopCheckChangesProcess();
this.autoSaveErrorSubscription && this.autoSaveErrorSubscription.unsubscribe();
// Unblock the quiz so it can be synced.
this.syncProvider.unblockOperation(AddonModQuizProvider.COMPONENT, this.quizId);
}
/**
* Check if we can leave the page or not.
*
* @return {boolean|Promise<void>} Resolved if we can leave it, rejected if not.
*/
ionViewCanLeave(): boolean | Promise<void> {
if (this.forceLeave) {
return true;
}
if (this.questions && this.questions.length && !this.showSummary) {
// Save answers.
const modal = this.domUtils.showModalLoading('core.sending', true);
return this.processAttempt(false, false).catch(() => {
// Save attempt failed. Show confirmation.
modal.dismiss();
return this.domUtils.showConfirm(this.translate.instant('addon.mod_quiz.confirmleavequizonerror'));
}).finally(() => {
modal.dismiss();
});
}
return Promise.resolve();
}
/**
* Abort the quiz.
*/
abortQuiz(): void {
this.quizAborted = true;
}
/**
* A behaviour button in a question was clicked (Check, Redo, ...).
*
* @param {any} button Clicked button.
*/
behaviourButtonClicked(button: any): void {
// Confirm that the user really wants to do it.
this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => {
const modal = this.domUtils.showModalLoading('core.sending', true),
answers = this.getAnswers();
// Add the clicked button data.
answers[button.name] = button.value;
// Behaviour checks are always in online.
return this.quizProvider.processAttempt(this.quiz, this.attempt, answers, this.preflightData).then(() => {
// Reload the current page.
const scrollElement = this.content.getScrollElement(),
scrollTop = scrollElement.scrollTop || 0,
scrollLeft = scrollElement.scrollLeft || 0;
this.loaded = false;
this.content.scrollToTop(); // Scroll top so the spinner is seen.
return this.loadPage(this.attempt.currentpage).finally(() => {
this.loaded = true;
this.content.scrollTo(scrollLeft, scrollTop);
});
}).finally(() => {
modal.dismiss();
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error performing action.');
});
}
/**
* Change the current page. If slot is supplied, try to scroll to that question.
*
* @param {number} page Page to load. -1 means summary.
* @param {boolean} [fromModal] Whether the page was selected using the navigation modal.
* @param {number} [slot] Slot of the question to scroll to.
*/
changePage(page: number, fromModal?: boolean, slot?: number): void {
if (page != -1 && (this.attempt.state == AddonModQuizProvider.ATTEMPT_OVERDUE || this.attempt.finishedOffline)) {
// We can't load a page if overdue or the local attempt is finished.
return;
} else if (page == this.attempt.currentpage && !this.showSummary && typeof slot != 'undefined') {
// Navigating to a question in the current page.
this.scrollToQuestion(slot);
return;
} else if ((page == this.attempt.currentpage && !this.showSummary) || (fromModal && this.quiz.isSequential && page != -1)) {
// If the user is navigating to the current page we do nothing.
// Also, in sequential quizzes we don't allow navigating using the modal except for finishing the quiz (summary).
return;
} else if (page === -1 && this.showSummary) {
// Summary already shown.
return;
}
this.loaded = false;
this.content.scrollToTop();
// First try to save the attempt data. We only save it if we're not seeing the summary.
const promise = this.showSummary ? Promise.resolve() : this.processAttempt(false, false);
promise.then(() => {
// Attempt data successfully saved, load the page or summary.
// Attempt data successfully saved, load the page or summary.
let subPromise;
// Stop checking for changes during page change.
this.autoSave.stopCheckChangesProcess();
if (page === -1) {
subPromise = this.loadSummary();
} else {
subPromise = this.loadPage(page);
}
return subPromise.catch((error) => {
// If the user isn't seeing the summary, start the check again.
if (!this.showSummary) {
this.autoSave.startCheckChangesProcess(this.quiz, this.attempt, this.preflightData, this.offline);
}
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
});
}, (error) => {
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true);
}).finally(() => {
this.loaded = true;
if (typeof slot != 'undefined') {
// Scroll to the question. Give some time to the questions to render.
setTimeout(() => {
this.scrollToQuestion(slot);
}, 2000);
}
});
}
/**
* Convenience function to get the quiz data.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchData(): Promise<any> {
// Wait for any ongoing sync to finish. We won't sync a quiz while it's being played.
return this.quizSync.waitForSync(this.quizId).then(() => {
// Sync finished, now get the quiz.
return this.quizProvider.getQuizById(this.courseId, this.quizId);
}).then((quizData) => {
this.quiz = quizData;
this.quiz.isSequential = this.quizProvider.isNavigationSequential(this.quiz);
if (this.quizProvider.isQuizOffline(this.quiz)) {
// Quiz supports offline.
return true;
} else {
// Quiz doesn't support offline right now, but maybe it did and then the setting was changed.
// If we have an unfinished offline attempt then we'll use offline mode.
return this.quizProvider.isLastAttemptOfflineUnfinished(this.quiz);
}
}).then((offlineMode) => {
this.offline = offlineMode;
if (this.quiz.timelimit > 0) {
this.quiz.readableTimeLimit = this.timeUtils.formatTime(this.quiz.timelimit);
}
// Get access information for the quiz.
return this.quizProvider.getQuizAccessInformation(this.quiz.id, this.offline, true);
}).then((info) => {
this.quizAccessInfo = info;
// Get user attempts to determine last attempt.
return this.quizProvider.getUserAttempts(this.quiz.id, 'all', true, this.offline, true);
}).then((attempts) => {
if (!attempts.length) {
// There are no attempts, start a new one.
this.newAttempt = true;
} else {
const promises = [];
// Get the last attempt. If it's finished, start a new one.
this.lastAttempt = attempts[attempts.length - 1];
this.newAttempt = this.quizProvider.isAttemptFinished(this.lastAttempt.state);
// Load quiz last sync time.
promises.push(this.quizSync.getSyncTime(this.quiz.id).then((time) => {
this.quiz.syncTime = time;
this.quiz.syncTimeReadable = this.quizSync.getReadableTimeFromTimestamp(time);
}));
// Load flag to show if attempts are finished but not synced.
promises.push(this.quizProvider.loadFinishedOfflineData(attempts));
return Promise.all(promises);
}
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true);
});
}
/**
* Finish an attempt, either by timeup or because the user clicked to finish it.
*
* @param {boolean} [userFinish] Whether the user clicked to finish the attempt.
* @param {boolean} [timeUp] Whether the quiz time is up.
* @return {Promise<void>} Promise resolved when done.
*/
finishAttempt(userFinish?: boolean, timeUp?: boolean): Promise<void> {
let promise;
// Show confirm if the user clicked the finish button and the quiz is in progress.
if (!timeUp && this.attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
promise = this.domUtils.showConfirm(this.translate.instant('addon.mod_quiz.confirmclose'));
} else {
promise = Promise.resolve();
}
return promise.then(() => {
const modal = this.domUtils.showModalLoading('core.sending', true);
return this.processAttempt(userFinish, timeUp).then(() => {
// Trigger an event to notify the attempt was finished.
this.eventsProvider.trigger(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, {
quizId: this.quizId,
attemptId: this.attempt.id,
synced: !this.offline
}, this.sitesProvider.getCurrentSiteId());
// Leave the player.
this.forceLeave = true;
this.navCtrl.pop();
}).finally(() => {
modal.dismiss();
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true);
});
}
/**
* Get the input answers.
*
* @return {any} Object with the answers.
*/
protected getAnswers(): any {
return this.questionHelper.getAnswersFromForm(document.forms['addon-mod_quiz-player-form']);
}
/**
* Initializes the timer if enabled.
*/
protected initTimer(): void {
if (this.attemptAccessInfo.endtime > 0) {
// Quiz has an end time. Check if time left should be shown.
if (this.quizProvider.shouldShowTimeLeft(this.quizAccessInfo.activerulenames, this.attempt,
this.attemptAccessInfo.endtime)) {
this.endTime = this.attemptAccessInfo.endtime;
} else {
delete this.endTime;
}
}
}
/**
* Load a page questions.
*
* @param {number} page The page to load.
* @return {Promise<void>} Promise resolved when done.
*/
protected loadPage(page: number): Promise<void> {
return this.quizProvider.getAttemptData(this.attempt.id, page, this.preflightData, this.offline, true).then((data) => {
// Update attempt, status could change during the execution.
this.attempt = data.attempt;
this.attempt.currentpage = page;
this.questions = data.questions;
this.nextPage = data.nextpage;
this.previousPage = this.quiz.isSequential ? -1 : page - 1;
this.showSummary = false;
this.questions.forEach((question) => {
// Get the readable mark for each question.
question.readableMark = this.quizHelper.getQuestionMarkFromHtml(question.html);
// Extract the question info box.
this.questionHelper.extractQuestionInfoBox(question, '.info');
// Set the preferred behaviour.
question.preferredBehaviour = this.quiz.preferredbehaviour;
// Check if the question is blocked. If it is, treat it as a description question.
if (this.quizProvider.isQuestionBlocked(question)) {
question.type = 'description';
}
});
// Mark the page as viewed. We'll ignore errors in this call.
this.quizProvider.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline).catch((error) => {
// Ignore errors.
});
// Start looking for changes.
this.autoSave.startCheckChangesProcess(this.quiz, this.attempt, this.preflightData, this.offline);
});
}
/**
* Load attempt summary.
*
* @return {Promise<void>} Promise resolved when done.
*/
protected loadSummary(): Promise<void> {
this.summaryQuestions = [];
return this.quizProvider.getAttemptSummary(this.attempt.id, this.preflightData, this.offline, true, true).then((qs) => {
this.showSummary = true;
this.summaryQuestions = qs;
this.canReturn = this.attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS && !this.attempt.finishedOffline;
this.preventSubmitMessages = this.quizProvider.getPreventSubmitMessages(this.summaryQuestions);
this.attempt.dueDateWarning = this.quizProvider.getAttemptDueDateWarning(this.quiz, this.attempt);
// Log summary as viewed.
this.quizProvider.logViewAttemptSummary(this.attempt.id, this.preflightData).catch((error) => {
// Ignore errors.
});
});
}
/**
* Load data to navigate the questions using the navigation modal.
*
* @return {Promise<void>} Promise resolved when done.
*/
protected loadNavigation(): Promise<void> {
// We use the attempt summary to build the navigation because it contains all the questions.
return this.quizProvider.getAttemptSummary(this.attempt.id, this.preflightData, this.offline).then((questions) => {
this.navigation = questions;
});
}
// Prepare the answers to be sent for the attempt.
protected prepareAnswers(): Promise<any> {
return this.questionHelper.prepareAnswers(this.questions, this.getAnswers(), this.offline);
}
/**
* Process attempt.
*
* @param {boolean} [userFinish] Whether the user clicked to finish the attempt.
* @param {boolean} [timeUp] Whether the quiz time is up.
* @return {Promise<any>} Promise resolved when done.
*/
protected processAttempt(userFinish?: boolean, timeUp?: boolean): Promise<any> {
// Get the answers to send.
return this.prepareAnswers().then((answers) => {
// Send the answers.
return this.quizProvider.processAttempt(this.quiz, this.attempt, answers, this.preflightData, userFinish, timeUp,
this.offline);
}).then(() => {
// Answers saved, cancel auto save.
this.autoSave.cancelAutoSave();
this.autoSave.hideAutoSaveError();
});
}
/**
* Scroll to a certain question.
*
* @param {number} slot Slot of the question to scroll to.
*/
protected scrollToQuestion(slot: number): void {
this.domUtils.scrollToElementBySelector(this.content, '#addon-mod_quiz-question-' + slot);
}
/**
* Show connection error.
*
* @param {Event} ev Click event.
*/
showConnectionError(ev: Event): void {
this.autoSave.showAutoSaveError(ev);
}
/**
* Convenience function to start the player.
*/
start(): void {
let promise;
this.loaded = false;
if (this.quizDataLoaded) {
// Quiz data has been loaded, try to start or continue.
promise = this.startOrContinueAttempt();
} else {
// Fetch data.
promise = this.fetchData().then(() => {
this.quizDataLoaded = true;
return this.startOrContinueAttempt();
});
}
promise.finally(() => {
this.loaded = true;
});
}
/**
* Start or continue an attempt.
*
* @return {Promise<any>} [description]
*/
protected startOrContinueAttempt(): Promise<any> {
const attempt = this.newAttempt ? undefined : this.lastAttempt;
// Get the preflight data and start attempt if needed.
return this.quizHelper.getAndCheckPreflightData(this.quiz, this.quizAccessInfo, this.preflightData, attempt, this.offline,
false, 'addon.mod_quiz.startattempt').then((attempt) => {
// Re-fetch attempt access information with the right attempt (might have changed because a new attempt was created).
return this.quizProvider.getAttemptAccessInformation(this.quiz.id, attempt.id, this.offline, true).then((info) => {
this.attemptAccessInfo = info;
this.attempt = attempt;
return this.loadNavigation();
}).then(() => {
if (this.attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !this.attempt.finishedOffline) {
// Attempt not overdue and not finished in offline, load page.
return this.loadPage(this.attempt.currentpage).then(() => {
this.initTimer();
});
} else {
// Attempt is overdue or finished in offline, we can only load the summary.
return this.loadSummary();
}
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
});
}
/**
* Quiz time has finished.
*/
timeUp(): void {
if (this.timeUpCalled) {
return;
}
this.timeUpCalled = true;
this.finishAttempt(false, true);
}
}

View File

@ -0,0 +1,27 @@
<ion-header>
<ion-navbar>
<ion-title>{{ title | translate }}</ion-title>
<ion-buttons end>
<button ion-button icon-only (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="close"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content padding class="addon-mod_quiz-preflight-modal">
<core-loading [hideUntil]="loaded">
<form ion-list [formGroup]="preflightForm" (ngSubmit)="sendData()">
<!-- Access rules. -->
<ng-container *ngFor="let componentClass of accessRulesComponent; let last = last">
<core-dynamic-component [component]="componentClass" [data]="data">
<p padding>Couldn't find the directive to render this access rule.</p>
</core-dynamic-component>
<ion-item-divider color="light" *ngIf="!last"></ion-item-divider>
</ng-container>
<button ion-button block type="submit">
{{ title | translate }}
</button>
</form>
</core-loading>
</ion-content>

View File

@ -0,0 +1,31 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { AddonModQuizPreflightModalPage } from './preflight-modal';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
@NgModule({
declarations: [
AddonModQuizPreflightModalPage
],
imports: [
CoreComponentsModule,
IonicPageModule.forChild(AddonModQuizPreflightModalPage),
TranslateModule.forChild()
]
})
export class AddonModQuizPreflightModalModule {}

View File

@ -0,0 +1,122 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, Injector, ViewChild } from '@angular/core';
import { IonicPage, ViewController, NavParams, Content } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate';
/**
* Modal that renders the access rules for a quiz.
*/
@IonicPage({ segment: 'addon-mod-quiz-preflight-modal' })
@Component({
selector: 'page-addon-mod-quiz-preflight-modal',
templateUrl: 'preflight-modal.html',
})
export class AddonModQuizPreflightModalPage implements OnInit {
@ViewChild(Content) content: Content;
preflightForm: FormGroup;
title: string;
accessRulesComponent: any[] = [];
data: any;
loaded: boolean;
protected quiz: any;
protected attempt: any;
protected prefetch: boolean;
protected siteId: string;
protected rules: string[];
protected renderedRules: string[] = [];
constructor(params: NavParams, fb: FormBuilder, translate: TranslateService, sitesProvider: CoreSitesProvider,
protected viewCtrl: ViewController, protected accessRuleDelegate: AddonModQuizAccessRuleDelegate,
protected injector: Injector, protected domUtils: CoreDomUtilsProvider) {
this.title = params.get('title') || translate.instant('addon.mod_quiz.startattempt');
this.quiz = params.get('quiz');
this.attempt = params.get('attempt');
this.prefetch = params.get('prefetch');
this.siteId = params.get('siteId') || sitesProvider.getCurrentSiteId();
this.rules = params.get('rules') || [];
// Create an empty form group. The controls will be added by the access rules components.
this.preflightForm = fb.group({});
// Create the data to pass to the access rules components.
this.data = {
quiz: this.quiz,
attempt: this.attempt,
prefetch: this.prefetch,
form: this.preflightForm,
siteId: this.siteId
};
}
/**
* Component being initialized.
*/
ngOnInit(): void {
const promises = [];
this.rules.forEach((rule) => {
// Check if preflight is required for rule and, if so, get the component to render it.
promises.push(this.accessRuleDelegate.isPreflightCheckRequiredForRule(rule, this.quiz, this.attempt, this.prefetch,
this.siteId).then((required) => {
if (required) {
return this.accessRuleDelegate.getPreflightComponent(rule, this.injector).then((component) => {
if (component) {
this.renderedRules.push(rule);
this.accessRulesComponent.push(component);
}
});
}
}));
});
Promise.all(promises).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error loading rules');
}).finally(() => {
this.loaded = true;
});
}
/**
* Check that the data is valid and send it back.
*/
sendData(): void {
if (!this.preflightForm.valid) {
// Form not valid. Scroll to the first element with errors.
if (!this.domUtils.scrollToInputError(this.content)) {
// Input not found, show an error modal.
this.domUtils.showErrorModal('core.errorinvalidform', true);
}
} else {
this.viewCtrl.dismiss(this.preflightForm.value);
}
}
/**
* Close modal.
*/
closeModal(): void {
this.viewCtrl.dismiss();
}
}

View File

@ -0,0 +1,102 @@
<ion-header>
<ion-navbar>
<ion-title>{{ 'addon.mod_quiz.review' | translate }}</ion-title>
<ion-buttons end>
<button *ngIf="navigation && navigation.length" ion-button icon-only [attr.aria-label]="'addon.mod_quiz.opentoc' | translate" (click)="navigationModal.present()">
<ion-icon name="bookmark"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="refreshData($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<!-- Review summary -->
<ion-card *ngIf="attempt">
<ion-card-header text-wrap>
<h2 *ngIf="attempt.preview">{{ 'addon.mod_quiz.reviewofpreview' | translate }}</h2>
<h2 *ngIf="!attempt.preview">{{ 'addon.mod_quiz.reviewofattempt' | translate:{$a: attempt.attempt} }}</h2>
</ion-card-header>
<ion-list>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_quiz.startedon' | translate }}</p>
<p>{{ attempt.timestart * 1000 | coreFormatDate:"dfmediumdate" }}</p>
</ion-item>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p>{{ attempt.readableState }}</p>
</ion-item>
<ion-item text-wrap *ngIf="showCompleted">
<p class="item-heading">{{ 'addon.mod_quiz.completedon' | translate }}</p>
<p>{{ attempt.timefinish * 1000 | coreFormatDate:"dfmediumdate" }}</p>
</ion-item>
<ion-item text-wrap *ngIf="attempt.timeTaken">
<p class="item-heading">{{ 'addon.mod_quiz.timetaken' | translate }}</p>
<p>{{ attempt.timeTaken }}</p>
</ion-item>
<ion-item text-wrap *ngIf="attempt.overTime">
<p class="item-heading">{{ 'addon.mod_quiz.overdue' | translate }}</p>
<p>{{ attempt.overTime }}</p>
</ion-item>
<ion-item text-wrap *ngIf="attempt.readableMark">
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }}</p>
<p><core-format-text [text]="attempt.readableMark"></core-format-text></p>
</ion-item>
<ion-item text-wrap *ngIf="attempt.readableGrade">
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }}</p>
<p>{{ attempt.readableGrade }}</p>
</ion-item>
<ion-item text-wrap *ngFor="let data of additionalData">
<p class="item-heading">{{ data.title }}</p>
<core-format-text [component]="component" [componentId]="componentId" [text]="data.content"></core-format-text>
</ion-item>
</ion-list>
</ion-card>
<!-- Questions -->
<div *ngIf="attempt && questions.length">
<!-- Arrows to go to next/previous. -->
<ng-container *ngTemplateOutlet="navArrows"></ng-container>
<!-- Questions. -->
<div *ngFor="let question of questions">
<ion-card id="addon-mod_quiz-question-{{question.slot}}">
<!-- "Header" of the question. -->
<ion-item-divider color="light">
<h2 *ngIf="question.number" class="inline">{{ 'core.question.questionno' | translate:{$a: question.number} }}</h2>
<h2 *ngIf="!question.number" class="inline">{{ 'core.question.information' | translate }}</h2>
<ion-note text-wrap item-end *ngIf="question.status || question.readableMark">
<p *ngIf="question.status" class="block">{{question.status}}</p>
<p *ngIf="question.readableMark"><core-format-text [text]="question.readableMark"></core-format-text></p>
</ion-note>
</ion-item-divider>
<!-- Body of the question. -->
<core-question text-wrap [question]="question" [component]="component" [componentId]="componentId" [attemptId]="attempt.id" [offlineEnabled]="false"></core-question>
</ion-card>
</div>
<!-- Arrows to go to next/previous. -->
<ng-container *ngTemplateOutlet="navArrows"></ng-container>
</div>
</core-loading>
</ion-content>
<!-- Arrows to go to next/previous. -->
<ng-template #navArrows>
<ion-row align-items-center>
<ion-col>
<a ion-button icon-only color="light" *ngIf="previousPage >= 0" (click)="changePage(previousPage)" [title]="'core.previous' | translate">
<ion-icon name="arrow-back" md="ios-arrow-back"></ion-icon>
</a>
</ion-col>
<ion-col text-right>
<a ion-button icon-only color="light" *ngIf="nextPage >= -1" (click)="changePage(nextPage)" [title]="'core.next' | translate">
<ion-icon name="arrow-forward" md="ios-arrow-forward"></ion-icon>
</a>
</ion-col>
</ion-row>
</ng-template>

View File

@ -0,0 +1,37 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { CoreQuestionComponentsModule } from '@core/question/components/components.module';
import { AddonModQuizReviewPage } from './review';
@NgModule({
declarations: [
AddonModQuizReviewPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
CoreQuestionComponentsModule,
IonicPageModule.forChild(AddonModQuizReviewPage),
TranslateModule.forChild()
],
})
export class AddonModQuizReviewPageModule {}

View File

@ -0,0 +1,11 @@
page-addon-mod-quiz-review {
.item-radio-disabled,
.item-checkbox-disabled,
.text-input[disabled] {
opacity: 1;
.label, .radio, .checkbox {
opacity: 1;
}
}
}

View File

@ -0,0 +1,297 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ViewChild } from '@angular/core';
import { IonicPage, NavParams, Content, ModalController, Modal } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
import { AddonModQuizProvider } from '../../providers/quiz';
import { AddonModQuizHelperProvider } from '../../providers/helper';
/**
* Page that allows reviewing a quiz attempt.
*/
@IonicPage({ segment: 'addon-mod-quiz-review' })
@Component({
selector: 'page-addon-mod-quiz-review',
templateUrl: 'review.html',
})
export class AddonModQuizReviewPage implements OnInit {
@ViewChild(Content) content: Content;
attempt: any; // The attempt being reviewed.
component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
componentId: number; // ID to use in conjunction with the component.
showAll: boolean; // Whether to view all questions in the same page.
numPages: number; // Number of pages.
showCompleted: boolean; // Whether to show completed time.
additionalData: any[]; // Additional data to display for the attempt.
loaded: boolean; // Whether data has been loaded.
navigation: any[]; // List of questions to navigate them.
questions: any[]; // Questions of the current page.
nextPage: number; // Next page.
previousPage: number; // Previous page.
navigationModal: Modal; // Modal to navigate through the questions.
protected quiz: any; // The quiz the attempt belongs to.
protected courseId: number; // The course ID the quiz belongs to.
protected quizId: number; // Quiz ID the attempt belongs to.
protected attemptId: number; // The attempt being reviewed.
protected currentPage: number; // The current page being reviewed.
protected options: any; // Review options.
constructor(navParams: NavParams, modalCtrl: ModalController, protected translate: TranslateService,
protected domUtils: CoreDomUtilsProvider, protected timeUtils: CoreTimeUtilsProvider,
protected quizProvider: AddonModQuizProvider, protected quizHelper: AddonModQuizHelperProvider,
protected questionHelper: CoreQuestionHelperProvider, protected textUtils: CoreTextUtilsProvider) {
this.quizId = navParams.get('quizId');
this.courseId = navParams.get('courseId');
this.attemptId = navParams.get('attemptId');
this.currentPage = navParams.get('page') || -1;
this.showAll = this.currentPage == -1;
// Create the navigation modal.
this.navigationModal = modalCtrl.create('AddonModQuizNavigationModalPage', {
isReview: true,
page: this
});
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.fetchData().then(() => {
this.quizProvider.logViewAttemptReview(this.attemptId).catch((error) => {
// Ignore errors.
});
}).finally(() => {
this.loaded = true;
});
}
/**
* Change the current page. If slot is supplied, try to scroll to that question.
*
* @param {number} page Page to load. -1 means all questions in same page.
* @param {boolean} [fromModal] Whether the page was selected using the navigation modal.
* @param {number} [slot] Slot of the question to scroll to.
*/
changePage(page: number, fromModal?: boolean, slot?: number): void {
if (typeof slot != 'undefined' && (this.attempt.currentpage == -1 || page == this.currentPage)) {
// Scrol to a certain question in the current page.
this.scrollToQuestion(slot);
return;
} else if (page == this.currentPage) {
// If the user is navigating to the current page and no question specified, we do nothing.
return;
}
this.loaded = false;
this.content.scrollToTop();
this.loadPage(page).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
}).finally(() => {
this.loaded = true;
if (typeof slot != 'undefined') {
// Scroll to the question. Give some time to the questions to render.
setTimeout(() => {
this.scrollToQuestion(slot);
}, 2000);
}
});
}
/**
* Convenience function to get the quiz data.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchData(): Promise<any> {
return this.quizProvider.getQuizById(this.courseId, this.quizId).then((quizData) => {
this.quiz = quizData;
this.componentId = this.quiz.coursemodule;
return this.quizProvider.getCombinedReviewOptions(this.quizId).then((result) => {
this.options = result;
// Load the navigation data.
return this.loadNavigation().then(() => {
// Load questions.
return this.loadPage(this.currentPage);
});
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true);
});
}
/**
* Load a page questions.
*
* @param {number} page The page to load.
* @return {Promise<void>} Promise resolved when done.
*/
protected loadPage(page: number): Promise<void> {
return this.quizProvider.getAttemptReview(this.attemptId, page).then((data) => {
this.attempt = data.attempt;
this.attempt.currentpage = page;
this.currentPage = page;
// Set the summary data.
this.setSummaryCalculatedData(data);
this.questions = data.questions;
this.nextPage = page == -1 ? undefined : page + 1;
this.previousPage = page - 1;
this.questions.forEach((question) => {
// Get the readable mark for each question.
question.readableMark = this.quizHelper.getQuestionMarkFromHtml(question.html);
// Extract the question info box.
this.questionHelper.extractQuestionInfoBox(question, '.info');
// Set the preferred behaviour.
question.preferredBehaviour = this.quiz.preferredbehaviour;
});
});
}
/**
* Load data to navigate the questions using the navigation modal.
*
* @return {Promise<void>} Promise resolved when done.
*/
protected loadNavigation(): Promise<void> {
// Get all questions in single page to retrieve all the questions.
return this.quizProvider.getAttemptReview(this.attemptId, -1).then((data) => {
const lastQuestion = data.questions[data.questions.length - 1];
data.questions.forEach((question) => {
question.stateClass = this.questionHelper.getQuestionStateClass(question.state);
});
this.navigation = data.questions;
this.numPages = lastQuestion ? lastQuestion.page + 1 : 0;
});
}
/**
* Refreshes data.
*
* @param {any} refresher Refresher
*/
refreshData(refresher: any): void {
const promises = [];
promises.push(this.quizProvider.invalidateQuizData(this.courseId));
promises.push(this.quizProvider.invalidateCombinedReviewOptionsForUser(this.quizId));
promises.push(this.quizProvider.invalidateAttemptReview(this.attemptId));
Promise.all(promises).finally(() => {
return this.fetchData();
}).finally(() => {
refresher.complete();
});
}
/**
* Scroll to a certain question.
*
* @param {number} slot Slot of the question to scroll to.
*/
protected scrollToQuestion(slot: number): void {
this.domUtils.scrollToElementBySelector(this.content, '#addon-mod_quiz-question-' + slot);
}
/**
* Calculate review summary data.
*
* @param {any} data Result of getAttemptReview.
*/
protected setSummaryCalculatedData(data: any): void {
this.attempt.readableState = this.quizProvider.getAttemptReadableStateName(this.attempt.state);
if (this.attempt.state == AddonModQuizProvider.ATTEMPT_FINISHED) {
this.showCompleted = true;
this.additionalData = data.additionaldata;
const timeTaken = this.attempt.timefinish - this.attempt.timestart;
if (timeTaken) {
// Format time taken.
this.attempt.timeTaken = this.timeUtils.formatTime(timeTaken);
// Calculate overdue time.
if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) {
this.attempt.overTime = this.timeUtils.formatTime(timeTaken - this.quiz.timelimit);
}
}
// Treat grade.
if (this.options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX &&
this.quizProvider.quizHasGrades(this.quiz)) {
if (data.grade === null || typeof data.grade == 'undefined') {
this.attempt.readableGrade = this.quizProvider.formatGrade(data.grade, this.quiz.decimalpoints);
} else {
// Show raw marks only if they are different from the grade (like on the entry page).
if (this.quiz.grade != this.quiz.sumgrades) {
this.attempt.readableMark = this.translate.instant('addon.mod_quiz.outofshort', {$a: {
grade: this.quizProvider.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints),
maxgrade: this.quizProvider.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints)
}});
}
// Now the scaled grade.
const gradeObject: any = {
grade: this.quizProvider.formatGrade(data.grade, this.quiz.decimalpoints),
maxgrade: this.quizProvider.formatGrade(this.quiz.grade, this.quiz.decimalpoints)
};
if (this.quiz.grade != 100) {
gradeObject.percent = this.textUtils.roundToDecimals(this.attempt.sumgrades * 100 / this.quiz.sumgrades, 0);
this.attempt.readableGrade = this.translate.instant('addon.mod_quiz.outofpercent', {$a: gradeObject});
} else {
this.attempt.readableGrade = this.translate.instant('addon.mod_quiz.outof', {$a: gradeObject});
}
}
}
// Treat additional data.
this.additionalData.forEach((data) => {
// Remove help links from additional data.
data.content = this.domUtils.removeElementFromHtml(data.content, '.helptooltip');
});
}
}
/**
* Switch mode: all questions in same page OR one page at a time.
*/
switchMode(): void {
this.showAll = !this.showAll;
// Load all questions or first page, depending on the mode.
this.loadPage(this.showAll ? -1 : 0);
}
}

View File

@ -0,0 +1,295 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable, Injector } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
/**
* Interface that all access rules handlers must implement.
*/
export interface AddonModQuizAccessRuleHandler extends CoreDelegateHandler {
/**
* Name of the rule the handler supports. E.g. 'password'.
* @type {string}
*/
ruleName: string;
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {boolean|Promise<boolean>} Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise<boolean>;
/**
* Add preflight data that doesn't require user interaction. The data should be added to the preflightData param.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} preflightData Object where to add the preflight data.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {void|Promise<any>} Promise resolved when done if async, void if it's synchronous.
*/
getFixedPreflightData?(quiz: any, preflightData: any, attempt?: any, prefetch?: boolean, siteId?: string): void | Promise<any>;
/**
* Return the Component to use to display the access rule preflight.
* Implement this if your access rule requires a preflight check with user interaction.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @param {Injector} injector Injector.
* @return {any|Promise<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getPreflightComponent?(injector: Injector): any | Promise<any>;
/**
* Function called when the preflight check has passed. This is a chance to record that fact in some way.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} attempt The attempt started/continued.
* @param {any} preflightData Preflight data gathered.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {void|Promise<any>} Promise resolved when done if async, void if it's synchronous.
*/
notifyPreflightCheckPassed?(quiz: any, attempt: any, preflightData: any, prefetch?: boolean, siteId?: string)
: void | Promise<any>;
/**
* Function called when the preflight check fails. This is a chance to record that fact in some way.
*
* @param {any} quiz The quiz the rule belongs to.
* @param {any} attempt The attempt started/continued.
* @param {any} preflightData Preflight data gathered.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {void|Promise<any>} Promise resolved when done if async, void if it's synchronous.
*/
notifyPreflightCheckFailed?(quiz: any, attempt: any, preflightData: any, prefetch?: boolean, siteId?: string)
: void | Promise<any>;
/**
* Whether or not the time left of an attempt should be displayed.
*
* @param {any} attempt The attempt.
* @param {number} endTime The attempt end time (in seconds).
* @param {number} timeNow The current time in seconds.
* @return {boolean} Whether it should be displayed.
*/
shouldShowTimeLeft?(attempt: any, endTime: number, timeNow: number): boolean;
}
/**
* Delegate to register access rules for quiz module.
*/
@Injectable()
export class AddonModQuizAccessRuleDelegate extends CoreDelegate {
protected handlerNameProperty = 'ruleName';
constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider,
protected utils: CoreUtilsProvider) {
super('AddonModQuizAccessRulesDelegate', logger, sitesProvider, eventsProvider);
}
/**
* Get the handler for a certain rule.
*
* @param {string} ruleName Name of the access rule.
* @return {AddonModQuizAccessRuleHandler} Handler. Undefined if no handler found for the rule.
*/
getAccessRuleHandler(ruleName: string): AddonModQuizAccessRuleHandler {
return <AddonModQuizAccessRuleHandler> this.getHandler(ruleName, true);
}
/**
* Given a list of rules, get some fixed preflight data (data that doesn't require user interaction).
*
* @param {string[]} rules List of active rules names.
* @param {any} quiz Quiz.
* @param {any} preflightData Object where to store the preflight data.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when all the data has been gathered.
*/
getFixedPreflightData(rules: string[], quiz: any, preflightData: any, attempt?: any, prefetch?: boolean, siteId?: string)
: Promise<any> {
rules = rules || [];
const promises = [];
rules.forEach((rule) => {
promises.push(Promise.resolve(
this.executeFunctionOnEnabled(rule, 'getFixedPreflightData', [quiz, preflightData, attempt, prefetch, siteId])
));
});
return this.utils.allPromises(promises).catch(() => {
// Never reject.
});
}
/**
* Get the Component to use to display the access rule preflight.
*
* @param {Injector} injector Injector.
* @return {Promise<any>} Promise resolved with the component to use, undefined if not found.
*/
getPreflightComponent(rule: string, injector: Injector): Promise<any> {
return Promise.resolve(this.executeFunctionOnEnabled(rule, 'getPreflightComponent', [injector]));
}
/**
* Check if an access rule is supported.
*
* @param {string} ruleName Name of the rule.
* @return {boolean} Whether it's supported.
*/
isAccessRuleSupported(ruleName: string): boolean {
return this.hasHandler(ruleName, true);
}
/**
* Given a list of rules, check if preflight check is required.
*
* @param {string[]} rules List of active rules names.
* @param {any} quiz Quiz.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<boolean>} Promise resolved with boolean: whether it's required.
*/
isPreflightCheckRequired(rules: string[], quiz: any, attempt: any, prefetch?: boolean, siteId?: string): Promise<boolean> {
rules = rules || [];
const promises = [];
let isRequired = false;
rules.forEach((rule) => {
promises.push(this.isPreflightCheckRequiredForRule(rule, quiz, attempt, prefetch, siteId).then((required) => {
if (required) {
isRequired = true;
}
}));
});
return this.utils.allPromises(promises).then(() => {
return isRequired;
}).catch(() => {
// Never reject.
return isRequired;
});
}
/**
* Check if preflight check is required for a certain rule.
*
* @param {string} rule Rule name.
* @param {any} quiz Quiz.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<boolean>} Promise resolved with boolean: whether it's required.
*/
isPreflightCheckRequiredForRule(rule: string, quiz: any, attempt: any, prefetch?: boolean, siteId?: string): Promise<boolean> {
return Promise.resolve(this.executeFunctionOnEnabled(rule, 'isPreflightCheckRequired', [quiz, attempt, prefetch, siteId]));
}
/**
* Notify all rules that the preflight check has passed.
*
* @param {string[]} rules List of active rules names.
* @param {any} quiz Quiz.
* @param {any} attempt Attempt.
* @param {any} preflightData Preflight data gathered.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
notifyPreflightCheckPassed(rules: string[], quiz: any, attempt: any, preflightData: any, prefetch?: boolean, siteId?: string)
: Promise<any> {
rules = rules || [];
const promises = [];
rules.forEach((rule) => {
promises.push(Promise.resolve(
this.executeFunctionOnEnabled(rule, 'notifyPreflightCheckPassed', [quiz, attempt, preflightData, prefetch, siteId])
));
});
return this.utils.allPromises(promises).catch(() => {
// Never reject.
});
}
/**
* Notify all rules that the preflight check has failed.
*
* @param {string[]} rules List of active rules names.
* @param {any} quiz Quiz.
* @param {any} attempt Attempt.
* @param {any} preflightData Preflight data gathered.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
notifyPreflightCheckFailed(rules: string[], quiz: any, attempt: any, preflightData: any, prefetch?: boolean, siteId?: string)
: Promise<any> {
rules = rules || [];
const promises = [];
rules.forEach((rule) => {
promises.push(Promise.resolve(
this.executeFunctionOnEnabled(rule, 'notifyPreflightCheckFailed', [quiz, attempt, preflightData, prefetch, siteId])
));
});
return this.utils.allPromises(promises).catch(() => {
// Never reject.
});
}
/**
* Whether or not the time left of an attempt should be displayed.
*
* @param {string[]} rules List of active rules names.
* @param {any} attempt The attempt.
* @param {number} endTime The attempt end time (in seconds).
* @param {number} timeNow The current time in seconds.
* @return {boolean} Whether it should be displayed.
*/
shouldShowTimeLeft(rules: string[], attempt: any, endTime: number, timeNow: number): boolean {
rules = rules || [];
for (const i in rules) {
const rule = rules[i];
if (this.executeFunctionOnEnabled(rule, 'shouldShowTimeLeft', [attempt, endTime, timeNow])) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,48 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreContentLinksModuleGradeHandler } from '@core/contentlinks/classes/module-grade-handler';
import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { AddonModQuizProvider } from './quiz';
/**
* Handler to treat links to quiz grade.
*/
@Injectable()
export class AddonModQuizGradeLinkHandler extends CoreContentLinksModuleGradeHandler {
name = 'AddonModQuizGradeLinkHandler';
canReview = false;
constructor(courseHelper: CoreCourseHelperProvider, domUtils: CoreDomUtilsProvider, sitesProvider: CoreSitesProvider,
protected quizProvider: AddonModQuizProvider) {
super(courseHelper, domUtils, sitesProvider, 'AddonModQuiz', 'quiz');
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param {string} siteId The site ID.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
return this.quizProvider.isPluginEnabled();
}
}

View File

@ -0,0 +1,270 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { ModalController } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { AddonModQuizProvider } from './quiz';
import { AddonModQuizOfflineProvider } from './quiz-offline';
import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate';
/**
* Helper service that provides some features for quiz.
*/
@Injectable()
export class AddonModQuizHelperProvider {
protected div = document.createElement('div'); // A div element to search in HTML code.
constructor(private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private utils: CoreUtilsProvider,
private accessRuleDelegate: AddonModQuizAccessRuleDelegate, private quizProvider: AddonModQuizProvider,
private modalCtrl: ModalController, private quizOfflineProvider: AddonModQuizOfflineProvider) { }
/**
* Validate a preflight data or show a modal to input the preflight data if required.
* It calls AddonModQuizProvider.startAttempt if a new attempt is needed.
*
* @param {any} quiz Quiz.
* @param {any} accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation.
* @param {any} preflightData Object where to store the preflight data.
* @param {any} [attempt] Attempt to continue. Don't pass any value if the user needs to start a new attempt.
* @param {boolean} [offline] Whether the attempt is offline.
* @param {boolean} [prefetch] Whether user is prefetching.
* @param {string} [title] The title to display in the modal and in the submit button.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {boolean} [retrying] Whether we're retrying after a failure.
* @return {Promise<any>} Promise resolved when the preflight data is validated. The resolve param is the attempt.
*/
getAndCheckPreflightData(quiz: any, accessInfo: any, preflightData: any, attempt: any, offline?: boolean, prefetch?: boolean,
title?: string, siteId?: string, retrying?: boolean): Promise<any> {
const rules = accessInfo.activerulenames;
let isPreflightCheckRequired = false;
// Check if the user needs to input preflight data.
return this.accessRuleDelegate.isPreflightCheckRequired(rules, quiz, attempt, prefetch, siteId).then((required) => {
isPreflightCheckRequired = required;
if (required) {
// Preflight check is required but no preflightData has been sent. Show a modal with the preflight form.
return this.getPreflightData(quiz, accessInfo, attempt, prefetch, title, siteId).then((data) => {
// Data entered by the user, add it to preflight data and check it again.
Object.assign(preflightData, data);
});
}
}).then(() => {
// Get some fixed preflight data from access rules (data that doesn't require user interaction).
return this.accessRuleDelegate.getFixedPreflightData(rules, quiz, preflightData, attempt, prefetch, siteId);
}).then(() => {
// All the preflight data is gathered, now validate it.
return this.validatePreflightData(quiz, accessInfo, preflightData, attempt, offline, prefetch, siteId)
.catch((error) => {
if (prefetch) {
return Promise.reject(error);
} else if (retrying && !isPreflightCheckRequired) {
// We're retrying after a failure, but the preflight check wasn't required.
// This means there's something wrong with some access rule or user is offline and data isn't cached.
// Don't retry again because it would lead to an infinite loop.
return Promise.reject(error);
} else {
// Show error and ask for the preflight again.
// Wait to show the error because we want it to be shown over the preflight modal.
setTimeout(() => {
this.domUtils.showErrorModalDefault(error, 'core.error', true);
}, 100);
return this.getAndCheckPreflightData(quiz, accessInfo, preflightData, attempt, offline, prefetch,
title, siteId, true);
}
});
});
}
/**
* Get the preflight data from the user using a modal.
*
* @param {any} quiz Quiz.
* @param {any} accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation.
* @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt.
* @param {boolean} [prefetch] Whether the user is prefetching the quiz.
* @param {string} [title] The title to display in the modal and in the submit button.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the preflight data. Rejected if user cancels.
*/
getPreflightData(quiz: any, accessInfo: any, attempt: any, prefetch?: boolean, title?: string, siteId?: string): Promise<any> {
const notSupported: string[] = [];
// Check if there is any unsupported rule.
accessInfo.activerulenames.forEach((rule) => {
if (!this.accessRuleDelegate.isAccessRuleSupported(rule)) {
notSupported.push(rule);
}
});
if (notSupported.length) {
return Promise.reject(this.translate.instant('addon.mod_quiz.errorrulesnotsupported') + ' ' +
JSON.stringify(notSupported));
}
// Create and show the modal.
const modal = this.modalCtrl.create('AddonModQuizPreflightModalPage', {
title: title,
quiz: quiz,
attempt: attempt,
prefetch: !!prefetch,
siteId: siteId,
rules: accessInfo.activerulenames
});
modal.present();
// Wait for modal to be dismissed.
return new Promise((resolve, reject): void => {
modal.onDidDismiss((data) => {
if (typeof data != 'undefined') {
resolve(data);
} else {
reject(this.domUtils.createCanceledError());
}
});
});
}
/**
* Gets the mark string from a question HTML.
* Example result: "Marked out of 1.00".
*
* @param {string} html Question's HTML.
* @return {string} Question's mark.
*/
getQuestionMarkFromHtml(html: string): string {
this.div.innerHTML = html;
return this.domUtils.getContentsOfElement(this.div, '.grade');
}
/**
* Add some calculated data to the attempt.
*
* @param {any} quiz Quiz.
* @param {any} attempt Attempt.
* @param {boolean} highlight Whether we should check if attempt should be highlighted.
* @param {number} [bestGrade] Quiz's best grade (formatted). Required if highlight=true.
*/
setAttemptCalculatedData(quiz: any, attempt: any, highlight?: boolean, bestGrade?: string): void {
attempt.rescaledGrade = this.quizProvider.rescaleGrade(attempt.sumgrades, quiz, false);
attempt.finished = this.quizProvider.isAttemptFinished(attempt.state);
attempt.readableState = this.quizProvider.getAttemptReadableState(quiz, attempt);
if (quiz.showMarkColumn && attempt.finished) {
attempt.readableMark = this.quizProvider.formatGrade(attempt.sumgrades, quiz.decimalpoints);
} else {
attempt.readableMark = '';
}
if (quiz.showGradeColumn && attempt.finished) {
attempt.readableGrade = this.quizProvider.formatGrade(attempt.rescaledGrade, quiz.decimalpoints);
// Highlight the highest grade if appropriate.
attempt.highlightGrade = highlight && !attempt.preview && attempt.state == AddonModQuizProvider.ATTEMPT_FINISHED &&
attempt.readableGrade == bestGrade;
} else {
attempt.readableGrade = '';
}
}
/**
* Add some calculated data to the quiz.
*
* @param {any} quiz Quiz.
* @param {any} options Options returned by AddonModQuizProvider.getCombinedReviewOptions.
*/
setQuizCalculatedData(quiz: any, options: any): void {
quiz.sumGradesFormatted = this.quizProvider.formatGrade(quiz.sumgrades, quiz.decimalpoints);
quiz.gradeFormatted = this.quizProvider.formatGrade(quiz.grade, quiz.decimalpoints);
quiz.showAttemptColumn = quiz.attempts != 1;
quiz.showGradeColumn = options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX &&
this.quizProvider.quizHasGrades(quiz);
quiz.showMarkColumn = quiz.showGradeColumn && quiz.grade != quiz.sumgrades;
quiz.showFeedbackColumn = quiz.hasfeedback && options.alloptions.overallfeedback;
}
/**
* Validate the preflight data. It calls AddonModQuizProvider.startAttempt if a new attempt is needed.
*
* @param {any} quiz Quiz.
* @param {any} accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation.
* @param {any} preflightData Object where to store the preflight data.
* @param {any} [attempt] Attempt to continue. Don't pass any value if the user needs to start a new attempt.
* @param {boolean} [offline] Whether the attempt is offline.
* @param {boolean} [sent] Whether preflight data has been entered by the user.
* @param {boolean} [prefetch] Whether user is prefetching.
* @param {string} [title] The title to display in the modal and in the submit button.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the preflight data is validated.
*/
validatePreflightData(quiz: any, accessInfo: any, preflightData: any, attempt: any, offline?: boolean, prefetch?: boolean,
siteId?: string): Promise<any> {
const rules = accessInfo.activerulenames;
let promise;
if (attempt) {
if (attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !attempt.finishedOffline) {
// We're continuing an attempt. Call getAttemptData to validate the preflight data.
const page = attempt.currentpage;
promise = this.quizProvider.getAttemptData(attempt.id, page, preflightData, offline, true, siteId).then(() => {
if (offline) {
// Get current page stored in local.
return this.quizOfflineProvider.getAttemptById(attempt.id).then((localAttempt) => {
attempt.currentpage = localAttempt.currentpage;
}).catch(() => {
// No local data.
});
}
});
} else {
// Attempt is overdue or finished in offline, we can only see the summary.
// Call getAttemptSummary to validate the preflight data.
promise = this.quizProvider.getAttemptSummary(attempt.id, preflightData, offline, true, false, siteId);
}
} else {
// We're starting a new attempt, call startAttempt.
promise = this.quizProvider.startAttempt(quiz.id, preflightData, false, siteId).then((att) => {
attempt = att;
});
}
return promise.then(() => {
// Preflight data validated.
this.accessRuleDelegate.notifyPreflightCheckPassed(rules, quiz, attempt, preflightData, prefetch, siteId);
return attempt;
}).catch((error) => {
if (this.utils.isWebServiceError(error)) {
// The WebService returned an error, assume the preflight failed.
this.accessRuleDelegate.notifyPreflightCheckFailed(rules, quiz, attempt, preflightData, prefetch, siteId);
}
return Promise.reject(error);
});
}
}

View File

@ -0,0 +1,44 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler';
import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { AddonModQuizProvider } from './quiz';
/**
* Handler to treat links to quiz index.
*/
@Injectable()
export class AddonModQuizIndexLinkHandler extends CoreContentLinksModuleIndexHandler {
name = 'AddonModQuizIndexLinkHandler';
constructor(courseHelper: CoreCourseHelperProvider, protected quizProvider: AddonModQuizProvider) {
super(courseHelper, 'AddonModQuiz', 'quiz');
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param {string} siteId The site ID.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
return this.quizProvider.isPluginEnabled();
}
}

View File

@ -0,0 +1,71 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { NavController, NavOptions } from 'ionic-angular';
import { AddonModQuizIndexComponent } from '../components/index/index';
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate';
import { CoreCourseProvider } from '@core/course/providers/course';
/**
* Handler to support quiz modules.
*/
@Injectable()
export class AddonModQuizModuleHandler implements CoreCourseModuleHandler {
name = 'AddonModQuiz';
modName = 'quiz';
constructor(private courseProvider: CoreCourseProvider) { }
/**
* Check if the handler is enabled on a site level.
*
* @return {boolean} Whether or not the handler is enabled on a site level.
*/
isEnabled(): boolean {
return true;
}
/**
* Get the data required to display the module in the course contents view.
*
* @param {any} module The module object.
* @param {number} courseId The course ID.
* @param {number} sectionId The section ID.
* @return {CoreCourseModuleHandlerData} Data to render the module.
*/
getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData {
return {
icon: this.courseProvider.getModuleIconSrc('quiz'),
title: module.name,
class: 'addon-mod_quiz-handler',
showDownloadButton: true,
action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void {
navCtrl.push('AddonModQuizIndexPage', {module: module, courseId: courseId}, options);
}
};
}
/**
* Get the component to render the module. This is needed to support singleactivity course format.
* The component returned must implement CoreCourseModuleMainComponent.
*
* @param {any} course The course object.
* @param {any} module The module object.
* @return {any} The component to use, undefined if not found.
*/
getMainComponent(course: any, module: any): any {
return AddonModQuizIndexComponent;
}
}

View File

@ -0,0 +1,451 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable, Injector } from '@angular/core';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler';
import { AddonModQuizProvider } from './quiz';
import { AddonModQuizHelperProvider } from './helper';
import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate';
import { AddonModQuizSyncProvider } from './quiz-sync';
import { CoreConstants } from '@core/constants';
/**
* Handler to prefetch quizzes.
*/
@Injectable()
export class AddonModQuizPrefetchHandler extends CoreCourseModulePrefetchHandlerBase {
name = 'AddonModQuiz';
modName = 'quiz';
component = AddonModQuizProvider.COMPONENT;
updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^questions$|^attempts$/;
protected syncProvider: AddonModQuizSyncProvider; // It will be injected later to prevent circular dependencies.
constructor(protected injector: Injector, protected quizProvider: AddonModQuizProvider,
protected textUtils: CoreTextUtilsProvider, protected quizHelper: AddonModQuizHelperProvider,
protected accessRuleDelegate: AddonModQuizAccessRuleDelegate, protected questionHelper: CoreQuestionHelperProvider) {
super(injector);
}
/**
* Download the module.
*
* @param {any} module The module object returned by WS.
* @param {number} courseId Course ID.
* @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
* @return {Promise<any>} Promise resolved when all content is downloaded.
*/
download(module: any, courseId: number, dirPath?: string): Promise<any> {
// Same implementation for download or prefetch.
return this.prefetch(module, courseId, false, dirPath);
}
/**
* Get the download size of a module.
*
* @param {any} module Module.
* @param {Number} courseId Course ID the module belongs to.
* @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
* @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able
* to calculate the total size.
*/
getDownloadSize(module: any, courseId: any, single?: boolean): Promise<{ size: number, total: boolean }> {
return Promise.resolve({
size: -1,
total: false
});
}
/**
* Get list of files. If not defined, we'll assume they're in module.contents.
*
* @param {any} module Module.
* @param {Number} courseId Course ID the module belongs to.
* @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
* @return {Promise<any[]>} Promise resolved with the list of files.
*/
getFiles(module: any, courseId: number, single?: boolean): Promise<any[]> {
return Promise.resolve([]);
}
/**
* Gather some preflight data for an attempt. This function will start a new attempt if needed.
*
* @param {any} quiz Quiz.
* @param {any} accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation.
* @param {any} [attempt] Attempt to continue. Don't pass any value if the user needs to start a new attempt.
* @param {boolean} [askPreflight] Whether it should ask for preflight data if needed.
* @param {string} [modalTitle] Lang key of the title to set to preflight modal (e.g. 'addon.mod_quiz.startattempt').
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the preflight data.
*/
getPreflightData(quiz: any, accessInfo: any, attempt?: any, askPreflight?: boolean, title?: string, siteId?: string)
: Promise<any> {
const preflightData = {};
let promise;
if (askPreflight) {
// We can ask preflight, check if it's needed and get the data.
promise = this.quizHelper.getAndCheckPreflightData(
quiz, accessInfo, preflightData, attempt, false, true, title, siteId);
} else {
// Get some fixed preflight data from access rules (data that doesn't require user interaction).
const rules = accessInfo.activerulenames;
promise = this.accessRuleDelegate.getFixedPreflightData(rules, quiz, preflightData, attempt, true, siteId).then(() => {
if (!attempt) {
// We need to create a new attempt.
return this.quizProvider.startAttempt(quiz.id, preflightData, false, siteId);
}
});
}
return promise.then(() => {
return preflightData;
});
}
/**
* Invalidate the prefetched content.
*
* @param {number} moduleId The module ID.
* @param {number} courseId The course ID the module belongs to.
* @return {Promise<any>} Promise resolved when the data is invalidated.
*/
invalidateContent(moduleId: number, courseId: number): Promise<any> {
return this.quizProvider.invalidateContent(moduleId, courseId);
}
/**
* Invalidate WS calls needed to determine module status.
*
* @param {any} module Module.
* @param {number} courseId Course ID the module belongs to.
* @return {Promise<any>} Promise resolved when invalidated.
*/
invalidateModule(module: any, courseId: number): Promise<any> {
// Invalidate the calls required to check if a quiz is downloadable.
const promises = [];
promises.push(this.quizProvider.invalidateQuizData(courseId));
promises.push(this.quizProvider.invalidateUserAttemptsForUser(module.instance));
return Promise.all(promises);
}
/**
* Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable.
*
* @param {any} module Module.
* @param {number} courseId Course ID the module belongs to.
* @return {boolean|Promise<boolean>} Whether the module can be downloaded. The promise should never be rejected.
*/
isDownloadable(module: any, courseId: number): boolean | Promise<boolean> {
const siteId = this.sitesProvider.getCurrentSiteId();
return this.quizProvider.getQuiz(courseId, module.id, false, siteId).then((quiz) => {
if (quiz.allowofflineattempts !== 1 || quiz.hasquestions === 0) {
return false;
}
// Not downloadable if we reached max attempts or the quiz has an unfinished attempt.
return this.quizProvider.getUserAttempts(quiz.id, undefined, true, false, false, siteId).then((attempts) => {
const isLastFinished = !attempts.length || this.quizProvider.isAttemptFinished(attempts[attempts.length - 1].state);
return quiz.attempts === 0 || quiz.attempts > attempts.length || !isLastFinished;
});
});
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return {boolean|Promise<boolean>} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled.
*/
isEnabled(): boolean | Promise<boolean> {
return this.quizProvider.isPluginEnabled();
}
/**
* Prefetch a module.
*
* @param {any} module Module.
* @param {number} courseId Course ID the module belongs to.
* @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
* @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
* @return {Promise<any>} Promise resolved when done.
*/
prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise<any> {
return this.prefetchPackage(module, courseId, single, this.prefetchQuiz.bind(this));
}
/**
* Prefetch a quiz.
*
* @param {any} module Module.
* @param {number} courseId Course ID the module belongs to.
* @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section.
* @param {String} siteId Site ID.
* @return {Promise<any>} Promise resolved when done.
*/
protected prefetchQuiz(module: any, courseId: number, single: boolean, siteId: string): Promise<any> {
let attempts: any[],
startAttempt = false,
quiz,
quizAccessInfo,
attemptAccessInfo,
preflightData;
// Get quiz.
return this.quizProvider.getQuiz(courseId, module.id, false, siteId).then((quizData) => {
quiz = quizData;
const promises = [],
introFiles = this.getIntroFilesFromInstance(module, quiz);
// Prefetch some quiz data.
promises.push(this.quizProvider.getQuizAccessInformation(quiz.id, false, true, siteId).then((info) => {
quizAccessInfo = info;
}));
promises.push(this.quizProvider.getQuizRequiredQtypes(quiz.id, true, siteId));
promises.push(this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, true, siteId).then((atts) => {
attempts = atts;
}));
promises.push(this.quizProvider.getAttemptAccessInformation(quiz.id, 0, false, true, siteId).then((info) => {
attemptAccessInfo = info;
}));
promises.push(this.filepoolProvider.addFilesToQueue(siteId, introFiles, AddonModQuizProvider.COMPONENT, module.id));
return Promise.all(promises);
}).then(() => {
// Check if we need to start a new attempt.
let attempt = attempts[attempts.length - 1];
if (!attempt || this.quizProvider.isAttemptFinished(attempt.state)) {
// Check if the user can attempt the quiz.
if (attemptAccessInfo.preventnewattemptreasons.length) {
return Promise.reject(this.textUtils.buildMessage(attemptAccessInfo.preventnewattemptreasons));
}
startAttempt = true;
attempt = undefined;
}
// Get the preflight data. This function will also start a new attempt if needed.
return this.getPreflightData(quiz, quizAccessInfo, attempt, single, 'core.download', siteId);
}).then((data) => {
preflightData = data;
const promises = [];
if (startAttempt) {
// Re-fetch user attempts since we created a new one.
promises.push(this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, true, siteId).then((atts) => {
attempts = atts;
}));
// Update the download time to prevent detecting the new attempt as an update.
promises.push(this.filepoolProvider.updatePackageDownloadTime(siteId, AddonModQuizProvider.COMPONENT, module.id)
.catch(() => {
// Ignore errors.
}));
}
// Fetch attempt related data.
promises.push(this.quizProvider.getCombinedReviewOptions(quiz.id, true, siteId));
promises.push(this.quizProvider.getUserBestGrade(quiz.id, true, siteId));
promises.push(this.quizProvider.getGradeFromGradebook(courseId, module.id, true, siteId).then((gradebookData) => {
if (typeof gradebookData.graderaw != 'undefined') {
return this.quizProvider.getFeedbackForGrade(quiz.id, gradebookData.graderaw, true, siteId);
}
}).catch(() => {
// Ignore errors.
}));
promises.push(this.quizProvider.getAttemptAccessInformation(quiz.id, 0, false, true, siteId)); // Last attempt.
return Promise.all(promises);
}).then(() => {
// We have quiz data, now we'll get specific data for each attempt.
const promises = [];
attempts.forEach((attempt) => {
promises.push(this.prefetchAttempt(quiz, attempt, preflightData, siteId));
});
return Promise.all(promises);
}).then(() => {
// If there's nothing to send, mark the quiz as synchronized.
// We don't return the promises because it should be fast and we don't want to block the user for this.
if (!this.syncProvider) {
this.syncProvider = this.injector.get(AddonModQuizSyncProvider);
}
this.syncProvider.hasDataToSync(quiz.id, siteId).then((hasData) => {
if (!hasData) {
this.syncProvider.setSyncTime(quiz.id, siteId);
}
});
});
}
/**
* Prefetch all WS data for an attempt.
*
* @param {any} quiz Quiz.
* @param {any} attempt Attempt.
* @param {any} preflightData Preflight required data (like password).
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the prefetch is finished. Data returned is not reliable.
*/
prefetchAttempt(quiz: any, attempt: any, preflightData: any, siteId?: string): Promise<any> {
const pages = this.quizProvider.getPagesFromLayout(attempt.layout),
promises = [],
isSequential = this.quizProvider.isNavigationSequential(quiz);
if (this.quizProvider.isAttemptFinished(attempt.state)) {
// Attempt is finished, get feedback and review data.
const attemptGrade = this.quizProvider.rescaleGrade(attempt.sumgrades, quiz, false);
if (typeof attemptGrade != 'undefined') {
promises.push(this.quizProvider.getFeedbackForGrade(quiz.id, Number(attemptGrade), true, siteId));
}
// Get the review for each page.
pages.forEach((page) => {
promises.push(this.quizProvider.getAttemptReview(attempt.id, page, true, siteId).catch(() => {
// Ignore failures, maybe the user can't review the attempt.
}));
});
// Get the review for all questions in same page.
promises.push(this.quizProvider.getAttemptReview(attempt.id, -1, true, siteId).then((data) => {
// Download the files inside the questions.
const questionPromises = [];
data.questions.forEach((question) => {
questionPromises.push(this.questionHelper.prefetchQuestionFiles(
question, this.component, quiz.coursemodule, siteId));
});
return Promise.all(questionPromises);
}, () => {
// Ignore failures, maybe the user can't review the attempt.
}));
} else {
// Attempt not finished, get data needed to continue the attempt.
promises.push(this.quizProvider.getAttemptAccessInformation(quiz.id, attempt.id, false, true, siteId));
promises.push(this.quizProvider.getAttemptSummary(attempt.id, preflightData, false, true, false, siteId));
if (attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
// Get data for each page.
pages.forEach((page) => {
if (isSequential && page < attempt.currentpage) {
// Sequential quiz, cannot get pages before the current one.
return;
}
promises.push(this.quizProvider.getAttemptData(attempt.id, page, preflightData, false, true, siteId)
.then((data) => {
// Download the files inside the questions.
const questionPromises = [];
data.questions.forEach((question) => {
questionPromises.push(this.questionHelper.prefetchQuestionFiles(
question, this.component, quiz.coursemodule, siteId));
});
return Promise.all(questionPromises);
}));
});
}
}
return Promise.all(promises);
}
/**
* Prefetches some data for a quiz and its last attempt.
* This function will NOT start a new attempt, it only reads data for the quiz and the last attempt.
*
* @param {any} quiz Quiz.
* @param {boolean} [askPreflight] Whether it should ask for preflight data if needed.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
prefetchQuizAndLastAttempt(quiz: any, askPreflight?: boolean, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const promises = [];
let attempts,
quizAccessInfo,
preflightData,
lastAttempt;
// Get quiz data.
promises.push(this.quizProvider.getQuizAccessInformation(quiz.id, false, true, siteId).then((info) => {
quizAccessInfo = info;
}));
promises.push(this.quizProvider.getQuizRequiredQtypes(quiz.id, true, siteId));
promises.push(this.quizProvider.getCombinedReviewOptions(quiz.id, true, siteId));
promises.push(this.quizProvider.getUserBestGrade(quiz.id, true, siteId));
promises.push(this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, true, siteId).then((atts) => {
attempts = atts;
}));
promises.push(this.quizProvider.getGradeFromGradebook(quiz.course, quiz.coursemodule, true, siteId)
.then((gradebookData) => {
if (typeof gradebookData.graderaw != 'undefined') {
return this.quizProvider.getFeedbackForGrade(quiz.id, gradebookData.graderaw, true, siteId);
}
}).catch(() => {
// Ignore errors.
}));
promises.push(this.quizProvider.getAttemptAccessInformation(quiz.id, 0, false, true, siteId)); // Last attempt.
return Promise.all(promises).then(() => {
lastAttempt = attempts[attempts.length - 1];
if (!lastAttempt) {
// No need to get attempt data, we don't need preflight data.
return;
}
// Get the preflight data.
return this.getPreflightData(quiz, quizAccessInfo, lastAttempt, askPreflight, 'core.download', siteId);
}).then((data) => {
preflightData = data;
if (lastAttempt) {
// Get data for last attempt.
return this.prefetchAttempt(quiz, lastAttempt, preflightData, siteId);
}
}).then(() => {
// Prefetch finished, get current status to determine if we need to change it.
return this.filepoolProvider.getPackageStatus(siteId, this.component, quiz.coursemodule);
}).then((status) => {
if (status !== CoreConstants.NOT_DOWNLOADED) {
// Quiz was downloaded, set the new status.
// If no attempts or last is finished we'll mark it as not downloaded to show download icon.
const isLastFinished = !lastAttempt || this.quizProvider.isAttemptFinished(lastAttempt.state),
newStatus = isLastFinished ? CoreConstants.NOT_DOWNLOADED : CoreConstants.DOWNLOADED;
return this.filepoolProvider.storePackageStatus(siteId, newStatus, this.component, quiz.coursemodule);
}
});
}
}

View File

@ -0,0 +1,385 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreQuestionProvider } from '@core/question/providers/question';
import { CoreQuestionBehaviourDelegate } from '@core/question/providers/behaviour-delegate';
import { AddonModQuizProvider } from './quiz';
import { SQLiteDB } from '@classes/sqlitedb';
/**
* Service to handle offline quiz.
*/
@Injectable()
export class AddonModQuizOfflineProvider {
protected logger;
// Variables for database.
protected ATTEMPTS_TABLE = 'addon_mod_quiz_attempts';
protected tablesSchema = [
{
name: this.ATTEMPTS_TABLE,
columns: [
{
name: 'id', // Attempt ID.
type: 'INTEGER',
primaryKey: true
},
{
name: 'attempt', // Attempt number.
type: 'INTEGER'
},
{
name: 'courseid',
type: 'INTEGER'
},
{
name: 'userid',
type: 'INTEGER'
},
{
name: 'quizid',
type: 'INTEGER'
},
{
name: 'currentpage',
type: 'INTEGER'
},
{
name: 'timecreated',
type: 'INTEGER'
},
{
name: 'timemodified',
type: 'INTEGER'
},
{
name: 'finished',
type: 'INTEGER'
}
]
}
];
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider,
private questionProvider: CoreQuestionProvider, private translate: TranslateService, private utils: CoreUtilsProvider,
private behaviourDelegate: CoreQuestionBehaviourDelegate) {
this.logger = logger.getInstance('AddonModQuizOfflineProvider');
this.sitesProvider.createTablesFromSchema(this.tablesSchema);
}
/**
* Classify the answers in questions.
*
* @param {any} answers List of answers.
* @return {any} Object with the questions, the keys are the slot. Each question contains its answers.
*/
classifyAnswersInQuestions(answers: any): any {
const questionsWithAnswers = {};
// Classify the answers in each question.
for (const name in answers) {
const slot = this.questionProvider.getQuestionSlotFromName(name),
nameWithoutPrefix = this.questionProvider.removeQuestionPrefix(name);
if (!questionsWithAnswers[slot]) {
questionsWithAnswers[slot] = {
answers: {},
prefix: name.substr(0, name.indexOf(nameWithoutPrefix))
};
}
questionsWithAnswers[slot].answers[nameWithoutPrefix] = answers[name];
}
return questionsWithAnswers;
}
/**
* Given a list of questions with answers classified in it (@see AddonModQuizOfflineProvider.classifyAnswersInQuestions),
* returns a list of answers (including prefix in the name).
*
* @param {any} questions Questions.
* @return {any} Answers.
*/
extractAnswersFromQuestions(questions: any): any {
const answers = {};
for (const slot in questions) {
const question = questions[slot];
for (const name in question.answers) {
answers[question.prefix + name] = question.answers[name];
}
}
return answers;
}
/**
* Get all the offline attempts in a certain site.
*
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with the offline attempts.
*/
getAllAttempts(siteId?: string): Promise<any[]> {
return this.sitesProvider.getSiteDb(siteId).then((db) => {
return db.getAllRecords(this.ATTEMPTS_TABLE);
});
}
/**
* Retrieve an attempt answers from site DB.
*
* @param {number} attemptId Attempt ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with the answers.
*/
getAttemptAnswers(attemptId: number, siteId?: string): Promise<any[]> {
return this.questionProvider.getAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId);
}
/**
* Retrieve an attempt from site DB.
*
* @param {number} attemptId Attempt ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with the attempt.
*/
getAttemptById(attemptId: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSiteDb(siteId).then((db) => {
return db.getRecord(this.ATTEMPTS_TABLE, {id: attemptId});
});
}
/**
* Retrieve an attempt from site DB.
*
* @param {number} attemptId Attempt ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User ID. If not defined, user current site's user.
* @return {Promise<any[]>} Promise resolved with the attempts.
*/
getQuizAttempts(quizId: number, siteId?: string, userId?: number): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
userId = userId || site.getUserId();
return site.getDb().getRecords(this.ATTEMPTS_TABLE, {quizid: quizId, userid: userId});
});
}
/**
* Load local state in the questions.
*
* @param {number} attemptId Attempt ID.
* @param {any[]} questions List of questions.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
loadQuestionsLocalStates(attemptId: number, questions: any[], siteId?: string): Promise<any[]> {
const promises = [];
questions.forEach((question) => {
promises.push(this.questionProvider.getQuestion(AddonModQuizProvider.COMPONENT, attemptId, question.slot, siteId)
.then((q) => {
const state = this.questionProvider.getState(q.state);
question.state = q.state;
question.status = this.translate.instant('core.question.' + state.status);
}).catch(() => {
// Question not found.
}));
});
return Promise.all(promises).then(() => {
return questions;
});
}
/**
* Process an attempt, saving its data.
*
* @param {any} quiz Quiz.
* @param {any} attempt Attempt.
* @param {any} questions Object with the questions of the quiz. The keys should be the question slot.
* @param {any} data Data to save.
* @param {boolean} [finish] Whether to finish the quiz.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved in success, rejected otherwise.
*/
processAttempt(quiz: any, attempt: any, questions: any, data: any, finish?: boolean, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const now = this.timeUtils.timestamp();
let db: SQLiteDB;
return this.sitesProvider.getSiteDb(siteId).then((siteDb) => {
db = siteDb;
// Check if an attempt already exists.
return this.getAttemptById(attempt.id, siteId).catch(() => {
// Attempt doesn't exist, create a new entry.
return {
quizid: quiz.id,
userid: attempt.userid,
id: attempt.id,
courseid: quiz.course,
timecreated: now,
attempt: attempt.attempt,
currentpage: attempt.currentpage
};
});
}).then((entry) => {
// Save attempt in DB.
entry.timemodified = now;
entry.finished = finish ? 1 : 0;
return db.insertRecord(this.ATTEMPTS_TABLE, entry);
}).then(() => {
// Attempt has been saved, now we need to save the answers.
return this.saveAnswers(quiz, attempt, questions, data, now, siteId);
});
}
/**
* Remove an attempt and its answers from local DB.
*
* @param {number} attemptId Attempt ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
removeAttemptAndAnswers(attemptId: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const promises = [];
// Remove stored answers and questions.
promises.push(this.questionProvider.removeAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId));
promises.push(this.questionProvider.removeAttemptQuestions(AddonModQuizProvider.COMPONENT, attemptId, siteId));
// Remove the attempt.
promises.push(this.sitesProvider.getSiteDb(siteId).then((db) => {
return db.deleteRecords(this.ATTEMPTS_TABLE, {id: attemptId});
}));
return Promise.all(promises);
}
/**
* Remove a question and its answers from local DB.
*
* @param {number} attemptId Attempt ID.
* @param {number} slot Question slot.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when finished.
*/
removeQuestionAndAnswers(attemptId: number, slot: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const promises = [];
promises.push(this.questionProvider.removeQuestion(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId));
promises.push(this.questionProvider.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId));
return Promise.all(promises);
}
/**
* Save an attempt's answers and calculate state for questions modified.
*
* @param {any} quiz Quiz.
* @param {any} attempt Attempt.
* @param {any} questions Object with the questions of the quiz. The keys should be the question slot.
* @param {any} answers Answers to save.
* @param {number} [timeMod] Time modified to set in the answers. If not defined, current time.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
saveAnswers(quiz: any, attempt: any, questions: any, answers: any, timeMod?: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
timeMod = timeMod || this.timeUtils.timestamp();
const questionsWithAnswers = {},
newStates = {};
let promises = [];
// Classify the answers in each question.
for (const name in answers) {
const slot = this.questionProvider.getQuestionSlotFromName(name),
nameWithoutPrefix = this.questionProvider.removeQuestionPrefix(name);
if (questions[slot]) {
if (!questionsWithAnswers[slot]) {
questionsWithAnswers[slot] = questions[slot];
questionsWithAnswers[slot].answers = {};
}
questionsWithAnswers[slot].answers[nameWithoutPrefix] = answers[name];
}
}
// First determine the new state of each question. We won't save the new state yet.
for (const slot in questionsWithAnswers) {
const question = questionsWithAnswers[slot];
promises.push(this.behaviourDelegate.determineNewState(
quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, attempt.id, question, siteId).then((state) => {
// Check if state has changed.
if (state && state.name != question.state) {
newStates[question.slot] = state.name;
}
}));
}
return Promise.all(promises).then(() => {
// Now save the answers.
return this.questionProvider.saveAnswers(AddonModQuizProvider.COMPONENT, quiz.id, attempt.id, attempt.userid,
answers, timeMod, siteId);
}).then(() => {
// Answers have been saved, now we can save the questions with the states.
promises = [];
for (const slot in newStates) {
const question = questionsWithAnswers[slot];
promises.push(this.questionProvider.saveQuestion(AddonModQuizProvider.COMPONENT, quiz.id, attempt.id,
attempt.userid, question, newStates[slot], siteId));
}
return this.utils.allPromises(promises).catch((err) => {
// Ignore errors when saving question state.
this.logger.error('Error saving question state', err);
});
});
}
/**
* Set attempt's current page.
*
* @param {number} attemptId Attempt ID.
* @param {number} page Page to set.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved in success, rejected otherwise.
*/
setAttemptCurrentPage(attemptId: number, page: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSiteDb(siteId).then((db) => {
return db.updateRecords(this.ATTEMPTS_TABLE, {currentpage: page}, {id: attemptId});
});
}
}

View File

@ -0,0 +1,404 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreEventsProvider } from '@providers/events';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreQuestionProvider } from '@core/question/providers/question';
import { CoreQuestionDelegate } from '@core/question/providers/delegate';
import { CoreSyncBaseProvider } from '@classes/base-sync';
import { AddonModQuizProvider } from './quiz';
import { AddonModQuizOfflineProvider } from './quiz-offline';
import { AddonModQuizPrefetchHandler } from './prefetch-handler';
/**
* Data returned by a quiz sync.
*/
export interface AddonModQuizSyncResult {
/**
* List of warnings.
* @type {string[]}
*/
warnings: string[];
/**
* Whether an attempt was finished in the site due to the sync,
* @type {boolean}
*/
attemptFinished: boolean;
}
/**
* Service to sync quizzes.
*/
@Injectable()
export class AddonModQuizSyncProvider extends CoreSyncBaseProvider {
static AUTO_SYNCED = 'addon_mod_quiz_autom_synced';
static SYNC_TIME = 300000;
protected componentTranslate: string;
constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider,
syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService,
courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider,
private quizProvider: AddonModQuizProvider, private quizOfflineProvider: AddonModQuizOfflineProvider,
private prefetchHandler: AddonModQuizPrefetchHandler, private questionProvider: CoreQuestionProvider,
private questionDelegate: CoreQuestionDelegate) {
super('AddonModQuizSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate);
this.componentTranslate = courseProvider.translateModuleName('quiz');
}
/**
* Finish a sync process: remove offline data if needed, prefetch quiz data, set sync time and return the result.
*
* @param {string} siteId Site ID.
* @param {any} quiz Quiz.
* @param {number} courseId Course ID.
* @param {string[]} warnings List of warnings generated by the sync.
* @param {number} [attemptId] Last attempt ID.
* @param {any} [offlineAttempt] Offline attempt synchronized, if any.
* @param {any} [onlineAttempt] Online data for the offline attempt.
* @param {boolean} [removeAttempt] Whether the offline data should be removed.
* @param {boolean} [updated] Whether some data was sent to the site.
* @return {Promise<AddonModQuizSyncResult>} Promise resolved on success.
*/
protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any,
onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise<AddonModQuizSyncResult> {
// Invalidate the data for the quiz and attempt.
return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, attemptId, siteId).catch(() => {
// Ignore errors.
}).then(() => {
if (removeAttempt && attemptId) {
return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId);
}
}).then(() => {
if (updated) {
// Data has been sent. Update prefetched data.
return this.prefetchHandler.prefetchQuizAndLastAttempt(quiz, false, siteId);
}
}).then(() => {
return this.setSyncTime(quiz.id, siteId).catch(() => {
// Ignore errors.
});
}).then(() => {
// Check if online attempt was finished because of the sync.
if (onlineAttempt && !this.quizProvider.isAttemptFinished(onlineAttempt.state)) {
// Attempt wasn't finished at start. Check if it's finished now.
return this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, false, siteId).then((attempts) => {
// Search the attempt.
for (const i in attempts) {
const attempt = attempts[i];
if (attempt.id == onlineAttempt.id) {
return this.quizProvider.isAttemptFinished(attempt.state);
}
}
return false;
});
}
return false;
}).then((attemptFinished) => {
return {
warnings: warnings,
attemptFinished: attemptFinished
};
});
}
/**
* Check if a quiz has data to synchronize.
*
* @param {number} quizId Quiz ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<boolean>} Promise resolved with boolean: whether it has data to sync.
*/
hasDataToSync(quizId: number, siteId?: string): Promise<boolean> {
return this.quizOfflineProvider.getQuizAttempts(quizId, siteId).then((attempts) => {
return !!attempts.length;
}).catch(() => {
return false;
});
}
/**
* Try to synchronize all the quizzes in a certain site or in all sites.
*
* @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
* @return {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
syncAllQuizzes(siteId?: string): Promise<any> {
return this.syncOnSites('all quizzes', this.syncAllQuizzesFunc.bind(this), [], siteId);
}
/**
* Sync all quizzes on a site.
*
* @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
* @param {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
protected syncAllQuizzesFunc(siteId?: string): Promise<any> {
// Get all offline attempts.
return this.quizOfflineProvider.getAllAttempts(siteId).then((attempts) => {
const quizzes = [],
ids = [], // To prevent duplicates.
promises = [];
// Get the IDs of all the quizzes that have something to be synced.
attempts.forEach((attempt) => {
if (ids.indexOf(attempt.quizid) == -1) {
ids.push(attempt.quizid);
quizzes.push({
id: attempt.quizid,
courseid: attempt.courseid
});
}
});
// Sync all quizzes that haven't been synced for a while and that aren't attempted right now.
quizzes.forEach((quiz) => {
if (!this.syncProvider.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) {
// Quiz not blocked, try to synchronize it.
promises.push(this.quizProvider.getQuizById(quiz.courseid, quiz.id, false, siteId).then((quiz) => {
return this.syncQuizIfNeeded(quiz, false, siteId).then((data) => {
if (data && data.warnings && data.warnings.length) {
// Store the warnings to show them when the user opens the quiz.
return this.setSyncWarnings(quiz.id, data.warnings, siteId).then(() => {
return data;
});
}
return data;
}).then((data) => {
if (typeof data != 'undefined') {
// Sync successful. Send event.
this.eventsProvider.trigger(AddonModQuizSyncProvider.AUTO_SYNCED, {
quizId: quiz.id,
attemptFinished: data.attemptFinished,
warnings: data.warnings
}, siteId);
}
});
}));
}
});
return Promise.all(promises);
});
}
/**
* Sync a quiz only if a certain time has passed since the last time.
*
* @param {any} quiz Quiz.
* @param {boolean} [askPreflight] Whether we should ask for preflight data if needed.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the quiz is synced or if it doesn't need to be synced.
*/
syncQuizIfNeeded(quiz: any, askPreflight?: boolean, siteId?: string): Promise<any> {
return this.isSyncNeeded(quiz.id, siteId).then((needed) => {
if (needed) {
return this.syncQuiz(quiz, askPreflight, siteId);
}
});
}
/**
* Try to synchronize a quiz.
* The promise returned will be resolved with an array with warnings if the synchronization is successful.
*
* @param {any} quiz Quiz.
* @param {boolean} [askPreflight] Whether we should ask for preflight data if needed.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<AddonModQuizSyncResult>} Promise resolved in success.
*/
syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const warnings = [],
courseId = quiz.course;
let syncPromise,
preflightData;
if (this.isSyncing(quiz.id, siteId)) {
// There's already a sync ongoing for this quiz, return the promise.
return this.getOngoingSync(quiz.id, siteId);
}
// Verify that quiz isn't blocked.
if (this.syncProvider.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) {
this.logger.debug('Cannot sync quiz ' + quiz.id + ' because it is blocked.');
return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
}
this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId);
// Get all the offline attempts for the quiz.
syncPromise = this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId).then((attempts) => {
// Should return 0 or 1 attempt.
if (!attempts.length) {
return this.finishSync(siteId, quiz, courseId, warnings);
}
const offlineAttempt = attempts.pop();
// Now get the list of online attempts to make sure this attempt exists and isn't finished.
return this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, true, siteId).then((attempts) => {
const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined;
let onlineAttempt;
// Search the attempt we retrieved from offline.
for (const i in attempts) {
const attempt = attempts[i];
if (attempt.id == offlineAttempt.id) {
onlineAttempt = attempt;
break;
}
}
if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) {
// Attempt not found or it's finished in online. Discard it.
warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished'));
return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, true);
}
// Get the data stored in offline.
return this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId).then((answersList) => {
if (!answersList.length) {
// No answers stored, finish.
return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt,
true);
}
const answers = this.questionProvider.convertAnswersArrayToObject(answersList),
offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(answers);
let finish;
// We're going to need preflightData, get it.
return this.quizProvider.getQuizAccessInformation(quiz.id, false, true, siteId).then((info) => {
return this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight,
'core.settings.synchronization', siteId);
}).then((data) => {
preflightData = data;
// Now get the online questions data.
const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions);
return this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, pages, false, true,
siteId);
}).then((onlineQuestions) => {
// Validate questions, discarding the offline answers that can't be synchronized.
return this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId);
}).then((discardedData) => {
// Get the answers to send.
const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions);
finish = offlineAttempt.finished && !discardedData;
if (discardedData) {
if (offlineAttempt.finished) {
warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscardedfromfinished'));
} else {
warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscarded'));
}
}
return this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false,
siteId);
}).then(() => {
// Answers sent, now set the current page if the attempt isn't finished.
if (!finish) {
return this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage, preflightData,
false).catch(() => {
// Ignore errors.
});
}
}).then(() => {
// Data sent. Finish the sync.
return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt,
true, true);
});
});
});
});
return this.addOngoingSync(quiz.id, syncPromise, siteId);
}
/**
* Validate questions, discarding the offline answers that can't be synchronized.
*
* @param {number} attemptId Attempt ID.
* @param {any} onlineQuestions Online questions
* @param {any} offlineQuestions Offline questions.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<boolean>} Promise resolved with boolean: true if some offline data was discarded, false otherwise.
*/
validateQuestions(attemptId: number, onlineQuestions: any, offlineQuestions: any, siteId?: string): Promise<boolean> {
const promises = [];
let discardedData = false;
for (const slot in offlineQuestions) {
const offlineQuestion = offlineQuestions[slot],
onlineQuestion = onlineQuestions[slot],
offlineSequenceCheck = offlineQuestion.answers[':sequencecheck'];
if (onlineQuestion) {
// We found the online data for the question, validate that the sequence check is ok.
if (!this.questionDelegate.validateSequenceCheck(onlineQuestion, offlineSequenceCheck)) {
// Sequence check is not valid, remove the offline data.
discardedData = true;
promises.push(this.quizOfflineProvider.removeQuestionAndAnswers(attemptId, Number(slot), siteId));
delete offlineQuestions[slot];
} else {
// Sequence check is valid. Use the online one to prevent synchronization errors.
offlineQuestion.answers[':sequencecheck'] = onlineQuestion.sequencecheck;
}
} else {
// Online question not found, it can happen for 2 reasons:
// 1- It's a sequential quiz and the question is in a page already passed.
// 2- Quiz layout has changed (shouldn't happen since it's blocked if there are attempts).
discardedData = true;
promises.push(this.quizOfflineProvider.removeQuestionAndAnswers(attemptId, Number(slot), siteId));
delete offlineQuestions[slot];
}
}
return Promise.all(promises).then(() => {
return discardedData;
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,117 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate';
import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper';
import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { AddonModQuizProvider } from './quiz';
/**
* Handler to treat links to quiz review.
*/
@Injectable()
export class AddonModQuizReviewLinkHandler extends CoreContentLinksHandlerBase {
name = 'AddonModQuizReviewLinkHandler';
featureName = 'CoreCourseModuleDelegate_AddonModQuiz';
pattern = /\/mod\/quiz\/review\.php.*([\&\?]attempt=\d+)/;
constructor(protected domUtils: CoreDomUtilsProvider, protected quizProvider: AddonModQuizProvider,
protected courseHelper: CoreCourseHelperProvider, protected linkHelper: CoreContentLinksHelperProvider) {
super();
}
/**
* Get the list of actions for a link (url).
*
* @param {string[]} siteIds List of sites the URL belongs to.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {CoreContentLinksAction[]|Promise<CoreContentLinksAction[]>} List of (or promise resolved with list of) actions.
*/
getActions(siteIds: string[], url: string, params: any, courseId?: number):
CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
courseId = courseId || params.courseid || params.cid;
return [{
action: (siteId, navCtrl?): void => {
// Retrieve the quiz ID using the attempt ID.
const modal = this.domUtils.showModalLoading(),
attemptId = parseInt(params.attempt, 10),
page = parseInt(params.page, 10);
let quizId;
this.getQuizIdByAttemptId(attemptId).then((id) => {
quizId = id;
// Get the courseId if we don't have it.
if (courseId) {
return courseId;
} else {
return this.courseHelper.getModuleCourseIdByInstance(quizId, 'quiz', siteId);
}
}).then((courseId) => {
// Go to the review page.
const pageParams = {
quizId: quizId,
attemptId: attemptId,
courseId: courseId,
page: params.showall ? -1 : (isNaN(page) ? -1 : page)
};
this.linkHelper.goInSite(navCtrl, 'AddonModQuizReviewPage', pageParams, siteId);
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'An error occurred while loading the required data.');
}).finally(() => {
modal.dismiss();
});
}
}];
}
/**
* Get a quiz ID by attempt ID.
*
* @param {number} attemptId Attempt ID.
* @return {Promise<number>} Promise resolved with the quiz ID.
*/
protected getQuizIdByAttemptId(attemptId: number): Promise<number> {
// Use getAttemptReview to retrieve the quiz ID.
return this.quizProvider.getAttemptReview(attemptId).then((reviewData) => {
if (reviewData.attempt && reviewData.attempt.quiz) {
return reviewData.attempt.quiz;
}
return Promise.reject(null);
});
}
/**
* Check if the handler is enabled for a certain site (site + user) and a URL.
* If not defined, defaults to true.
*
* @param {string} siteId The site ID.
* @param {string} url The URL to treat.
* @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
* @param {number} [courseId] Course ID related to the URL. Optional but recommended.
* @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
*/
isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
return this.quizProvider.isPluginEnabled();
}
}

View File

@ -0,0 +1,47 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreCronHandler } from '@providers/cron';
import { AddonModQuizSyncProvider } from './quiz-sync';
/**
* Synchronization cron handler.
*/
@Injectable()
export class AddonModQuizSyncCronHandler implements CoreCronHandler {
name = 'AddonModQuizSyncCronHandler';
constructor(private quizSync: AddonModQuizSyncProvider) {}
/**
* Execute the process.
* Receives the ID of the site affected, undefined for all sites.
*
* @param {string} [siteId] ID of the site affected, undefined for all sites.
* @return {Promise<any>} Promise resolved when done, rejected if failure.
*/
execute(siteId?: string): Promise<any> {
return this.quizSync.syncAllQuizzes(siteId);
}
/**
* Get the time between consecutive executions.
*
* @return {number} Time between consecutive executions (in ms).
*/
getInterval(): number {
return AddonModQuizSyncProvider.SYNC_TIME;
}
}

View File

@ -0,0 +1,87 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CoreCronDelegate } from '@providers/cron';
import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate';
import { AddonModQuizAccessRuleDelegate } from './providers/access-rules-delegate';
import { AddonModQuizProvider } from './providers/quiz';
import { AddonModQuizOfflineProvider } from './providers/quiz-offline';
import { AddonModQuizHelperProvider } from './providers/helper';
import { AddonModQuizSyncProvider } from './providers/quiz-sync';
import { AddonModQuizModuleHandler } from './providers/module-handler';
import { AddonModQuizPrefetchHandler } from './providers/prefetch-handler';
import { AddonModQuizSyncCronHandler } from './providers/sync-cron-handler';
import { AddonModQuizIndexLinkHandler } from './providers/index-link-handler';
import { AddonModQuizGradeLinkHandler } from './providers/grade-link-handler';
import { AddonModQuizReviewLinkHandler } from './providers/review-link-handler';
import { AddonModQuizComponentsModule } from './components/components.module';
// Access rules.
import { AddonModQuizAccessDelayBetweenAttemptsModule } from './accessrules/delaybetweenattempts/delaybetweenattempts.module';
import { AddonModQuizAccessIpAddressModule } from './accessrules/ipaddress/ipaddress.module';
import { AddonModQuizAccessNumAttemptsModule } from './accessrules/numattempts/numattempts.module';
import { AddonModQuizAccessOfflineAttemptsModule } from './accessrules/offlineattempts/offlineattempts.module';
import { AddonModQuizAccessOpenCloseDateModule } from './accessrules/openclosedate/openclosedate.module';
import { AddonModQuizAccessPasswordModule } from './accessrules/password/password.module';
import { AddonModQuizAccessSafeBrowserModule } from './accessrules/safebrowser/safebrowser.module';
import { AddonModQuizAccessSecureWindowModule } from './accessrules/securewindow/securewindow.module';
import { AddonModQuizAccessTimeLimitModule } from './accessrules/timelimit/timelimit.module';
@NgModule({
declarations: [
],
imports: [
AddonModQuizComponentsModule,
AddonModQuizAccessDelayBetweenAttemptsModule,
AddonModQuizAccessIpAddressModule,
AddonModQuizAccessNumAttemptsModule,
AddonModQuizAccessOfflineAttemptsModule,
AddonModQuizAccessOpenCloseDateModule,
AddonModQuizAccessPasswordModule,
AddonModQuizAccessSafeBrowserModule,
AddonModQuizAccessSecureWindowModule,
AddonModQuizAccessTimeLimitModule
],
providers: [
AddonModQuizAccessRuleDelegate,
AddonModQuizProvider,
AddonModQuizOfflineProvider,
AddonModQuizHelperProvider,
AddonModQuizSyncProvider,
AddonModQuizModuleHandler,
AddonModQuizPrefetchHandler,
AddonModQuizSyncCronHandler,
AddonModQuizIndexLinkHandler,
AddonModQuizGradeLinkHandler,
AddonModQuizReviewLinkHandler
]
})
export class AddonModQuizModule {
constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModQuizModuleHandler,
prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModQuizPrefetchHandler,
cronDelegate: CoreCronDelegate, syncHandler: AddonModQuizSyncCronHandler, linksDelegate: CoreContentLinksDelegate,
indexHandler: AddonModQuizIndexLinkHandler, gradeHandler: AddonModQuizGradeLinkHandler,
reviewHandler: AddonModQuizReviewLinkHandler) {
moduleDelegate.registerHandler(moduleHandler);
prefetchDelegate.registerHandler(prefetchHandler);
cronDelegate.register(syncHandler);
linksDelegate.registerHandler(indexHandler);
linksDelegate.registerHandler(gradeHandler);
linksDelegate.registerHandler(reviewHandler);
}
}

View File

@ -20,6 +20,8 @@ import { AddonModResourceIndexComponent } from '../components/index/index';
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { CoreConstants } from '@core/constants';
/**
* Handler to support resource modules.
@ -29,8 +31,12 @@ export class AddonModResourceModuleHandler implements CoreCourseModuleHandler {
name = 'AddonModResource';
modName = 'resource';
protected statusObserver;
constructor(protected resourceProvider: AddonModResourceProvider, private courseProvider: CoreCourseProvider,
protected mimetypeUtils: CoreMimetypeUtilsProvider, private resourceHelper: AddonModResourceHelperProvider) { }
protected mimetypeUtils: CoreMimetypeUtilsProvider, private resourceHelper: AddonModResourceHelperProvider,
protected prefetchDelegate: CoreCourseModulePrefetchDelegate) {
}
/**
* Check if the handler is enabled on a site level.
@ -50,6 +56,11 @@ export class AddonModResourceModuleHandler implements CoreCourseModuleHandler {
* @return {CoreCourseModuleHandlerData} Data to render the module.
*/
getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData {
const updateStatus = (status: string): void => {
handlerData.buttons[0].hidden = status !== CoreConstants.DOWNLOADED ||
this.resourceHelper.isDisplayedInIframe(module);
};
const handlerData = {
icon: this.courseProvider.getModuleIconSrc('resource'),
title: module.name,
@ -58,8 +69,9 @@ export class AddonModResourceModuleHandler implements CoreCourseModuleHandler {
action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void {
navCtrl.push('AddonModResourceIndexPage', {module: module, courseId: courseId}, options);
},
updateStatus: updateStatus.bind(this),
buttons: [ {
hidden: !this.resourceHelper.isDisplayedInIframe(module),
hidden: true,
icon: 'document',
label: 'addon.mod_resource.openthefile',
action: (event: Event, navCtrl: NavController, module: any, courseId: number): void => {
@ -92,7 +104,9 @@ export class AddonModResourceModuleHandler implements CoreCourseModuleHandler {
*/
protected hideOpenButton(module: any, courseId: number): Promise<boolean> {
return this.courseProvider.loadModuleContents(module, courseId).then(() => {
return this.resourceHelper.isDisplayedInIframe(module);
return this.prefetchDelegate.getModuleStatus(module, courseId).then((status) => {
return status !== CoreConstants.DOWNLOADED || this.resourceHelper.isDisplayedInIframe(module);
});
});
}

View File

@ -13,15 +13,6 @@ addon-mod-survey-index {
background-color: $white;
}
ion-select {
float: right;
max-width: none;
.select-text {
white-space: normal;
text-align: right;
}
}
.even {
background-color: $gray-light;
}

View File

@ -188,15 +188,15 @@ export class AddonModSurveyIndexComponent extends CoreCourseModuleMainActivityCo
});
}
this.surveyProvider.submitAnswers(this.survey.id, this.survey.name, this.courseId, answers).then(() => {
return this.surveyProvider.submitAnswers(this.survey.id, this.survey.name, this.courseId, answers).then(() => {
this.content.scrollToTop();
return this.refreshContent(false);
}).catch((message) => {
this.domUtils.showErrorModalDefault(message, 'addon.mod_survey.cannotsubmitsurvey', true);
}).finally(() => {
modal.dismiss();
});
}).catch((message) => {
this.domUtils.showErrorModalDefault(message, 'addon.mod_survey.cannotsubmitsurvey', true);
});
}

View File

@ -35,12 +35,14 @@ export class AddonModSurveySyncProvider extends CoreSyncBaseProvider {
static AUTO_SYNCED = 'addon_mod_survey_autom_synced';
protected componentTranslate: string;
constructor(protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider,
protected appProvider: CoreAppProvider, private surveyOffline: AddonModSurveyOfflineProvider,
constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider,
syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService,
courseProvider: CoreCourseProvider, private surveyOffline: AddonModSurveyOfflineProvider,
private eventsProvider: CoreEventsProvider, private surveyProvider: AddonModSurveyProvider,
private translate: TranslateService, private utils: CoreUtilsProvider, protected textUtils: CoreTextUtilsProvider,
courseProvider: CoreCourseProvider, syncProvider: CoreSyncProvider) {
super('AddonModSurveySyncProvider', sitesProvider, loggerProvider, appProvider, syncProvider, textUtils);
private utils: CoreUtilsProvider) {
super('AddonModSurveySyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate);
this.componentTranslate = courseProvider.translateModuleName('survey');
}

View File

@ -12,7 +12,7 @@
<form name="itemEdit" (ngSubmit)="addNote()">
<ion-item>
<ion-label>{{ 'addon.notes.publishstate' | translate }}</ion-label>
<ion-select [(ngModel)]="publishState" name="publishState">
<ion-select [(ngModel)]="publishState" name="publishState" interface="popover">
<ion-option value="personal">{{ 'addon.notes.personalnotes' | translate }}</ion-option>
<ion-option value="course">{{ 'addon.notes.coursenotes' | translate }}</ion-option>
<ion-option value="site">{{ 'addon.notes.sitenotes' | translate }}</ion-option>

View File

@ -34,12 +34,13 @@ export class AddonNotesSyncProvider extends CoreSyncBaseProvider {
static AUTO_SYNCED = 'addon_notes_autom_synced';
constructor(protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider,
protected appProvider: CoreAppProvider, private notesOffline: AddonNotesOfflineProvider,
constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider,
syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService,
private notesOffline: AddonNotesOfflineProvider, private utils: CoreUtilsProvider,
private eventsProvider: CoreEventsProvider, private notesProvider: AddonNotesProvider,
private coursesProvider: CoreCoursesProvider, private translate: TranslateService, private utils: CoreUtilsProvider,
syncProvider: CoreSyncProvider, protected textUtils: CoreTextUtilsProvider) {
super('AddonNotesSync', sitesProvider, loggerProvider, appProvider, syncProvider, textUtils);
private coursesProvider: CoreCoursesProvider) {
super('AddonNotesSync', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate);
}
/**

View File

@ -1,6 +1,16 @@
<ion-item text-wrap class="addon-qbehaviour-deferredcbm-certainty-title" *ngIf="question.behaviourCertaintyOptions && question.behaviourCertaintyOptions.length">
<p>{{ 'core.question.certainty' | translate }}</p>
</ion-item>
<ion-radio *ngFor="let option of question.behaviourCertaintyOptions" id="{{option.id}}" name="{{option.name}}" [ngModel]="question.behaviourCertaintySelected" [value]="option.value" [disabled]="option.disabled">
<p><core-format-text [component]="component" [componentId]="componentId" [text]="option.text"></core-format-text></p>
</ion-radio>
<div *ngIf="question.behaviourCertaintyOptions && question.behaviourCertaintyOptions.length">
<ion-item text-wrap class="addon-qbehaviour-deferredcbm-certainty-title" >
<p>{{ 'core.question.certainty' | translate }}</p>
</ion-item>
<div radio-group [(ngModel)]="question.behaviourCertaintySelected" [name]="question.behaviourCertaintyOptions[0].name">
<ion-item text-wrap *ngFor="let option of question.behaviourCertaintyOptions">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="option.text"></core-format-text>
</ion-label>
<ion-radio id="{{option.id}}" [value]="option.value" [disabled]="option.disabled"></ion-radio>
</ion-item>
</div>
<!-- ion-radio doesn't use an input. Create a hidden input to hold the selected value. -->
<input type="hidden" [ngModel]="question.behaviourCertaintySelected" [attr.name]="question.behaviourCertaintyOptions[0].name">
</div>

View File

@ -8,24 +8,26 @@
<ng-container *ngTemplateOutlet="radioUnits"></ng-container>
</ng-container>
<ion-item text-wrap>
<ion-row>
<!-- Display unit select before the answer input. -->
<ng-container *ngIf="question.select && question.selectFirst">
<ng-container *ngTemplateOutlet="selectUnits"></ng-container>
</ng-container>
<ion-item text-wrap ion-grid>
<ion-grid item-content>
<ion-row>
<!-- Display unit select before the answer input. -->
<ng-container *ngIf="question.select && question.selectFirst">
<ng-container *ngTemplateOutlet="selectUnits"></ng-container>
</ng-container>
<!-- Input to enter the answer. -->
<ion-col>
<ion-input type="text" placeholder="{{ 'core.question.answer' | translate }}" [attr.name]="question.input.name" [value]="question.input.value" [disabled]="question.input.readOnly" [ngClass]='{"core-question-answer-correct": question.input.isCorrect === 1, "core-question-answer-incorrect": question.input.isCorrect === 0}' autocorrect="off">
</ion-input>
</ion-col>
<!-- Input to enter the answer. -->
<ion-col>
<ion-input padding-left type="text" placeholder="{{ 'core.question.answer' | translate }}" [attr.name]="question.input.name" [value]="question.input.value" [disabled]="question.input.readOnly" [ngClass]='{"core-question-answer-correct": question.input.isCorrect === 1, "core-question-answer-incorrect": question.input.isCorrect === 0}' autocorrect="off">
</ion-input>
</ion-col>
<!-- Display unit select after the answer input. -->
<ng-container *ngIf="question.select && !question.selectFirst">
<ng-container *ngTemplateOutlet="selectUnits"></ng-container>
</ng-container>
</ion-row>
<!-- Display unit select after the answer input. -->
<ng-container *ngIf="question.select && !question.selectFirst">
<ng-container *ngTemplateOutlet="selectUnits"></ng-container>
</ng-container>
</ion-row>
</ion-grid>
</ion-item>
<!-- Display unit options after the answer input. -->
@ -38,18 +40,26 @@
<ng-template #selectUnits>
<ion-col>
<label *ngIf="question.select.accessibilityLabel" class="accesshide" for="{{question.select.id}}">{{ question.select.accessibilityLabel }}</label>
<ion-select id="{{question.select.id}}" [name]="question.select.name" [ngModel]="question.select.selected">
<ion-select id="{{question.select.id}}" [name]="question.select.name" [(ngModel)]="question.select.selected" interface="popover">
<ion-option *ngFor="let option of question.select.options" [value]="option.value">{{option.label}}</ion-option>
</ion-select>
<!-- @todo: select fix? -->
<!-- ion-select doesn't use a select. Create a hidden input to hold the selected value. -->
<input type="hidden" [ngModel]="question.select.selected" [attr.name]="question.select.name">
</ion-col>
</ng-template>
<!-- Template for units entered using radio buttons. -->
<ng-template #radioUnits>
<div radio-group [ngModel]="question.unit" [name]="question.optionsName">
<ion-radio *ngFor="let option of question.options" [value]="option.value" [disabled]="option.disabled">
<p>{{option.text}}</p>
</ion-radio>
<div radio-group [(ngModel)]="question.unit" [name]="question.optionsName">
<ion-item text-wrap *ngFor="let option of question.options">
<ion-label>
<p>{{option.text}}</p>
</ion-label>
<ion-radio [value]="option.value" [disabled]="option.disabled"></ion-radio>
</ion-item>
<!-- ion-radio doesn't use an input. Create a hidden input to hold the selected value. -->
<input type="hidden" [ngModel]="question.unit" [attr.name]="question.optionsName">
</div>
</ng-template>

View File

@ -45,6 +45,7 @@ export class AddonQtypeDdImageOrTextQuestion {
protected topNode: HTMLElement;
protected proportion = 1;
protected selected: HTMLElement; // Selected element (being "dragged").
protected resizeFunction;
/**
* Create the this.
@ -182,7 +183,10 @@ export class AddonQtypeDdImageOrTextQuestion {
*/
destroy(): void {
this.stopPolling();
window.removeEventListener('resize', this.resizeFunction);
if (this.resizeFunction) {
window.removeEventListener('resize', this.resizeFunction);
}
}
/**
@ -192,7 +196,7 @@ export class AddonQtypeDdImageOrTextQuestion {
* @return {AddonQtypeDdImageOrTextQuestionDocStructure} The object.
*/
docStructure(slot: number): AddonQtypeDdImageOrTextQuestionDocStructure {
const topNode = <HTMLElement> this.container.querySelector(`#core-question-${slot} .addon-qtype-ddimageortext-container`),
const topNode = <HTMLElement> this.container.querySelector('.addon-qtype-ddimageortext-container'),
dragItemsArea = <HTMLElement> topNode.querySelector('div.dragitems'),
doc: AddonQtypeDdImageOrTextQuestionDocStructure = {};
@ -456,6 +460,7 @@ export class AddonQtypeDdImageOrTextQuestion {
this.pollForImageLoad();
});
this.resizeFunction = this.repositionDragsForQuestion.bind(this);
window.addEventListener('resize', this.resizeFunction);
}
@ -637,13 +642,6 @@ export class AddonQtypeDdImageOrTextQuestion {
}
}
/**
* Function to call when the window is resized.
*/
resizeFunction(): void {
this.repositionDragsForQuestion();
}
/**
* Mark a draggable element as selected.
*

View File

@ -3,11 +3,11 @@
<core-loading [hideUntil]="question.loaded"></core-loading>
<ion-item text-wrap [ngClass]="{invisible: !question.loaded}">
<p *ngIf="!question.readOnly" class="core-info-card-icon">
<ion-icon name="information" item-start></ion-icon>
<p *ngIf="!question.readOnly" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
{{ 'core.question.howtodraganddrop' | translate }}
</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
<core-format-text *ngIf="question.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId" [text]="question.ddArea"></core-format-text>
<core-format-text *ngIf="question.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId" [text]="question.ddArea" (afterRender)="questionRendered()"></core-format-text>
</ion-item>
</section>

View File

@ -0,0 +1,101 @@
// Style ddimageortext content a bit. Almost all these styles are copied from Moodle.
addon-qtype-ddimageortext {
.qtext {
margin-bottom: 0.5em;
display: block;
}
div.droparea img {
border: 1px solid $gray-darker;
max-width: 100%;
}
.draghome {
vertical-align: top;
margin: 5px;
visibility : hidden;
}
.draghome img {
display: block;
}
div.draghome {
border: 1px solid $gray-darker;
cursor: pointer;
background-color: #B0C4DE;
display:inline-block;
height: auto;
width: auto;
zoom: 1;
}
.group1 {
background-color: $white;
}
.group2 {
background-color: $blue-light;
}
.group3 {
background-color: #DCDCDC;
}
.group4 {
background-color: #D8BFD8;
}
.group5 {
background-color: #87CEFA;
}
.group6 {
background-color: #DAA520;
}
.group7 {
background-color: #FFD700;
}
.group8 {
background-color: #F0E68C;
}
.drag {
border: 1px solid $gray-darker;
cursor: pointer;
z-index: 2;
}
.dragitems.readonly .drag {
cursor: auto;
}
.dragitems>div {
clear: both;
}
.dragitems {
cursor: pointer;
}
.dragitems.readonly {
cursor: auto;
}
.drag img {
display: block;
}
div.ddarea {
text-align : center;
position: relative;
}
.dropbackground {
margin:0 auto;
}
.dropzone {
border: 1px solid $gray-darker;
position: absolute;
z-index: 1;
cursor: pointer;
}
.readonly .dropzone {
cursor: auto;
}
div.dragitems div.draghome, div.dragitems div.drag {
font:13px/1.231 arial,helvetica,clean,sans-serif;
}
.drag.beingdragged {
z-index: 3;
box-shadow: 3px 3px 4px $gray-darker;
}
}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, OnDestroy, AfterViewInit, Injector, ElementRef } from '@angular/core';
import { Component, OnInit, OnDestroy, Injector, ElementRef } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
import { AddonQtypeDdImageOrTextQuestion } from '../classes/ddimageortext';
@ -24,14 +24,15 @@ import { AddonQtypeDdImageOrTextQuestion } from '../classes/ddimageortext';
selector: 'addon-qtype-ddimageortext',
templateUrl: 'ddimageortext.html'
})
export class AddonQtypeDdImageOrTextComponent extends CoreQuestionBaseComponent implements OnInit, AfterViewInit, OnDestroy {
export class AddonQtypeDdImageOrTextComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy {
protected element: HTMLElement;
protected questionInstance: AddonQtypeDdImageOrTextQuestion;
protected drops: any[]; // The drop zones received in the init object of the question.
protected destroyed = false;
constructor(logger: CoreLoggerProvider, injector: Injector, element: ElementRef) {
super(logger, 'AddonQtypeDdImageOrTextComponent', injector);
constructor(protected loggerProvider: CoreLoggerProvider, injector: Injector, element: ElementRef) {
super(loggerProvider, 'AddonQtypeDdImageOrTextComponent', injector);
this.element = element.nativeElement;
}
@ -76,18 +77,21 @@ export class AddonQtypeDdImageOrTextComponent extends CoreQuestionBaseComponent
}
/**
* View has been initialized.
* The question has been rendered.
*/
ngAfterViewInit(): void {
// Create the instance.
this.questionInstance = new AddonQtypeDdImageOrTextQuestion(this.logger, this.domUtils, this.element,
this.question, this.question.readOnly, this.drops);
questionRendered(): void {
if (!this.destroyed) {
// Create the instance.
this.questionInstance = new AddonQtypeDdImageOrTextQuestion(this.loggerProvider, this.domUtils, this.element,
this.question, this.question.readOnly, this.drops);
}
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.destroyed = true;
this.questionInstance && this.questionInstance.destroy();
}
}

View File

@ -50,6 +50,7 @@ export class AddonQtypeDdMarkerQuestion {
protected proportion = 1;
protected selected: HTMLElement; // Selected element (being "dragged").
protected graphics: AddonQtypeDdMarkerGraphicsApi;
protected resizeFunction;
doc: AddonQtypeDdMarkerQuestionDocStructure;
shapes = [];
@ -157,7 +158,9 @@ export class AddonQtypeDdMarkerQuestion {
* Function to call when the instance is no longer needed.
*/
destroy(): void {
window.removeEventListener('resize', this.resizeFunction);
if (this.resizeFunction) {
window.removeEventListener('resize', this.resizeFunction);
}
}
/**
@ -167,7 +170,7 @@ export class AddonQtypeDdMarkerQuestion {
* @return {AddonQtypeDdMarkerQuestionDocStructure} The object.
*/
docStructure(slot: number): AddonQtypeDdMarkerQuestionDocStructure {
const topNode = <HTMLElement> this.container.querySelector('#core-question-' + slot + ' .addon-qtype-ddmarker-container'),
const topNode = <HTMLElement> this.container.querySelector('.addon-qtype-ddmarker-container'),
dragItemsArea = <HTMLElement> topNode.querySelector('div.dragitems');
return {
@ -293,9 +296,9 @@ export class AddonQtypeDdMarkerQuestion {
const markerTexts = this.doc.markerTexts();
// Check if there is already a marker text for this drop zone.
if (link) {
existingMarkerText = markerTexts.querySelector('span.markertext' + dropZoneNo + ' a');
existingMarkerText = <HTMLElement> markerTexts.querySelector('span.markertext' + dropZoneNo + ' a');
} else {
existingMarkerText = markerTexts.querySelector('span.markertext' + dropZoneNo);
existingMarkerText = <HTMLElement> markerTexts.querySelector('span.markertext' + dropZoneNo);
}
if (existingMarkerText) {
@ -538,7 +541,7 @@ export class AddonQtypeDdMarkerQuestion {
dragging = (this.doc.dragItemBeingDragged(choiceNo) !== null),
coords: number[][] = [];
if (fv !== '' && typeof fv != 'undefined') {
if (fv !== '' && typeof fv != 'undefined' && fv !== null) {
// Get all the coordinates in the input and add them to the coords list.
const coordsStrings = fv.split(';');
@ -645,6 +648,7 @@ export class AddonQtypeDdMarkerQuestion {
this.pollForImageLoad();
});
this.resizeFunction = this.redrawDragsAndDrops.bind(this);
window.addEventListener('resize', this.resizeFunction);
}
@ -731,6 +735,9 @@ export class AddonQtypeDdMarkerQuestion {
}, 500);
}
/**
* Redraw all draggables and drop zones.
*/
redrawDragsAndDrops(): void {
// Mark all the draggable items as not placed.
const drags = this.doc.dragItems();
@ -789,7 +796,7 @@ export class AddonQtypeDdMarkerQuestion {
dropZone = this.dropZones[dropZoneNo],
dzNo = Number(dropZoneNo);
this.drawDropZone(dzNo, dropZone.markerText, dropZone.shape, dropZone.coords, colourForDropZone, true);
this.drawDropZone(dzNo, dropZone.markertext, dropZone.shape, dropZone.coords, colourForDropZone, true);
}
}
}
@ -803,13 +810,6 @@ export class AddonQtypeDdMarkerQuestion {
this.setFormValue(choiceNo, '');
}
/**
* Function to call when the window is resized.
*/
resizeFunction(): void {
this.redrawDragsAndDrops();
}
/**
* Restart the colour index.
*/

View File

@ -3,11 +3,11 @@
<core-loading [hideUntil]="question.loaded"></core-loading>
<ion-item text-wrap [ngClass]="{invisible: !question.loaded}">
<p *ngIf="!question.readOnly" class="core-info-card-icon">
<ion-icon name="information" item-start></ion-icon>
<p *ngIf="!question.readOnly" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
{{ 'core.question.howtodraganddrop' | translate }}
</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
<core-format-text *ngIf="question.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId" [text]="question.ddArea"></core-format-text>
<core-format-text *ngIf="question.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId" [text]="question.ddArea" (afterRender)="questionRendered()"></core-format-text>
</ion-item>
</section>

View File

@ -0,0 +1,97 @@
// Style ddmarker content a bit. Almost all these styles are copied from Moodle.
addon-qtype-ddmarker {
.qtext {
margin-bottom: 0.5em;
display: block;
}
div.droparea img {
border: 1px solid $gray-darker;
max-width: 100%;
}
.draghome img, .draghome span {
visibility: hidden;
}
.dragitems .dragitem {
cursor: pointer;
position: absolute;
z-index: 2;
}
.dropzones {
position: absolute;
}
.dropzones svg {
z-index: 3;
}
.dragitem.beingdragged .markertext {
z-index: 5;
box-shadow: 3px 3px 4px $gray-darker;
}
.dragitems .draghome {
margin: 10px;
display: inline-block;
}
.dragitems.readonly .dragitem {
cursor: auto;
}
div.ddarea {
text-align: center;
}
div.ddarea .markertexts {
min-height: 80px;
position: absolute;
text-align: left;
}
.dropbackground {
margin: 0 auto;
}
div.dragitems div.draghome, div.dragitems div.dragitem,
div.draghome, div.drag {
font: 13px/1.231 arial,helvetica,clean,sans-serif;
}
div.dragitems span.markertext,
div.markertexts span.markertext {
margin: 0 5px;
z-index: 2;
background-color: $white;
border: 2px solid $gray-darker;
padding: 5px;
display: inline-block;
zoom: 1;
border-radius: 10px;
}
div.markertexts span.markertext {
z-index: 3;
background-color: $yellow-light;
border-style: solid;
border-width: 2px;
border-color: $yellow;
position: absolute;
}
span.wrongpart {
background-color: $yellow-light;
border-style: solid;
border-width: 2px;
border-color: $yellow;
padding: 5px;
border-radius: 10px;
filter: alpha(opacity=60);
opacity: 0.6;
margin: 5px;
display: inline-block;
}
div.dragitems img.target {
position: absolute;
left: -7px; /* This must be half the size of the target image, minus 0.5. */
top: -7px; /* In other words, this works for a 15x15 cross-hair. */
}
div.dragitems div.draghome img.target {
display: none;
}
}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, OnDestroy, AfterViewInit, Injector, ElementRef } from '@angular/core';
import { Component, OnInit, OnDestroy, Injector, ElementRef } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
import { AddonQtypeDdMarkerQuestion } from '../classes/ddmarker';
@ -24,14 +24,15 @@ import { AddonQtypeDdMarkerQuestion } from '../classes/ddmarker';
selector: 'addon-qtype-ddmarker',
templateUrl: 'ddmarker.html'
})
export class AddonQtypeDdMarkerComponent extends CoreQuestionBaseComponent implements OnInit, AfterViewInit, OnDestroy {
export class AddonQtypeDdMarkerComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy {
protected element: HTMLElement;
protected questionInstance: AddonQtypeDdMarkerQuestion;
protected dropZones: any[]; // The drop zones received in the init object of the question.
protected destroyed = false;
constructor(logger: CoreLoggerProvider, injector: Injector, element: ElementRef) {
super(logger, 'AddonQtypeDdMarkerComponent', injector);
constructor(protected loggerProvider: CoreLoggerProvider, injector: Injector, element: ElementRef) {
super(loggerProvider, 'AddonQtypeDdMarkerComponent', injector);
this.element = element.nativeElement;
}
@ -83,18 +84,21 @@ export class AddonQtypeDdMarkerComponent extends CoreQuestionBaseComponent imple
}
/**
* View has been initialized.
* The question has been rendered.
*/
ngAfterViewInit(): void {
// Create the instance.
this.questionInstance = new AddonQtypeDdMarkerQuestion(this.logger, this.domUtils, this.textUtils, this.element,
this.question, this.question.readOnly, this.dropZones);
questionRendered(): void {
if (!this.destroyed) {
// Create the instance.
this.questionInstance = new AddonQtypeDdMarkerQuestion(this.loggerProvider, this.domUtils, this.textUtils, this.element,
this.question, this.question.readOnly, this.dropZones);
}
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.destroyed = true;
this.questionInstance && this.questionInstance.destroy();
}
}

View File

@ -46,6 +46,7 @@ export class AddonQtypeDdwtosQuestion {
protected selectors: AddonQtypeDdwtosQuestionCSSSelectors; // Result of cssSelectors.
protected placed: {[no: number]: number}; // Map that relates drag elements numbers with drop zones numbers.
protected selected: HTMLElement; // Selected element (being "dragged").
protected resizeFunction;
/**
* Create the instance.
@ -125,7 +126,7 @@ export class AddonQtypeDdwtosQuestion {
* @return {AddonQtypeDdwtosQuestionCSSSelectors} Object with the functions to get the selectors.
*/
cssSelectors(slot: number): AddonQtypeDdwtosQuestionCSSSelectors {
const topNode = '#core-question-' + slot + ' .addon-qtype-ddwtos-container',
const topNode = '.addon-qtype-ddwtos-container',
selectors: AddonQtypeDdwtosQuestionCSSSelectors = {};
selectors.topNode = (): string => {
@ -193,7 +194,9 @@ export class AddonQtypeDdwtosQuestion {
* Function to call when the instance is no longer needed.
*/
destroy(): void {
window.removeEventListener('resize', this.resizeFunction);
if (this.resizeFunction) {
window.removeEventListener('resize', this.resizeFunction);
}
}
/**
@ -285,6 +288,7 @@ export class AddonQtypeDdwtosQuestion {
this.positionDragItems();
});
this.resizeFunction = this.positionDragItems.bind(this);
window.addEventListener('resize', this.resizeFunction);
}
@ -488,13 +492,6 @@ export class AddonQtypeDdwtosQuestion {
this.placeDragInDrop(null, drop);
}
/**
* Function to call when the window is resized.
*/
resizeFunction(): void {
this.positionDragItems();
}
/**
* Select a certain element as being "dragged".
*

View File

@ -1,11 +1,11 @@
<section ion-list *ngIf="question.text || question.text === ''" class="addon-qtype-ddwtos-container">
<ion-item text-wrap>
<p *ngIf="!question.readOnly" class="core-info-card-icon">
<ion-icon name="information" item-start></ion-icon>
<section ion-list *ngIf="question.text || question.text === ''">
<ion-item text-wrap class="addon-qtype-ddwtos-container">
<p *ngIf="!question.readOnly" class="core-info-card" icon-start>
<ion-icon name="information"></ion-icon>
{{ 'core.question.howtodraganddrop' | translate }}
</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
<core-format-text *ngIf="question.answers" [component]="component" [componentId]="componentId" [text]="question.answers"></core-format-text>
<core-format-text *ngIf="question.answers" [component]="component" [componentId]="componentId" [text]="question.answers" (afterRender)="questionRendered()"></core-format-text>
<div class="drags"></div>
</ion-item>
</section>

View File

@ -0,0 +1,108 @@
// Style ddwtos content a bit. Almost all these styles are copied from Moodle.
addon-qtype-ddwtos {
.qtext {
margin-bottom: 0.5em;
display: block;
}
.draghome {
margin-bottom: 1em;
}
.answertext {
margin-bottom: 0.5em;
}
.drop {
display: inline-block;
text-align: center;
border: 1px solid $gray-darker;
margin-bottom: 2px;
border-radius: 5px;
}
.draghome, .drag {
display: inline-block;
text-align: center;
background: transparent;
border: 0;
}
.draghome, .drag.unplaced{
border: 1px solid $gray-darker;
border-radius: 5px;
}
.draghome {
visibility: hidden;
}
.drag {
z-index: 2;
border-radius: 5px;
}
.drag.selected {
z-index: 3;
box-shadow: 3px 3px 4px $gray-darker;
}
.drop.selected {
border-color: $yellow-light;
box-shadow: 0 0 5px 5px $yellow-light;
}
&.notreadonly .drag,
&.notreadonly .draghome,
&.notreadonly .drop,
&.notreadonly .answercontainer {
cursor: pointer;
border-radius: 5px;
}
&.readonly .drag,
&.readonly .draghome,
&.readonly .drop,
&.readonly .answercontainer {
cursor: default;
}
span.incorrect {
background-color: $red-light;
}
span.correct {
background-color: $green-light;
}
.group1 {
background-color: $white;
}
.group2 {
background-color: #DCDCDC;
}
.group3 {
background-color: $blue-light;
}
.group4 {
background-color: #D8BFD8;
}
.group5 {
background-color: #87CEFA;
}
.group6 {
background-color: #DAA520;
}
.group7 {
background-color: #FFD700;
}
.group8 {
background-color: #F0E68C;
}
sub, sup {
font-size: 80%;
position: relative;
vertical-align: baseline;
}
sup {
top: -0.4em;
}
sub {
bottom: -0.2em;
}
}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, OnDestroy, AfterViewInit, Injector, ElementRef } from '@angular/core';
import { Component, OnInit, OnDestroy, Injector, ElementRef } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
import { AddonQtypeDdwtosQuestion } from '../classes/ddwtos';
@ -24,14 +24,15 @@ import { AddonQtypeDdwtosQuestion } from '../classes/ddwtos';
selector: 'addon-qtype-ddwtos',
templateUrl: 'ddwtos.html'
})
export class AddonQtypeDdwtosComponent extends CoreQuestionBaseComponent implements OnInit, AfterViewInit, OnDestroy {
export class AddonQtypeDdwtosComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy {
protected element: HTMLElement;
protected questionInstance: AddonQtypeDdwtosQuestion;
protected inputIds: string[]; // Ids of the inputs of the question (where the answers will be stored).
protected inputIds: string[] = []; // Ids of the inputs of the question (where the answers will be stored).
protected destroyed = false;
constructor(logger: CoreLoggerProvider, injector: Injector, element: ElementRef) {
super(logger, 'AddonQtypeDdwtosComponent', injector);
constructor(protected loggerProvider: CoreLoggerProvider, injector: Injector, element: ElementRef) {
super(loggerProvider, 'AddonQtypeDdwtosComponent', injector);
this.element = element.nativeElement;
}
@ -54,7 +55,7 @@ export class AddonQtypeDdwtosComponent extends CoreQuestionBaseComponent impleme
this.questionHelper.replaceFeedbackClasses(div);
// Treat the correct/incorrect icons.
this.questionHelper.treatCorrectnessIcons(div, this.component, this.componentId);
this.questionHelper.treatCorrectnessIcons(div);
const answerContainer = div.querySelector('.answercontainer');
if (!answerContainer) {
@ -74,28 +75,32 @@ export class AddonQtypeDdwtosComponent extends CoreQuestionBaseComponent impleme
}
// Get the inputs where the answers will be stored and add them to the question text.
const inputEls = <HTMLElement[]> Array.from(div.querySelectorAll('input[type="hidden"]:not([name*=sequencecheck])')),
inputIds = [];
const inputEls = <HTMLElement[]> Array.from(div.querySelectorAll('input[type="hidden"]:not([name*=sequencecheck])'));
inputEls.forEach((inputEl) => {
this.question.text += inputEl.outerHTML;
inputIds.push(inputEl.getAttribute('id'));
this.inputIds.push(inputEl.getAttribute('id'));
});
}
/**
* View has been initialized.
* The question has been rendered.
*/
ngAfterViewInit(): void {
// Create the instance.
this.questionInstance = new AddonQtypeDdwtosQuestion(this.logger, this.domUtils, this.element, this.question,
this.question.readOnly, this.inputIds);
questionRendered(): void {
if (!this.destroyed) {
// Create the instance.
this.questionInstance = new AddonQtypeDdwtosQuestion(this.loggerProvider, this.domUtils, this.element, this.question,
this.question.readOnly, this.inputIds);
this.questionHelper.treatCorrectnessIconsClicks(this.element, this.component, this.componentId);
}
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.destroyed = true;
this.questionInstance && this.questionInstance.destroy();
}
}

View File

@ -7,18 +7,18 @@
<!-- Textarea. -->
<ion-item *ngIf="question.textarea && !question.hasDraftFiles">
<!-- "Format" hidden input -->
<input *ngIf="question.formatInput" type="hidden" [name]="question.formatInput.name" [value]="question.formatInput.value" >
<input item-content *ngIf="question.formatInput" type="hidden" [name]="question.formatInput.name" [value]="question.formatInput.value" >
<!-- Plain text textarea. -->
<ion-textarea *ngIf="question.isPlainText" class="core-question-textarea" [ngClass]='{"core-monospaced": question.isMonospaced}' placeholder="{{ 'core.question.answer' | translate }}" [attr.name]="question.textarea.name" aria-multiline="true">{{question.textarea.text}}</ion-textarea>
<ion-textarea *ngIf="question.isPlainText" class="core-question-textarea" [ngClass]='{"core-monospaced": question.isMonospaced}' placeholder="{{ 'core.question.answer' | translate }}" [attr.name]="question.textarea.name" aria-multiline="true" [ngModel]="question.textarea.text"></ion-textarea>
<!-- Rich text editor. -->
<core-rich-text-editor *ngIf="!question.isPlainText" placeholder="{{ 'core.question.answer' | translate }}"></core-rich-text-editor>
<core-rich-text-editor item-content *ngIf="!question.isPlainText" placeholder="{{ 'core.question.answer' | translate }}" [control]="formControl" [name]="question.textarea.name"></core-rich-text-editor>
<!-- @todo: Attributes that were passed to RTE in Ionic 1 but now they aren't supported yet:
model="textarea" [name]="textarea.name" [component]="component" [componentId]="componentId" -->
[component]="component" [componentId]="componentId" -->
</ion-item>
<!-- Draft files not supported. -->
<ng-container *ngIf="question.textarea && question.hasDraftFiles">
<ion-item text-wrap class="core-error-item">
<ion-item text-wrap class="core-danger-item">
<p class="core-question-warning">{{ 'core.question.errorinlinefilesnotsupported' | translate }}</p>
</ion-item>
<ion-item text-wrap>
@ -27,7 +27,7 @@
</ng-container>
<!-- Attachments not supported in the app yet. -->
<ion-item text-wrap *ngIf="question.allowsAttachments" class="core-error-item">
<ion-item text-wrap *ngIf="question.allowsAttachments" class="core-danger-item">
<p class="core-question-warning">{{ 'core.question.errorattachmentsnotsupported' | translate }}</p>
</ion-item>

View File

@ -15,6 +15,7 @@
import { Component, OnInit, Injector } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
import { FormControl, FormBuilder } from '@angular/forms';
/**
* Component to render an essay question.
@ -25,7 +26,9 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-
})
export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implements OnInit {
constructor(logger: CoreLoggerProvider, injector: Injector) {
protected formControl: FormControl;
constructor(logger: CoreLoggerProvider, injector: Injector, protected fb: FormBuilder) {
super(logger, 'AddonQtypeEssayComponent', injector);
}
@ -34,5 +37,7 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen
*/
ngOnInit(): void {
this.initEssayComponent();
this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text);
}
}

View File

@ -1,5 +1,5 @@
<section ion-list class="addon-qtype-gapselect-container" *ngIf="question.text || question.text === ''">
<ion-item text-wrap>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text" (afterRender)="questionRendered()"></core-format-text></p>
</ion-item>
</section>

View File

@ -0,0 +1,19 @@
// Style gapselect content a bit. All these styles are copied from Moodle.
addon-qtype-gapselect {
p {
margin: 0 0 .5em;
}
select {
height: 30px;
line-height: 30px;
display: inline-block;
border: 1px solid $gray-dark;
padding: 4px 6px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
margin-bottom: 10px;
background: $gray-lighter;
}
}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, Injector } from '@angular/core';
import { Component, OnInit, Injector, ElementRef } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
@ -25,8 +25,12 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-
})
export class AddonQtypeGapSelectComponent extends CoreQuestionBaseComponent implements OnInit {
constructor(logger: CoreLoggerProvider, injector: Injector) {
protected element: HTMLElement;
constructor(logger: CoreLoggerProvider, injector: Injector, element: ElementRef) {
super(logger, 'AddonQtypeGapSelectComponent', injector);
this.element = element.nativeElement;
}
/**
@ -35,4 +39,11 @@ export class AddonQtypeGapSelectComponent extends CoreQuestionBaseComponent impl
ngOnInit(): void {
this.initOriginalTextComponent('.qtext');
}
/**
* The question has been rendered.
*/
questionRendered(): void {
this.questionHelper.treatCorrectnessIconsClicks(this.element, this.component, this.componentId);
}
}

View File

@ -3,17 +3,21 @@
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
</ion-item>
<ion-item text-wrap *ngFor="let row of question.rows">
<ion-row>
<ion-col>
<p><core-format-text id="addon-qtype-match-question-{{row.id}}" [component]="component" [componentId]="componentId" [text]="row.text"></core-format-text></p>
</ion-col>
<ion-col [ngClass]='{"core-question-answer-correct": row.isCorrect === 1, "core-question-answer-incorrect": row.isCorrect === 0}'>
<label class="accesshide" for="{{row.id}}" *ngIf="row.accessibilityLabel">{{ row.accessibilityLabel }}</label>
<ion-select id="{{row.id}}" [name]="row.name" [attr.aria-labelledby]="'addon-qtype-match-question-' + row.id" [ngModel]="row.selected">
<ion-option *ngFor="let option of row.options" [value]="option.value">{{option.label}}</ion-option>
</ion-select>
<!-- @todo: select fix? -->
</ion-col>
</ion-row>
<ion-grid item-content>
<ion-row>
<ion-col>
<p><core-format-text id="addon-qtype-match-question-{{row.id}}" [component]="component" [componentId]="componentId" [text]="row.text"></core-format-text></p>
</ion-col>
<ion-col [ngClass]='{"core-question-answer-correct": row.isCorrect === 1, "core-question-answer-incorrect": row.isCorrect === 0}'>
<label class="accesshide" for="{{row.id}}" *ngIf="row.accessibilityLabel">{{ row.accessibilityLabel }}</label>
<ion-select id="{{row.id}}" [name]="row.name" [attr.aria-labelledby]="'addon-qtype-match-question-' + row.id" [(ngModel)]="row.selected" interface="popover">
<ion-option *ngFor="let option of row.options" [value]="option.value">{{option.label}}</ion-option>
</ion-select>
<!-- ion-select doesn't use a select. Create a hidden input to hold the selected value. -->
<input type="hidden" [ngModel]="row.selected" [attr.name]="row.name">
</ion-col>
</ion-row>
</ion-grid>
</ion-item>
</section>

View File

@ -1,5 +1,5 @@
<section ion-list class="addon-qtype-multianswer-container" *ngIf="question.text || question.text === ''">
<ion-item text-wrap>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text" (afterRender)="questionRendered()"></core-format-text></p>
</ion-item>
</section>

View File

@ -0,0 +1,43 @@
// Style multianswer content a bit. All these styles are copied from Moodle.
addon-qtype-multianswer {
p {
margin: 0 0 .5em;
}
.answer div.r0, .answer div.r1, .answer td.r0, .answer td.r1 {
padding: 0.3em;
}
table {
width: 100%;
display: table;
}
tr {
display: table-row;
}
td {
display: table-cell;
}
input, select {
display: inline-block;
border: 1px solid #ccc;
padding: 4px 6px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
margin-bottom: 10px;
}
select {
height: 30px;
line-height: 30px;
}
input[type="radio"], input[type="checkbox"] {
margin-top: -4px;
margin-right: 7px;
}
}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, Injector } from '@angular/core';
import { Component, OnInit, Injector, ElementRef } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
@ -25,8 +25,12 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-
})
export class AddonQtypeMultiAnswerComponent extends CoreQuestionBaseComponent implements OnInit {
constructor(logger: CoreLoggerProvider, injector: Injector) {
protected element: HTMLElement;
constructor(logger: CoreLoggerProvider, injector: Injector, element: ElementRef) {
super(logger, 'AddonQtypeMultiAnswerComponent', injector);
this.element = element.nativeElement;
}
/**
@ -35,4 +39,11 @@ export class AddonQtypeMultiAnswerComponent extends CoreQuestionBaseComponent im
ngOnInit(): void {
this.initOriginalTextComponent('.formulation');
}
/**
* The question has been rendered.
*/
questionRendered(): void {
this.questionHelper.treatCorrectnessIconsClicks(this.element, this.component, this.componentId);
}
}

Some files were not shown because too many files have changed in this diff Show More