diff --git a/src/addon/qbehaviour/deferredcbm/component/deferredcbm.ts b/src/addon/qbehaviour/deferredcbm/component/deferredcbm.ts index a6c822770..ad204e3a0 100644 --- a/src/addon/qbehaviour/deferredcbm/component/deferredcbm.ts +++ b/src/addon/qbehaviour/deferredcbm/component/deferredcbm.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, EventEmitter } from '@angular/core'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; /** * Component to render the deferred CBM in a question. @@ -27,8 +27,8 @@ export class AddonQbehaviourDeferredCBMComponent { @Input() componentId: number; // ID of the component the question belongs to. @Input() attemptId: number; // Attempt ID. @Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline. - @Input() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked. - @Input() onAbort: EventEmitter; // Should emit an event if the question should be aborted. + @Output() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked. + @Output() onAbort: EventEmitter; // Should emit an event if the question should be aborted. constructor() { // Nothing to do. diff --git a/src/addon/qbehaviour/informationitem/component/informationitem.ts b/src/addon/qbehaviour/informationitem/component/informationitem.ts index 35e37f3f3..e29339cff 100644 --- a/src/addon/qbehaviour/informationitem/component/informationitem.ts +++ b/src/addon/qbehaviour/informationitem/component/informationitem.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, EventEmitter } from '@angular/core'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; /** * Component to render a "seen" hidden input for informationitem question behaviour. @@ -27,8 +27,8 @@ export class AddonQbehaviourInformationItemComponent { @Input() componentId: number; // ID of the component the question belongs to. @Input() attemptId: number; // Attempt ID. @Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline. - @Input() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked. - @Input() onAbort: EventEmitter; // Should emit an event if the question should be aborted. + @Output() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked. + @Output() onAbort: EventEmitter; // Should emit an event if the question should be aborted. constructor() { // Nothing to do. diff --git a/src/core/compile/components/compile-html/compile-html.ts b/src/core/compile/components/compile-html/compile-html.ts index fcbd23171..16ca3b26f 100644 --- a/src/core/compile/components/compile-html/compile-html.ts +++ b/src/core/compile/components/compile-html/compile-html.ts @@ -42,7 +42,7 @@ import { BehaviorSubject } from 'rxjs'; export class CoreCompileHtmlComponent implements OnChanges, OnDestroy { @Input() text: string; // The HTML text to display. @Input() javascript: string; // The Javascript to execute in the component. - @Input() jsData; // Data to pass to the fake component. + @Input() jsData: any; // Data to pass to the fake component. // Get the container where to put the content. @ViewChild('dynamicComponent', { read: ViewContainerRef }) container: ViewContainerRef; @@ -98,13 +98,13 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy { // If there is some javascript to run, prepare the instance. if (compileInstance.javascript) { compileInstance.compileProvider.injectLibraries(this); - - // Add some more components and classes. - this['ChangeDetectorRef'] = compileInstance.cdr; - this['NavController'] = compileInstance.navCtrl; - this['componentContainer'] = compileInstance.element; } + // Always add these elements, they could be needed on component init (componentObservable). + this['ChangeDetectorRef'] = compileInstance.cdr; + this['NavController'] = compileInstance.navCtrl; + this['componentContainer'] = compileInstance.element; + // Add the data passed to the component. for (const name in compileInstance.jsData) { this[name] = compileInstance.jsData[name]; diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index c86b7abe4..8b79eb23b 100644 --- a/src/core/question/classes/base-question-component.ts +++ b/src/core/question/classes/base-question-component.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Input, EventEmitter, Injector } from '@angular/core'; +import { Input, Output, EventEmitter, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; @@ -27,8 +27,8 @@ export class CoreQuestionBaseComponent { @Input() componentId: number; // ID of the component the question belongs to. @Input() attemptId: number; // Attempt ID. @Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline. - @Input() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked. - @Input() onAbort: EventEmitter; // Should emit an event if the question should be aborted. + @Output() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked. + @Output() onAbort: EventEmitter; // Should emit an event if the question should be aborted. protected logger; protected questionHelper: CoreQuestionHelperProvider; diff --git a/src/core/siteplugins/classes/question-handler.ts b/src/core/siteplugins/classes/question-handler.ts new file mode 100644 index 000000000..4950468ca --- /dev/null +++ b/src/core/siteplugins/classes/question-handler.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injector } from '@angular/core'; +import { CoreQuestionDefaultHandler } from '@core/question/providers/default-question-handler'; +import { CoreSitePluginsQuestionComponent } from '../components/question/question'; + +/** + * Handler to display a question site plugin. + */ +export class CoreSitePluginsQuestionHandler extends CoreQuestionDefaultHandler { + + constructor(public name: string, public type: string) { + super(); + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} question The question to render. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): any | Promise { + return CoreSitePluginsQuestionComponent; + } +} diff --git a/src/core/siteplugins/components/components.module.ts b/src/core/siteplugins/components/components.module.ts index 53a3d912c..4c809ca39 100644 --- a/src/core/siteplugins/components/components.module.ts +++ b/src/core/siteplugins/components/components.module.ts @@ -23,6 +23,7 @@ import { CoreSitePluginsModuleIndexComponent } from './module-index/module-index import { CoreSitePluginsCourseOptionComponent } from './course-option/course-option'; import { CoreSitePluginsCourseFormatComponent } from './course-format/course-format'; import { CoreSitePluginsUserProfileFieldComponent } from './user-profile-field/user-profile-field'; +import { CoreSitePluginsQuestionComponent } from './question/question'; @NgModule({ declarations: [ @@ -30,7 +31,8 @@ import { CoreSitePluginsUserProfileFieldComponent } from './user-profile-field/u CoreSitePluginsModuleIndexComponent, CoreSitePluginsCourseOptionComponent, CoreSitePluginsCourseFormatComponent, - CoreSitePluginsUserProfileFieldComponent + CoreSitePluginsUserProfileFieldComponent, + CoreSitePluginsQuestionComponent ], imports: [ CommonModule, @@ -46,13 +48,15 @@ import { CoreSitePluginsUserProfileFieldComponent } from './user-profile-field/u CoreSitePluginsModuleIndexComponent, CoreSitePluginsCourseOptionComponent, CoreSitePluginsCourseFormatComponent, - CoreSitePluginsUserProfileFieldComponent + CoreSitePluginsUserProfileFieldComponent, + CoreSitePluginsQuestionComponent ], entryComponents: [ CoreSitePluginsModuleIndexComponent, CoreSitePluginsCourseOptionComponent, CoreSitePluginsCourseFormatComponent, - CoreSitePluginsUserProfileFieldComponent + CoreSitePluginsUserProfileFieldComponent, + CoreSitePluginsQuestionComponent ] }) export class CoreSitePluginsComponentsModule {} diff --git a/src/core/siteplugins/components/question/question.html b/src/core/siteplugins/components/question/question.html new file mode 100644 index 000000000..fec5e4726 --- /dev/null +++ b/src/core/siteplugins/components/question/question.html @@ -0,0 +1 @@ + diff --git a/src/core/siteplugins/components/question/question.ts b/src/core/siteplugins/components/question/question.ts new file mode 100644 index 000000000..f7e46cc52 --- /dev/null +++ b/src/core/siteplugins/components/question/question.ts @@ -0,0 +1,90 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, Input, Output, EventEmitter, ViewChild, OnDestroy } from '@angular/core'; +import { CoreSitePluginsProvider } from '../../providers/siteplugins'; +import { CoreCompileHtmlComponent } from '@core/compile/components/compile-html/compile-html'; +import { Subscription } from 'rxjs'; + +/** + * Component that displays a question created using a site plugin. + */ +@Component({ + selector: 'core-site-plugins-question', + templateUrl: 'question.html', +}) +export class CoreSitePluginsQuestionComponent implements OnInit, OnDestroy { + @Input() question: any; // The question to render. + @Input() component: string; // The component the question belongs to. + @Input() componentId: number; // ID of the component the question belongs to. + @Input() attemptId: number; // Attempt ID. + @Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline. + @Output() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked. + @Output() onAbort: EventEmitter; // Should emit an event if the question should be aborted. + + @ViewChild(CoreCompileHtmlComponent) compileComponent: CoreCompileHtmlComponent; + + content = ''; // Content. + jsData; + protected componentObserver: Subscription; + + constructor(protected sitePluginsProvider: CoreSitePluginsProvider) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Pass the input and output data to the component. + this.jsData = { + question: this.question, + component: this.component, + componentId: this.componentId, + attemptId: this.attemptId, + offlineEnabled: this.offlineEnabled, + buttonClicked: this.buttonClicked, + onAbort: this.onAbort + }; + + if (this.question) { + // Retrieve the handler data. + const handler = this.sitePluginsProvider.getSitePluginHandler('qtype_' + this.question.type), + handlerSchema = handler && handler.handlerSchema; + + if (handlerSchema) { + // Load first template. + if (handlerSchema.methodTemplates && handlerSchema.methodTemplates.length) { + this.content = handler.handlerSchema.methodTemplates[0].html; + } + + // Wait for the instance to be created. + if (this.compileComponent && this.compileComponent.componentObservable && + handlerSchema.methodJSResult && handlerSchema.methodJSResult.componentInit) { + this.componentObserver = this.compileComponent.componentObservable.subscribe((instance) => { + if (instance) { + // Instance created, call component init. + handlerSchema.methodJSResult.componentInit.apply(instance); + } + }); + } + } + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.componentObserver && this.componentObserver.unsubscribe(); + } +} diff --git a/src/core/siteplugins/providers/helper.ts b/src/core/siteplugins/providers/helper.ts index cdd034b58..cdd570dd9 100644 --- a/src/core/siteplugins/providers/helper.ts +++ b/src/core/siteplugins/providers/helper.ts @@ -35,6 +35,7 @@ import { CoreCourseFormatDelegate } from '@core/course/providers/format-delegate import { CoreUserDelegate } from '@core/user/providers/user-delegate'; import { CoreUserProfileFieldDelegate } from '@core/user/providers/user-profile-field-delegate'; import { CoreSettingsDelegate } from '@core/settings/providers/delegate'; +import { CoreQuestionDelegate } from '@core/question/providers/delegate'; import { AddonMessageOutputDelegate } from '@addon/messageoutput/providers/delegate'; // Handler classes. @@ -46,6 +47,7 @@ import { CoreSitePluginsMainMenuHandler } from '../classes/main-menu-handler'; import { CoreSitePluginsUserProfileHandler } from '../classes/user-handler'; import { CoreSitePluginsUserProfileFieldHandler } from '../classes/user-profile-field-handler'; import { CoreSitePluginsSettingsHandler } from '../classes/settings-handler'; +import { CoreSitePluginsQuestionHandler } from '../classes/question-handler'; import { CoreSitePluginsMessageOutputHandler } from '../classes/message-output-handler'; /** @@ -69,7 +71,9 @@ export class CoreSitePluginsHelperProvider { private courseOptionsDelegate: CoreCourseOptionsDelegate, eventsProvider: CoreEventsProvider, private courseFormatDelegate: CoreCourseFormatDelegate, private profileFieldDelegate: CoreUserProfileFieldDelegate, private textUtils: CoreTextUtilsProvider, private filepoolProvider: CoreFilepoolProvider, - private settingsDelegate: CoreSettingsDelegate, private messageOutputDelegate: AddonMessageOutputDelegate) { + private settingsDelegate: CoreSettingsDelegate, private questionDelegate: CoreQuestionDelegate, + private messageOutputDelegate: AddonMessageOutputDelegate) { + this.logger = logger.getInstance('CoreSitePluginsHelperProvider'); // Fetch the plugins on login. @@ -442,6 +446,10 @@ export class CoreSitePluginsHelperProvider { promise = Promise.resolve(this.registerSettingsHandler(plugin, handlerName, handlerSchema, result)); break; + case 'CoreQuestionDelegate': + promise = Promise.resolve(this.registerQuestionHandler(plugin, handlerName, handlerSchema, result)); + break; + case 'AddonMessageOutputDelegate': promise = Promise.resolve(this.registerMessageOutputHandler(plugin, handlerName, handlerSchema, result)); break; @@ -609,6 +617,55 @@ export class CoreSitePluginsHelperProvider { return modName; } + /** + * Given a handler in an plugin, register it in the question delegate. + * + * @param {any} plugin Data of the plugin. + * @param {string} handlerName Name of the handler in the plugin. + * @param {any} handlerSchema Data about the handler. + * @param {any} initResult Result of the init WS call. + * @return {string|Promise} A string (or a promise resolved with a string) to identify the handler. + */ + protected registerQuestionHandler(plugin: any, handlerName: string, handlerSchema: any, initResult: any) + : string | Promise { + if (!handlerSchema.method) { + // Required data not provided, stop. + this.logger.warn('Ignore site plugin because it doesn\'t provide method', plugin, handlerSchema); + + return; + } + + this.logger.debug('Register site plugin in question delegate:', plugin, handlerSchema, initResult); + + // Execute the main method and its JS. The template returned will be used in the question component. + return this.executeMethodAndJS(plugin, handlerSchema.method).then((result) => { + // Create and register the handler. + const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), + questionType = plugin.component, + questionHandler = new CoreSitePluginsQuestionHandler(uniqueName, questionType); + + // Store in handlerSchema some data required by the component. + handlerSchema.methodTemplates = result.templates; + handlerSchema.methodJSResult = result.jsResult; + + if (result && result.jsResult) { + // Override default handler functions with the result of the method JS. + for (const property in questionHandler) { + if (property != 'constructor' && typeof questionHandler[property] == 'function' && + typeof result.jsResult[property] == 'function') { + questionHandler[property] = result.jsResult[property].bind(questionHandler); + } + } + } + + this.questionDelegate.registerHandler(questionHandler); + + return questionType; + }).catch((err) => { + this.logger.error('Error executing main method for question', handlerSchema.method, err); + }); + } + /** * Given a handler in an plugin, register it in the settings delegate. * @@ -712,7 +769,7 @@ export class CoreSitePluginsHelperProvider { return fieldType; }).catch((err) => { - this.logger.error('Error executing main method', handlerSchema.method, err); + this.logger.error('Error executing main method for user profile field', handlerSchema.method, err); }); } }