diff --git a/src/addons/calendar/pages/edit-event/edit-event.page.ts b/src/addons/calendar/pages/edit-event/edit-event.page.ts index 41aad7974..bcb25057c 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.page.ts +++ b/src/addons/calendar/pages/edit-event/edit-event.page.ts @@ -43,6 +43,7 @@ import { AddonCalendarOfflineEventDBRecord } from '../../services/database/calen import { CoreError } from '@classes/errors/error'; import { CoreNavigator } from '@services/navigator'; import { CanLeave } from '@guards/can-leave'; +import { CoreForms } from '@singletons/form'; /** * Page that displays a form to create/edit an event. @@ -518,7 +519,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { const result = await AddonCalendar.submitEvent(this.eventId, data); event = result.event; - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, result.sent, this.currentSite.getId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, result.sent, this.currentSite.getId()); if (result.sent) { // Event created or edited, invalidate right days & months. @@ -588,7 +589,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { try { await AddonCalendarOffline.deleteEvent(this.eventId!); - CoreDomUtils.triggerFormCancelledEvent(this.formElement, this.currentSite.getId()); + CoreForms.triggerFormCancelledEvent(this.formElement, this.currentSite.getId()); this.returnToList(); } catch { @@ -611,7 +612,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy, CanLeave { await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); } - CoreDomUtils.triggerFormCancelledEvent(this.formElement, this.currentSite.getId()); + CoreForms.triggerFormCancelledEvent(this.formElement, this.currentSite.getId()); return true; } diff --git a/src/addons/calendar/services/database/calendar.ts b/src/addons/calendar/services/database/calendar.ts index 711239fbc..c6d3acd3d 100644 --- a/src/addons/calendar/services/database/calendar.ts +++ b/src/addons/calendar/services/database/calendar.ts @@ -201,7 +201,6 @@ export const CALENDAR_SITE_SCHEMA: CoreSiteSchema = { ], async migrate(db: SQLiteDB, oldVersion: number): Promise { if (oldVersion < 3) { - const newTable = EVENTS_TABLE; let oldTable = 'addon_calendar_events_2'; try { @@ -211,19 +210,7 @@ export const CALENDAR_SITE_SCHEMA: CoreSiteSchema = { oldTable = 'addon_calendar_events'; } - try { - await db.tableExists(oldTable); - - // Move the records from the old table. - const events = await db.getAllRecords(oldTable); - const promises = events.map((event) => db.insertRecord(newTable, event)); - - await Promise.all(promises); - - db.dropTable(oldTable); - } catch { - // Old table does not exist, ignore. - } + await db.migrateTable(oldTable, EVENTS_TABLE); } }, }; diff --git a/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.ts b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.ts index ffcee4261..6e48aab65 100644 --- a/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.ts +++ b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.ts @@ -15,6 +15,7 @@ import { Component, Input, ViewChild, ElementRef } from '@angular/core'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreFormFields, CoreForms } from '@singletons/form'; import { CoreUtils } from '@services/utils/utils'; import { ModalController, Translate } from '@singletons'; import { AddonModAssignAssign, AddonModAssignPlugin, AddonModAssignSubmission } from '../../services/assign'; @@ -47,7 +48,7 @@ export class AddonModAssignEditFeedbackModalComponent { await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); } - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); ModalController.dismiss(); } @@ -61,7 +62,7 @@ export class AddonModAssignEditFeedbackModalComponent { e.preventDefault(); e.stopPropagation(); - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); // Close the modal, sending the input data. ModalController.dismiss(this.getInputData()); @@ -72,8 +73,8 @@ export class AddonModAssignEditFeedbackModalComponent { * * @return Object with the data. */ - protected getInputData(): Record { - return CoreDomUtils.getDataFromForm(document.forms['addon-mod_assign-edit-feedback-form']); + protected getInputData(): CoreFormFields { + return CoreForms.getDataFromForm(document.forms['addon-mod_assign-edit-feedback-form']); } /** diff --git a/src/addons/mod/assign/pages/edit/edit.ts b/src/addons/mod/assign/pages/edit/edit.ts index a85013e36..1a8aa7507 100644 --- a/src/addons/mod/assign/pages/edit/edit.ts +++ b/src/addons/mod/assign/pages/edit/edit.ts @@ -21,6 +21,7 @@ import { CoreNavigator } from '@services/navigator'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreSync } from '@services/sync'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreFormFields, CoreForms } from '@singletons/form'; import { Translate } from '@singletons'; import { CoreEvents } from '@singletons/events'; import { @@ -105,7 +106,7 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { // Nothing has changed or user confirmed to leave. Clear temporary data from plugins. AddonModAssignHelper.clearSubmissionPluginTmpData(this.assign!, this.userSubmission, this.getInputData()); - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); return true; } @@ -199,8 +200,8 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { * * @return Input data. */ - protected getInputData(): Record { - return CoreDomUtils.getDataFromForm(document.forms['addon-mod_assign-edit-form']); + protected getInputData(): CoreFormFields { + return CoreForms.getDataFromForm(document.forms['addon-mod_assign-edit-form']); } /** @@ -234,7 +235,7 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { * @param inputData The input data. * @return Promise resolved with the data to submit. */ - protected prepareSubmissionData(inputData: Record): Promise { + protected prepareSubmissionData(inputData: CoreFormFields): Promise { // If there's offline data, always save it in offline. this.saveOffline = this.hasOffline; @@ -353,7 +354,7 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy, CanLeave { } // Submission saved, trigger events. - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.getCurrentSiteId()); CoreEvents.trigger( AddonModAssignProvider.SUBMISSION_SAVED_EVENT, diff --git a/src/addons/mod/assign/services/assign-helper.ts b/src/addons/mod/assign/services/assign-helper.ts index c71681b36..b4bbb4eb7 100644 --- a/src/addons/mod/assign/services/assign-helper.ts +++ b/src/addons/mod/assign/services/assign-helper.ts @@ -35,6 +35,7 @@ import { CoreGroups } from '@services/groups'; import { AddonModAssignSubmissionDelegate } from './submission-delegate'; import { AddonModAssignFeedbackDelegate } from './feedback-delegate'; import { makeSingleton } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; /** * Service that provides some helper functions for assign. @@ -88,7 +89,7 @@ export class AddonModAssignHelperProvider { clearSubmissionPluginTmpData( assign: AddonModAssignAssign, submission: AddonModAssignSubmission | undefined, - inputData: Record, + inputData: CoreFormFields, ): void { if (!submission) { return; @@ -362,7 +363,7 @@ export class AddonModAssignHelperProvider { async getSubmissionSizeForEdit( assign: AddonModAssignAssign, submission: AddonModAssignSubmission, - inputData: Record, + inputData: CoreFormFields, ): Promise { let totalSize = 0; @@ -537,7 +538,7 @@ export class AddonModAssignHelperProvider { async hasSubmissionDataChanged( assign: AddonModAssignAssign, submission: AddonModAssignSubmission | undefined, - inputData: Record, + inputData: CoreFormFields, ): Promise { if (!submission) { return false; @@ -580,7 +581,7 @@ export class AddonModAssignHelperProvider { siteId?: string, ): Promise { - const pluginData: Record = {}; + const pluginData: CoreFormFields = {}; const promises = feedback.plugins ? feedback.plugins.map((plugin) => AddonModAssignFeedbackDelegate.preparePluginFeedbackData(assignId, userId, plugin, pluginData, siteId)) @@ -603,7 +604,7 @@ export class AddonModAssignHelperProvider { async prepareSubmissionPluginData( assign: AddonModAssignAssign, submission: AddonModAssignSubmission | undefined, - inputData: Record, + inputData: CoreFormFields, offline = false, ): Promise { diff --git a/src/addons/mod/assign/services/assign-sync.ts b/src/addons/mod/assign/services/assign-sync.ts index 14e91e5a7..2ea3fd466 100644 --- a/src/addons/mod/assign/services/assign-sync.ts +++ b/src/addons/mod/assign/services/assign-sync.ts @@ -186,7 +186,7 @@ export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvid * Perform the assign submission. * * @param assignId Assign ID. - * @param siteId Site ID. If not defined, current site. + * @param siteId Site ID. * @return Promise resolved in success. */ protected async performSyncAssign(assignId: number, siteId: string): Promise { diff --git a/src/addons/mod/assign/services/assign.ts b/src/addons/mod/assign/services/assign.ts index f575c07ee..aa53f5f4f 100644 --- a/src/addons/mod/assign/services/assign.ts +++ b/src/addons/mod/assign/services/assign.ts @@ -33,6 +33,7 @@ import { CoreComments } from '@features/comments/services/comments'; import { AddonModAssignSubmissionFormatted } from './assign-helper'; import { CoreWSError } from '@classes/errors/wserror'; import { AddonModAssignAutoSyncData, AddonModAssignManualSyncData, AddonModAssignSyncProvider } from './assign-sync'; +import { CoreFormFields } from '@singletons/form'; const ROOT_CACHE_KEY = 'mmaModAssign:'; @@ -467,7 +468,7 @@ export class AddonModAssignProvider { ): Promise<{ canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] }> { const site = await CoreSites.getSite(options.siteId); - const params: ModAssignGetSubmissionsWSParams = { + const params: AddonModAssignGetSubmissionsWSParams = { assignmentids: [assignId], }; const preSets: CoreSiteWSPreSets = { @@ -1681,7 +1682,7 @@ export type AddonModAssignGetAssignmentsWSResponse = { /** * Params of mod_assign_get_submissions WS. */ -type ModAssignGetSubmissionsWSParams = { +type AddonModAssignGetSubmissionsWSParams = { assignmentids: number[]; // 1 or more assignment ids. status?: string; // Status. since?: number; // Submitted since. @@ -1808,7 +1809,7 @@ type AddonModAssignSaveSubmissionWSParams = { /** * All subplugins will decide what to add here. */ -export type AddonModAssignSavePluginData = Record; +export type AddonModAssignSavePluginData = CoreFormFields; /** * Params of mod_assign_submit_for_grading WS. diff --git a/src/addons/mod/assign/services/feedback-delegate.ts b/src/addons/mod/assign/services/feedback-delegate.ts index 604b7298a..8c9951877 100644 --- a/src/addons/mod/assign/services/feedback-delegate.ts +++ b/src/addons/mod/assign/services/feedback-delegate.ts @@ -19,6 +19,7 @@ import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin, A import { makeSingleton } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; import { AddonModAssignSubmissionFormatted } from './assign-helper'; +import { CoreFormFields } from '@singletons/form'; /** * Interface that all feedback handlers must implement. @@ -61,7 +62,7 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { assignId: number, userId: number, siteId?: string, - ): Record | Promise | undefined> | undefined; + ): CoreFormFields | Promise | undefined; /** * Get files used by this plugin. @@ -102,7 +103,7 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: Record, + inputData: CoreFormFields, userId: number, ): boolean | Promise; @@ -165,7 +166,7 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { assignId: number, userId: number, plugin: AddonModAssignPlugin, - data: Record, + data: CoreFormFields, siteId?: string, ): void | Promise; } @@ -276,7 +277,7 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate, + inputData: CoreFormFields, userId: number, ): Promise { return await this.executeFunctionOnEnabled( @@ -371,7 +372,7 @@ export class AddonModAssignFeedbackDelegateService extends CoreDelegate, + inputData: CoreFormFields, siteId?: string, ): Promise { return await this.executeFunctionOnEnabled( diff --git a/src/addons/mod/assign/services/submission-delegate.ts b/src/addons/mod/assign/services/submission-delegate.ts index b7a62a797..a58da6eac 100644 --- a/src/addons/mod/assign/services/submission-delegate.ts +++ b/src/addons/mod/assign/services/submission-delegate.ts @@ -19,6 +19,7 @@ import { AddonModAssignAssign, AddonModAssignSubmission, AddonModAssignPlugin, A import { makeSingleton } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; import { AddonModAssignSubmissionsDBRecordFormatted } from './assign-offline'; +import { CoreFormFields } from '@singletons/form'; /** * Interface that all submission handlers must implement. @@ -70,7 +71,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: Record, + inputData: CoreFormFields, ): void; /** @@ -173,7 +174,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: Record, + inputData: CoreFormFields, ): number | Promise; /** @@ -189,7 +190,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: Record, + inputData: CoreFormFields, ): boolean | Promise; /** @@ -233,7 +234,7 @@ export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { assign: AddonModAssignAssign, submission: AddonModAssignSubmission, plugin: AddonModAssignPlugin, - inputData: Record, + inputData: CoreFormFields, pluginData: AddonModAssignSavePluginData, offline?: boolean, userId?: number, @@ -304,7 +305,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate, + inputData: CoreFormFields, ): void { return this.executeFunctionOnEnabled(plugin.type, 'clearTmpData', [assign, submission, plugin, inputData]); } @@ -424,7 +425,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate, + inputData: CoreFormFields, ): Promise { return await this.executeFunctionOnEnabled( plugin.type, @@ -446,7 +447,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate, + inputData: CoreFormFields, ): Promise { return await this.executeFunctionOnEnabled( plugin.type, @@ -521,7 +522,7 @@ export class AddonModAssignSubmissionDelegateService extends CoreDelegate, + inputData: CoreFormFields, pluginData: AddonModAssignSavePluginData, offline?: boolean, userId?: number, diff --git a/src/addons/mod/data/classes/field-plugin-component.ts b/src/addons/mod/data/classes/field-plugin-component.ts new file mode 100644 index 000000000..9c0a79948 --- /dev/null +++ b/src/addons/mod/data/classes/field-plugin-component.ts @@ -0,0 +1,116 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Input, Output, OnInit, OnChanges, SimpleChange, EventEmitter, Component } from '@angular/core'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { CoreFormFields } from '@singletons/form'; +import { AddonModDataData, AddonModDataEntryField, AddonModDataField, AddonModDataTemplateMode } from '../services/data'; + +/** + * Base class for component to render a field. + */ +@Component({ + template: '', +}) +export abstract class AddonModDataFieldPluginComponent implements OnInit, OnChanges { + + @Input() mode!: AddonModDataTemplateMode; // The render mode. + @Input() field!: AddonModDataField; // The field to render. + @Input() value?: Partial; // The value of the field. + @Input() database?: AddonModDataData; // Database object. + @Input() error?: string; // Error when editing. + @Input() form?: FormGroup; // Form where to add the form control. Just required for edit and search modes. + @Input() searchFields?: CoreFormFields; // The search value of all fields. + @Output() gotoEntry: EventEmitter; // Action to perform. + + constructor(protected fb: FormBuilder) { + this.gotoEntry = new EventEmitter(); + } + + /** + * Add the form control for the search mode. + * + * @param fieldName Control field name. + * @param value Initial set value. + */ + protected addControl(fieldName: string, value?: unknown): void { + if (!this.form) { + return; + } + + if (this.searchMode) { + this.form.addControl(fieldName, this.fb.control(this.searchFields?.[fieldName] || undefined)); + } + + if (this.editMode) { + this.form.addControl(fieldName, this.fb.control(value, this.field.required ? Validators.required : null)); + } + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.init(); + } + + /** + * Initialize field. + */ + protected init(): void { + return; + } + + /** + * Component being changed. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if ((this.showMode || this.listMode) && changes.value) { + this.updateValue(changes.value.currentValue); + } + } + + /** + * Update value being shown. + */ + protected updateValue(value?: Partial): void { + this.value = value; + } + + /** Magic mode getters */ + get listMode(): boolean { + return this.mode == AddonModDataTemplateMode.LIST; + } + + get showMode(): boolean { + return this.mode == AddonModDataTemplateMode.SHOW; + } + + get displayMode(): boolean { + return this.listMode || this.showMode; + } + + get editMode(): boolean { + return this.mode == AddonModDataTemplateMode.EDIT; + } + + get searchMode(): boolean { + return this.mode == AddonModDataTemplateMode.SEARCH; + } + + get inputMode(): boolean { + return this.searchMode || this.editMode; + } + +} diff --git a/src/addons/mod/data/components/action/action.ts b/src/addons/mod/data/components/action/action.ts new file mode 100644 index 000000000..b10d9fe6a --- /dev/null +++ b/src/addons/mod/data/components/action/action.ts @@ -0,0 +1,140 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, Input } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreTag } from '@features/tag/services/tag'; +import { CoreUser } from '@features/user/services/user'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreEvents } from '@singletons/events'; +import { + AddonModDataAction, + AddonModDataData, + AddonModDataEntry, + AddonModDataProvider, + AddonModDataTemplateMode, +} from '../../services/data'; +import { AddonModDataHelper } from '../../services/data-helper'; +import { AddonModDataOffline } from '../../services/data-offline'; +import { AddonModDataModuleHandlerService } from '../../services/handlers/module'; + +/** + * Component that displays a database action. + */ +@Component({ + selector: 'addon-mod-data-action', + templateUrl: 'addon-mod-data-action.html', +}) +export class AddonModDataActionComponent implements OnInit { + + @Input() mode!: AddonModDataTemplateMode; // The render mode. + @Input() action!: AddonModDataAction; // The field to render. + @Input() entry!: AddonModDataEntry; // The value of the field. + @Input() database!: AddonModDataData; // Database object. + @Input() module!: CoreCourseModule; // Module object. + @Input() group = 0; // Module object. + @Input() offset?: number; // Offset of the entry. + + siteId: string; + userPicture?: string; + tagsEnabled = false; + + constructor() { + this.siteId = CoreSites.getCurrentSiteId(); + this.tagsEnabled = CoreTag.areTagsAvailableInSite(); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (this.action == AddonModDataAction.USERPICTURE) { + const profile = await CoreUser.getProfile(this.entry.userid, this.database.course); + this.userPicture = profile.profileimageurl; + } + } + + /** + * Approve the entry. + */ + approveEntry(): void { + AddonModDataHelper.approveOrDisapproveEntry(this.database.id, this.entry.id, true, this.database.course); + } + + /** + * Show confirmation modal for deleting the entry. + */ + deleteEntry(): void { + AddonModDataHelper.showDeleteEntryModal(this.database.id, this.entry.id, this.database.course); + } + + /** + * Disapprove the entry. + */ + disapproveEntry(): void { + AddonModDataHelper.approveOrDisapproveEntry(this.database.id, this.entry.id, false, this.database.course); + } + + /** + * Go to the edit page of the entry. + */ + editEntry(): void { + const params = { + courseId: this.database.course, + module: this.module, + }; + + CoreNavigator.navigateToSitePath( + `${AddonModDataModuleHandlerService.PAGE_NAME}/${this.module.course}/${this.module.id}/edit/${this.entry.id}`, + { params }, + ); + } + + /** + * Go to the view page of the entry. + */ + viewEntry(): void { + const params: Params = { + courseId: this.database.course, + module: this.module, + entryId: this.entry.id, + group: this.group, + offset: this.offset, + }; + + CoreNavigator.navigateToSitePath( + `${AddonModDataModuleHandlerService.PAGE_NAME}/${this.module.course}/${this.module.id}/${this.entry.id}`, + { params }, + ); + } + + /** + * Undo delete action. + * + * @return Solved when done. + */ + async undoDelete(): Promise { + const dataId = this.database.id; + const entryId = this.entry.id; + + await AddonModDataOffline.getEntry(dataId, entryId, AddonModDataAction.DELETE, this.siteId); + + // Found. Just delete the action. + await AddonModDataOffline.deleteEntry(dataId, entryId, AddonModDataAction.DELETE, this.siteId); + CoreEvents.trigger(AddonModDataProvider.ENTRY_CHANGED, { dataId: dataId, entryId: entryId }, this.siteId); + } + +} diff --git a/src/addons/mod/data/components/action/addon-mod-data-action.html b/src/addons/mod/data/components/action/addon-mod-data-action.html new file mode 100644 index 000000000..d87b725a0 --- /dev/null +++ b/src/addons/mod/data/components/action/addon-mod-data-action.html @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +{{ entry.timecreated * 1000 | coreFormatDate }} +{{ entry.timemodified * 1000 | coreFormatDate }} + + + + + + + {{entry.fullname}} + + + diff --git a/src/addons/mod/data/components/components-compile.module.ts b/src/addons/mod/data/components/components-compile.module.ts new file mode 100644 index 000000000..72277202c --- /dev/null +++ b/src/addons/mod/data/components/components-compile.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModDataFieldPluginComponent } from './field-plugin/field-plugin'; +import { AddonModDataActionComponent } from './action/action'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCommentsComponentsModule } from '@features/comments/components/components.module'; +import { CoreTagComponentsModule } from '@features/tag/components/components.module'; + +// This module is intended to be passed to the compiler in order to avoid circular depencencies. +@NgModule({ + declarations: [ + AddonModDataFieldPluginComponent, + AddonModDataActionComponent, + ], + imports: [ + CoreSharedModule, + CoreCommentsComponentsModule, + CoreTagComponentsModule, + ], + exports: [ + AddonModDataActionComponent, + AddonModDataFieldPluginComponent, + ], +}) +export class AddonModDataComponentsCompileModule {} diff --git a/src/addons/mod/data/components/components.module.ts b/src/addons/mod/data/components/components.module.ts new file mode 100644 index 000000000..36d2812b9 --- /dev/null +++ b/src/addons/mod/data/components/components.module.ts @@ -0,0 +1,37 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { AddonModDataIndexComponent } from './index'; +import { AddonModDataSearchComponent } from './search/search'; +import { CoreCompileHtmlComponentModule } from '@features/compile/components/compile-html/compile-html.module'; + +@NgModule({ + declarations: [ + AddonModDataIndexComponent, + AddonModDataSearchComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + CoreCompileHtmlComponentModule, + ], + exports: [ + AddonModDataIndexComponent, + AddonModDataSearchComponent, + ], +}) +export class AddonModDataComponentsModule {} diff --git a/src/addons/mod/data/components/field-plugin/addon-mod-data-field-plugin.html b/src/addons/mod/data/components/field-plugin/addon-mod-data-field-plugin.html new file mode 100644 index 000000000..44e4d82a5 --- /dev/null +++ b/src/addons/mod/data/components/field-plugin/addon-mod-data-field-plugin.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/addons/mod/data/components/field-plugin/field-plugin.ts b/src/addons/mod/data/components/field-plugin/field-plugin.ts new file mode 100644 index 000000000..28d20115f --- /dev/null +++ b/src/addons/mod/data/components/field-plugin/field-plugin.ts @@ -0,0 +1,103 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnChanges, ViewChild, Input, Output, SimpleChange, Type, EventEmitter } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; +import { CoreFormFields } from '@singletons/form'; +import { AddonModDataData, AddonModDataField, AddonModDataTemplateMode } from '../../services/data'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; + +/** + * Component that displays a database field plugin. + */ +@Component({ + selector: 'addon-mod-data-field-plugin', + templateUrl: 'addon-mod-data-field-plugin.html', +}) +export class AddonModDataFieldPluginComponent implements OnInit, OnChanges { + + @ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent; + + @Input() mode!: AddonModDataTemplateMode; // The render mode. + @Input() field!: AddonModDataField; // The field to render. + @Input() value?: unknown; // The value of the field. + @Input() database?: AddonModDataData; // Database object. + @Input() error?: string; // Error when editing. + @Input() form?: FormGroup; // Form where to add the form control. Just required for edit and search modes. + @Input() searchFields?: CoreFormFields; // The search value of all fields. + @Output() gotoEntry = new EventEmitter(); // Action to perform. + + fieldComponent?: Type; // Component to render the plugin. + pluginData?: AddonDataFieldPluginComponentData; // Data to pass to the component. + fieldLoaded = false; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!this.field) { + this.fieldLoaded = true; + + return; + } + + try{ + // Check if the plugin has defined its own component to render itself. + this.fieldComponent = await AddonModDataFieldsDelegate.getComponentForField(this.field); + + if (this.fieldComponent) { + // Prepare the data to pass to the component. + this.pluginData = { + mode: this.mode, + field: this.field, + value: this.value, + database: this.database, + error: this.error, + gotoEntry: this.gotoEntry, + form: this.form, + searchFields: this.searchFields, + }; + } + } finally { + this.fieldLoaded = true; + } + } + + /** + * Component being changed. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (this.fieldLoaded && this.pluginData) { + if (this.mode == AddonModDataTemplateMode.EDIT && changes.error) { + this.pluginData.error = changes.error.currentValue; + } + if ((this.mode == AddonModDataTemplateMode.SHOW || this.mode == AddonModDataTemplateMode.LIST) && changes.value) { + this.pluginData.value = changes.value.currentValue; + } + } + } + +} + +export type AddonDataFieldPluginComponentData = { + mode: AddonModDataTemplateMode; // The render mode. + field: AddonModDataField; // The field to render. + value?: unknown; // The value of the field. + database?: AddonModDataData; // Database object. + error?: string; // Error when editing. + form?: FormGroup; // Form where to add the form control. Just required for edit and search modes. + searchFields?: CoreFormFields; // The search value of all fields. + gotoEntry: EventEmitter; +}; diff --git a/src/addons/mod/data/components/index/addon-mod-data-index.html b/src/addons/mod/data/components/index/addon-mod-data-index.html new file mode 100644 index 000000000..26c2e3ec2 --- /dev/null +++ b/src/addons/mod/data/components/index/addon-mod-data-index.html @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + + {{'core.groupsseparate' | translate }} + {{'core.groupsvisible' | translate }} + + + + {{groupOpt.name}} + + + + + + + + {{ 'addon.mod_data.notopenyet' | translate:{$a: timeAvailableFromReadable} }} + + + + + + + {{ 'addon.mod_data.expired' | translate:{$a: timeAvailableToReadable} }} + + + + > + + + + {{ 'addon.mod_data.entrieslefttoaddtoview' | translate:{$a: {entrieslefttoview: access.entrieslefttoview} } }} + + + + + > + + + + {{ 'addon.mod_data.entrieslefttoadd' | translate:{$a: {entriesleft: access.entrieslefttoadd} } }} + + + + + + + + + {{ 'addon.mod_data.resetsettings' | translate}} + + + + + +

+
+
+
+ +
+ + + +
+ + + + + + + {{ 'core.previous' | translate }} + + + + + {{ 'core.next' | translate }} + + + + + + + + + + + {{ 'addon.mod_data.resetsettings' | translate}} + + +
+ + + + + + +
diff --git a/src/addons/mod/data/components/index/index.ts b/src/addons/mod/data/components/index/index.ts new file mode 100644 index 000000000..69640238b --- /dev/null +++ b/src/addons/mod/data/components/index/index.ts @@ -0,0 +1,556 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextLevel } from '@/core/constants'; +import { Component, OnDestroy, OnInit, Optional, Type } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreCommentsProvider } from '@features/comments/services/comments'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseModule } from '@features/course/course.module'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreRatingProvider } from '@features/rating/services/rating'; +import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync'; +import { IonContent } from '@ionic/angular'; +import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { + AddonModDataProvider, + AddonModData, + AddonModDataEntry, + AddonModDataTemplateType, + AddonModDataTemplateMode, + AddonModDataField, + AddonModDataGetDataAccessInformationWSResponse, + AddonModDataData, + AddonModDataSearchEntriesAdvancedField, +} from '../../services/data'; +import { AddonModDataHelper } from '../../services/data-helper'; +import { AddonModDataAutoSyncData, AddonModDataSyncProvider, AddonModDataSyncResult } from '../../services/data-sync'; +import { AddonModDataPrefetchHandler } from '../../services/handlers/prefetch'; +import { AddonModDataComponentsCompileModule } from '../components-compile.module'; +import { AddonModDataSearchComponent } from '../search/search'; + +/** + * Component that displays a data index page. + */ +@Component({ + selector: 'addon-mod-data-index', + templateUrl: 'addon-mod-data-index.html', + styleUrls: ['../../data.scss'], +}) +export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + component = AddonModDataProvider.COMPONENT; + moduleName = 'data'; + + access?: AddonModDataGetDataAccessInformationWSResponse; + database?: AddonModDataData; + fields: Record = {}; + selectedGroup = 0; + timeAvailableFrom?: number; + timeAvailableFromReadable?: string; + timeAvailableTo?: number; + timeAvailableToReadable?: string; + isEmpty = true; + groupInfo?: CoreGroupInfo; + entries: AddonModDataEntry[] = []; + firstEntry?: number; + canAdd = false; + canSearch = false; + search: AddonModDataSearchDataParams = { + sortBy: '0', + sortDirection: 'DESC', + page: 0, + text: '', + searching: false, + searchingAdvanced: false, + advanced: [], + }; + + hasNextPage = false; + entriesRendered = ''; + extraImports: Type[] = [AddonModDataComponentsCompileModule]; + + jsData? : { + fields: Record; + entries: Record; + database: AddonModDataData; + module: CoreCourseModule; + group: number; + gotoEntry: (a: number) => void; + }; + + // Data for found records translation. + foundRecordsTranslationData? : { + num: number; + max: number; + reseturl: string; + };; + + hasOfflineRatings = false; + + protected syncEventName = AddonModDataSyncProvider.AUTO_SYNCED; + protected hasComments = false; + protected fieldsArray: AddonModDataField[] = []; + protected entryChangedObserver?: CoreEventObserver; + protected ratingOfflineObserver?: CoreEventObserver; + protected ratingSyncObserver?: CoreEventObserver; + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModDataIndexComponent', content, courseContentsPage); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + await super.ngOnInit(); + + this.selectedGroup = this.group || 0; + + // Refresh entries on change. + this.entryChangedObserver = CoreEvents.on(AddonModDataProvider.ENTRY_CHANGED, (eventData) => { + if (this.database?.id == eventData.dataId) { + this.loaded = false; + + return this.loadContent(true); + } + }, this.siteId); + + // Listen for offline ratings saved and synced. + this.ratingOfflineObserver = CoreEvents.on(CoreRatingProvider.RATING_SAVED_EVENT, (data) => { + if (data.component == 'mod_data' && data.ratingArea == 'entry' && data.contextLevel == ContextLevel.MODULE + && data.instanceId == this.database?.coursemodule) { + this.hasOfflineRatings = true; + } + }); + this.ratingSyncObserver = CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, (data) => { + if (data.component == 'mod_data' && data.ratingArea == 'entry' && data.contextLevel == ContextLevel.MODULE + && data.instanceId == this.database?.coursemodule) { + this.hasOfflineRatings = false; + } + }); + + await this.loadContent(false, true); + await this.logView(true); + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModData.invalidateDatabaseData(this.courseId)); + if (this.database) { + promises.push(AddonModData.invalidateDatabaseAccessInformationData(this.database.id)); + promises.push(CoreGroups.invalidateActivityGroupInfo(this.database.coursemodule)); + promises.push(AddonModData.invalidateEntriesData(this.database.id)); + promises.push(AddonModData.invalidateFieldsData(this.database.id)); + + if (this.hasComments) { + CoreEvents.trigger(CoreCommentsProvider.REFRESH_COMMENTS_EVENT, { + contextLevel: ContextLevel.MODULE, + instanceId: this.database.coursemodule, + }, CoreSites.getCurrentSiteId()); + } + } + + await Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModDataAutoSyncData): boolean { + if (this.database && syncEventData.dataId == this.database.id && typeof syncEventData.entryId == 'undefined') { + this.loaded = false; + // Refresh the data. + this.content?.scrollToTop(); + + return true; + } + + return false; + } + + /** + * Download data contents. + * + * @param refresh If it's refreshing content. + * @param sync If it should try to sync. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + let canAdd = false; + let canSearch = false; + + this.database = await AddonModData.getDatabase(this.courseId, this.module.id); + this.hasComments = this.database.comments; + + this.description = this.database.intro; + this.dataRetrieved.emit(this.database); + + if (sync) { + // Try to synchronize the data. + await CoreUtils.ignoreErrors(this.syncActivity(showErrors)); + } + + this.groupInfo = await CoreGroups.getActivityGroupInfo(this.database.coursemodule); + this.selectedGroup = CoreGroups.validateGroupId(this.selectedGroup, this.groupInfo); + + this.access = await AddonModData.getDatabaseAccessInformation(this.database.id, { + cmId: this.module.id, + groupId: this.selectedGroup, + }); + + if (!this.access.timeavailable) { + const time = CoreTimeUtils.timestamp(); + + this.timeAvailableFrom = this.database.timeavailablefrom && time < this.database.timeavailablefrom + ? this.database.timeavailablefrom * 1000 + : undefined; + this.timeAvailableFromReadable = this.timeAvailableFrom + ? CoreTimeUtils.userDate(this.timeAvailableFrom) + : undefined; + this.timeAvailableTo = this.database.timeavailableto && time > this.database.timeavailableto + ? this.database.timeavailableto * 1000 + : undefined; + this.timeAvailableToReadable = this.timeAvailableTo + ? CoreTimeUtils.userDate(this.timeAvailableTo) + : undefined; + + this.isEmpty = true; + this.groupInfo = undefined; + } else { + canSearch = true; + canAdd = this.access.canaddentry; + } + + const fields = await AddonModData.getFields(this.database.id, { cmId: this.module.id }); + this.search.advanced = []; + + this.fields = CoreUtils.arrayToObject(fields, 'id'); + this.fieldsArray = CoreUtils.objectToArray(this.fields); + if (this.fieldsArray.length == 0) { + canSearch = false; + canAdd = false; + } + + try { + await this.fetchEntriesData(); + } finally { + this.canAdd = canAdd; + this.canSearch = canSearch; + this.fillContextMenu(refresh); + } + } + + /** + * Fetch current database entries. + * + * @return Resolved then done. + */ + protected async fetchEntriesData(): Promise { + + const search = this.search.searching && !this.search.searchingAdvanced ? this.search.text : undefined; + const advSearch = this.search.searching && this.search.searchingAdvanced ? this.search.advanced : undefined; + + const entries = await AddonModDataHelper.fetchEntries(this.database!, this.fieldsArray, { + groupId: this.selectedGroup, + search, + advSearch, + sort: Number(this.search.sortBy), + order: this.search.sortDirection, + page: this.search.page, + cmId: this.module.id, + }); + + const numEntries = entries.entries.length; + const numOfflineEntries = entries.offlineEntries?.length || 0; + + this.isEmpty = !numEntries && !numOfflineEntries; + + this.hasNextPage = numEntries >= AddonModDataProvider.PER_PAGE && ((this.search.page + 1) * + AddonModDataProvider.PER_PAGE) < entries.totalcount; + + this.hasOffline = entries.hasOfflineActions; + + this.hasOfflineRatings = !!entries.hasOfflineRatings; + + this.entriesRendered = ''; + + this.foundRecordsTranslationData = typeof entries.maxcount != 'undefined' + ? { + num: entries.totalcount, + max: entries.maxcount, + reseturl: '#', + } + : undefined; + + if (!this.isEmpty) { + this.entries = (entries.offlineEntries || []).concat(entries.entries); + + let entriesHTML = AddonModDataHelper.getTemplate( + this.database!, + AddonModDataTemplateType.LIST_HEADER, + this.fieldsArray, + ); + + // Get first entry from the whole list. + if (!this.search.searching || !this.firstEntry) { + this.firstEntry = this.entries[0].id; + } + + const template = AddonModDataHelper.getTemplate(this.database!, AddonModDataTemplateType.LIST, this.fieldsArray); + + const entriesById: Record = {}; + this.entries.forEach((entry, index) => { + entriesById[entry.id] = entry; + + const actions = AddonModDataHelper.getActions(this.database!, this.access!, entry); + const offset = this.search.searching + ? 0 + : this.search.page * AddonModDataProvider.PER_PAGE + index - numOfflineEntries; + + entriesHTML += AddonModDataHelper.displayShowFields( + template, + this.fieldsArray, + entry, + offset, + AddonModDataTemplateMode.LIST, + actions, + ); + }); + entriesHTML += AddonModDataHelper.getTemplate(this.database!, AddonModDataTemplateType.LIST_FOOTER, this.fieldsArray); + + this.entriesRendered = CoreDomUtils.fixHtml(entriesHTML); + + // Pass the input data to the component. + this.jsData = { + fields: this.fields, + entries: entriesById, + database: this.database!, + module: this.module, + group: this.selectedGroup, + gotoEntry: this.gotoEntry.bind(this), + }; + } else if (!this.search.searching) { + // Empty and no searching. + this.canSearch = false; + this.firstEntry = undefined; + } else { + this.firstEntry = undefined; + } + } + + /** + * Display the chat users modal. + */ + async showSearch(): Promise { + const modal = await ModalController.create({ + component: AddonModDataSearchComponent, + componentProps: { + search: this.search, + fields: this.fields, + database: this.database, + }, + }); + + await modal.present(); + + const result = await modal.onDidDismiss(); + // Add data to search object. + if (result.data) { + this.search = result.data; + this.searchEntries(0); + } + } + + /** + * Performs the search and closes the modal. + * + * @param page Page number. + * @return Resolved when done. + */ + async searchEntries(page: number): Promise { + this.loaded = false; + this.search.page = page; + + try { + await this.fetchEntriesData(); + // Log activity view for coherence with Moodle web. + await this.logView(); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } finally { + this.loaded = true; + } + } + + /** + * Reset all search filters and closes the modal. + */ + searchReset(): void { + this.search.sortBy = '0'; + this.search.sortDirection = 'DESC'; + this.search.text = ''; + this.search.advanced = []; + this.search.searchingAdvanced = false; + this.search.searching = false; + this.searchEntries(0); + } + + /** + * Set group to see the database. + * + * @param groupId Group ID. + * @return Resolved when new group is selected or rejected if not. + */ + async setGroup(groupId: number): Promise { + this.selectedGroup = groupId; + this.search.page = 0; + + // Only update canAdd if there's any field, otheerwise, canAdd will remain false. + if (this.fieldsArray.length > 0) { + // Update values for current group. + this.access = await AddonModData.getDatabaseAccessInformation(this.database!.id, { + groupId: this.selectedGroup, + cmId: this.module.id, + }); + + this.canAdd = this.access.canaddentry; + } + + try { + await this.fetchEntriesData(); + + // Log activity view for coherence with Moodle web. + return this.logView(); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } + } + + /** + * Opens add entries form. + */ + gotoAddEntries(): void { + const params: Params = { + module: this.module, + courseId: this.courseId, + group: this.selectedGroup, + }; + + CoreNavigator.navigate('edit', { params }); + } + + /** + * Goto the selected entry. + * + * @param entryId Entry ID. + */ + gotoEntry(entryId: number): void { + const params: Params = { + module: this.module, + courseId: this.courseId, + group: this.selectedGroup, + }; + + // Try to find page number and offset of the entry. + const pageXOffset = this.entries.findIndex((entry) => entry.id == entryId); + if (pageXOffset >= 0) { + params.offset = this.search.page * AddonModDataProvider.PER_PAGE + pageXOffset; + } + + CoreNavigator.navigate(String(entryId), { params }); + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected async sync(): Promise { + await AddonModDataPrefetchHandler.sync(this.module, this.courseId); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + protected hasSyncSucceed(result: AddonModDataSyncResult): boolean { + return result.updated; + } + + /** + * Log viewing the activity. + * + * @param checkCompletion Whether to check completion. + * @return Promise resolved when done. + */ + protected async logView(checkCompletion = false): Promise { + if (!this.database || !this.database.id) { + return; + } + + try { + await AddonModData.logView(this.database.id, this.database.name); + if (checkCompletion) { + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } + } catch { + // Ignore errors, the user could be offline. + } + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + this.entryChangedObserver?.off(); + this.ratingOfflineObserver?.off(); + this.ratingSyncObserver?.off(); + } + +} + +export type AddonModDataSearchDataParams = { + sortBy: string; + sortDirection: string; + page: number; + text: string; + searching: boolean; + searchingAdvanced: boolean; + advanced?: AddonModDataSearchEntriesAdvancedField[]; +}; diff --git a/src/addons/mod/data/components/search/search.html b/src/addons/mod/data/components/search/search.html new file mode 100644 index 000000000..ad5bcef5d --- /dev/null +++ b/src/addons/mod/data/components/search/search.html @@ -0,0 +1,68 @@ + + + + + + {{ 'addon.mod_data.search' | translate }} + + + + + + + + + + {{ 'addon.mod_data.advancedsearch' | translate }} + + +
+ + + + + + + + {{ 'core.sortby' | translate }} + + + {{field.name}} + + + {{ 'addon.mod_data.timeadded' | translate }} + {{ 'addon.mod_data.timemodified' | translate }} + {{ 'addon.mod_data.authorfirstname' | translate }} + {{ 'addon.mod_data.authorlastname' | translate }} + + {{ 'addon.mod_data.approved' | translate }} + + + + + + + + {{ 'addon.mod_data.ascending' | translate }} + + + + {{ 'addon.mod_data.descending' | translate }} + + + + + + +
+ + + {{ 'addon.mod_data.search' | translate }} + +
+
+
diff --git a/src/addons/mod/data/components/search/search.ts b/src/addons/mod/data/components/search/search.ts new file mode 100644 index 000000000..74e7bab66 --- /dev/null +++ b/src/addons/mod/data/components/search/search.ts @@ -0,0 +1,216 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ElementRef, Input, OnInit, Type, ViewChild } from '@angular/core'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { CoreTag } from '@features/tag/services/tag'; +import { CoreSites } from '@services/sites'; +import { CoreFormFields, CoreForms } from '@singletons/form'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController } from '@singletons'; +import { + AddonModDataField, + AddonModDataData, + AddonModDataTemplateType, + AddonModDataSearchEntriesAdvancedField, +} from '../../services/data'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataHelper } from '../../services/data-helper'; +import { AddonModDataComponentsCompileModule } from '../components-compile.module'; +import { AddonModDataSearchDataParams } from '../index'; + +/** + * Page that displays the search modal. + */ +@Component({ + selector: 'addon-mod-data-search-modal', + templateUrl: 'search.html', + styleUrls: ['../../data.scss', '../../data-forms.scss'], +}) +export class AddonModDataSearchComponent implements OnInit { + + @ViewChild('searchFormEl') formElement!: ElementRef; + + @Input() search!: AddonModDataSearchDataParams; + @Input() fields!: Record; + @Input() database!: AddonModDataData; + + advancedSearch = ''; + advancedIndexed: CoreFormFields = {}; + extraImports: Type[] = [AddonModDataComponentsCompileModule]; + + searchForm: FormGroup; + jsData? : { + fields: Record; + form: FormGroup; + search: CoreFormFields; + }; + + fieldsArray: AddonModDataField[] = []; + + constructor( + protected fb: FormBuilder, + ) { + this.searchForm = new FormGroup({}); + } + + ngOnInit(): void { + this.advancedIndexed = {}; + this.search.advanced?.forEach((field) => { + if (typeof field != 'undefined') { + this.advancedIndexed[field.name] = field.value + ? CoreTextUtils.parseJSON(field.value) + : ''; + } + }); + + this.searchForm.addControl('text', this.fb.control(this.search.text || '')); + this.searchForm.addControl('sortBy', this.fb.control(this.search.sortBy || '0')); + this.searchForm.addControl('sortDirection', this.fb.control(this.search.sortDirection || 'DESC')); + this.searchForm.addControl('firstname', this.fb.control(this.advancedIndexed['firstname'] || '')); + this.searchForm.addControl('lastname', this.fb.control(this.advancedIndexed['lastname'] || '')); + + this.fieldsArray = CoreUtils.objectToArray(this.fields); + this.advancedSearch = this.renderAdvancedSearchFields(); + } + + /** + * Displays Advanced Search Fields. + * + * @return Generated HTML. + */ + protected renderAdvancedSearchFields(): string { + this.jsData = { + fields: this.fields, + form: this.searchForm, + search: this.advancedIndexed, + }; + + let template = AddonModDataHelper.getTemplate(this.database, AddonModDataTemplateType.SEARCH, this.fieldsArray); + + // Replace the fields found on template. + this.fieldsArray.forEach((field) => { + let replace = '[[' + field.name + ']]'; + replace = replace.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); + const replaceRegex = new RegExp(replace, 'gi'); + + // Replace field by a generic directive. + const render = ''; + template = template.replace(replaceRegex, render); + }); + + // Not pluginable other search elements. + // Replace firstname field by the text input. + let replaceRegex = new RegExp('##firstname##', 'gi'); + let render = ''; + template = template.replace(replaceRegex, render); + + // Replace lastname field by the text input. + replaceRegex = new RegExp('##lastname##', 'gi'); + render = ''; + template = template.replace(replaceRegex, render); + + // Searching by tags is not supported. + replaceRegex = new RegExp('##tags##', 'gi'); + const message = CoreTag.areTagsAvailableInSite() ? + '

{{ \'addon.mod_data.searchbytagsnotsupported\' | translate }}

' + : ''; + template = template.replace(replaceRegex, message); + + return template; + } + + /** + * Retrieve the entered data in search in a form. + * + * @param searchedData Array with the entered form values. + * @return Array with the answers. + */ + getSearchDataFromForm(searchedData: CoreFormFields): AddonModDataSearchEntriesAdvancedField[] { + const advancedSearch: AddonModDataSearchEntriesAdvancedField[] = []; + + // Filter and translate fields to each field plugin. + this.fieldsArray.forEach((field) => { + const fieldData = AddonModDataFieldsDelegate.getFieldSearchData(field, searchedData); + + fieldData.forEach((data) => { + // WS wants values in Json format. + advancedSearch.push({ + name: data.name, + value: JSON.stringify(data.value), + }); + }); + }); + + // Not pluginable other search elements. + if (searchedData.firstname) { + // WS wants values in Json format. + advancedSearch.push({ + name: 'firstname', + value: JSON.stringify(searchedData.firstname), + }); + } + + if (searchedData.lastname) { + // WS wants values in Json format. + advancedSearch.push({ + name: 'lastname', + value: JSON.stringify(searchedData.lastname), + }); + } + + return advancedSearch; + } + + /** + * Close modal. + */ + closeModal(): void { + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + + ModalController.dismiss(); + } + + /** + * Done editing. + * + * @param e Event. + */ + searchEntries(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + const searchedData = this.searchForm.value; + + if (this.search.searchingAdvanced) { + this.search.advanced = this.getSearchDataFromForm(searchedData); + this.search.searching = this.search.advanced.length > 0; + } else { + this.search.text = searchedData.text; + this.search.searching = this.search.text.length > 0; + } + + this.search.sortBy = searchedData.sortBy; + this.search.sortDirection = searchedData.sortDirection; + + CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); + + ModalController.dismiss(this.search); + } + +} diff --git a/src/addons/mod/data/data-forms.scss b/src/addons/mod/data/data-forms.scss new file mode 100644 index 000000000..365655ab4 --- /dev/null +++ b/src/addons/mod/data/data-forms.scss @@ -0,0 +1,106 @@ +@import "~theme/globals"; + +// Edit and search modal. +:host { + --input-border-color: var(--gray); + --input-border-width: 1px; + --select-border-width: 0; + + ::ng-deep { + table { + width: 100%; + } + td { + vertical-align: top; + } + + .addon-data-latlong { + display: flex; + } + } + + .addon-data-advanced-search { + padding: 16px; + width: 100%; + // @todo check if needed + // @include safe-area-padding-horizontal(16px !important, 16px !important); + } + + .addon-data-contents form, + form .addon-data-advanced-search { + background-color: var(--ion-item-background); + + ::ng-deep { + + ion-input { + border-bottom: var(--input-border-width) solid var(--input-border-color); + &.has-focus, + &.has-focus.ion-valid, + &.ion-touched.ion-invalid { + --input-border-width: 2px; + } + + &.has-focus { + --input-border-color: var(--core-color); + } + &.has-focus.ion-valid { + --input-border-color: var(--success); + } + &.ion-touched.ion-invalid { + --input-border-color: var(--danger); + } + } + + core-rich-text-editor { + border-bottom: var(--select-border-width) solid var(--input-border-color); + + &.ion-touched.ng-valid, + &.ion-touched.ng-invalid { + --select-border-width: 2px; + } + + &.ion-touched.ng-valid { + --input-border-color: var(--success); + } + &.ion-touched.ng-invalid { + --input-border-color: var(--danger); + } + } + ion-select { + border-bottom: var(--select-border-width) solid var(--input-border-color); + + &.ion-touched.ion-valid, + &.ion-touched.ion-invalid { + --select-border-width: 2px; + } + + &.ion-touched.ion-valid { + --input-border-color: var(--success); + } + &.ion-touched.ion-invalid { + --input-border-color: var(--danger); + } + } + + .has-errors ion-input.ion-invalid { + --input-border-width: 2px; + --input-border-color: var(--danger); + } + + .has-errors ion-select.ion-invalid, + .has-errors core-rich-text-editor.ng-invalid { + --select-border-width: 2px; + --input-border-color: var(--danger); + } + + .core-mark-required { + @include float(end); + + + ion-input, + + ion-select { + @include padding(null, 20px, null, null); + } + } + } + } +} diff --git a/src/addons/mod/data/data-lazy.module.ts b/src/addons/mod/data/data-lazy.module.ts new file mode 100644 index 000000000..955b03a35 --- /dev/null +++ b/src/addons/mod/data/data-lazy.module.ts @@ -0,0 +1,65 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CoreCommentsComponentsModule } from '@features/comments/components/components.module'; +import { CoreCompileHtmlComponentModule } from '@features/compile/components/compile-html/compile-html.module'; +import { CoreRatingComponentsModule } from '@features/rating/components/components.module'; +import { CanLeaveGuard } from '@guards/can-leave'; +import { AddonModDataComponentsCompileModule } from './components/components-compile.module'; +import { AddonModDataComponentsModule } from './components/components.module'; +import { AddonModDataEditPage } from './pages/edit/edit'; +import { AddonModDataEntryPage } from './pages/entry/entry'; +import { AddonModDataIndexPage } from './pages/index/index'; + +const routes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModDataIndexPage, + }, + { + path: ':courseId/:cmId/edit', + component: AddonModDataEditPage, + canDeactivate: [CanLeaveGuard], + }, + { + path: ':courseId/:cmId/edit/:entryId', + component: AddonModDataEditPage, + canDeactivate: [CanLeaveGuard], + }, + { + path: ':courseId/:cmId/:entryId', + component: AddonModDataEntryPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModDataComponentsModule, + AddonModDataComponentsCompileModule, + CoreCommentsComponentsModule, + CoreRatingComponentsModule, + CoreCompileHtmlComponentModule, + ], + declarations: [ + AddonModDataIndexPage, + AddonModDataEntryPage, + AddonModDataEditPage, + ], +}) +export class AddonModDataLazyModule {} diff --git a/src/addons/mod/data/data.module.ts b/src/addons/mod/data/data.module.ts new file mode 100644 index 000000000..4c2410ebc --- /dev/null +++ b/src/addons/mod/data/data.module.ts @@ -0,0 +1,88 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; +import { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModDataProvider } from './services/data'; +import { AddonModDataFieldsDelegateService } from './services/data-fields-delegate'; +import { AddonModDataHelperProvider } from './services/data-helper'; +import { AddonModDataOfflineProvider } from './services/data-offline'; +import { AddonModDataSyncProvider } from './services/data-sync'; +import { ADDON_MOD_DATA_OFFLINE_SITE_SCHEMA } from './services/database/data'; +import { AddonModDataApproveLinkHandler } from './services/handlers/approve-link'; +import { AddonModDataDeleteLinkHandler } from './services/handlers/delete-link'; +import { AddonModDataEditLinkHandler } from './services/handlers/edit-link'; +import { AddonModDataIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModDataListLinkHandler } from './services/handlers/list-link'; +import { AddonModDataModuleHandler, AddonModDataModuleHandlerService } from './services/handlers/module'; +import { AddonModDataPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModDataShowLinkHandler } from './services/handlers/show-link'; +import { AddonModDataSyncCronHandler } from './services/handlers/sync-cron'; +import { AddonModDataTagAreaHandler } from './services/handlers/tag-area'; +import { AddonModDataFieldModule } from './fields/field.module'; + +// List of providers (without handlers). +export const ADDON_MOD_DATA_SERVICES: Type[] = [ + AddonModDataProvider, + AddonModDataHelperProvider, + AddonModDataSyncProvider, + AddonModDataOfflineProvider, + AddonModDataFieldsDelegateService, +]; + +const routes: Routes = [ + { + path: AddonModDataModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./data-lazy.module').then(m => m.AddonModDataLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModDataFieldModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ADDON_MOD_DATA_OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.registerHandler(AddonModDataModuleHandler.instance); + CoreCourseModulePrefetchDelegate.registerHandler(AddonModDataPrefetchHandler.instance); + CoreCronDelegate.register(AddonModDataSyncCronHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModDataIndexLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModDataListLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModDataApproveLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModDataDeleteLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModDataShowLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModDataEditLinkHandler.instance); + CoreTagAreaDelegate.registerHandler(AddonModDataTagAreaHandler.instance); + }, + }, + ], +}) +export class AddonModDataModule {} diff --git a/src/addons/mod/data/data.scss b/src/addons/mod/data/data.scss new file mode 100644 index 000000000..62141a4e3 --- /dev/null +++ b/src/addons/mod/data/data.scss @@ -0,0 +1,70 @@ +@import "~theme/globals"; + +/// @prop - The padding for the grid column +$grid-column-padding: var(--ion-grid-column-padding, 5px) !default; + +/// @prop - The padding for the column at different breakpoints +$grid-column-paddings: ( + xs: var(--ion-grid-column-padding-xs, $grid-column-padding), + sm: var(--ion-grid-column-padding-sm, $grid-column-padding), + md: var(--ion-grid-column-padding-md, $grid-column-padding), + lg: var(--ion-grid-column-padding-lg, $grid-column-padding), + xl: var(--ion-grid-column-padding-xl, $grid-column-padding) +) !default; + +.addon-data-contents { + overflow: visible; + white-space: normal; + word-break: break-word; + padding: 16px; + // @todo check if needed + // @include safe-area-padding-horizontal(16px !important, 16px !important); + + background-color: var(--ion-item-background); + border-width: 1px 0; + border-style: solid; + border-color: var(--gray-dark); + + ::ng-deep { + table, tbody { + display: block; + } + + tr { + // Imported form ion-row; + display: flex; + flex-wrap: wrap; + + padding: 0; + @include media-breakpoint-down(sm) { + flex-direction: column; + } + } + + td, th { + // Imported form ion-col; + @include make-breakpoint-padding($grid-column-paddings); + @include margin(0); + box-sizing: border-box; + position: relative; + flex-basis: 0; + flex-grow: 1; + width: 100%; + max-width: 100%; + min-height: auto; + } + + // Do not let block elements to define widths or heights. + address, article, aside, blockquote, canvas, dd, div, dl, dt, fieldset, figcaption, figure, footer, form, + h1, h2, h3, h4, h5, h6, + header, hr, li, main, nav, noscript, ol, p, pre, section, table, tfoot, ul, video { + width: auto !important; + height: auto !important; + min-width: auto !important; + min-height: auto !important; + // Avoid having one entry over another. + max-height: none !important; + + } + } +} diff --git a/src/addons/mod/data/fields/checkbox/checkbox.module.ts b/src/addons/mod/data/fields/checkbox/checkbox.module.ts new file mode 100644 index 000000000..beb2b7786 --- /dev/null +++ b/src/addons/mod/data/fields/checkbox/checkbox.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AddonModDataFieldCheckboxComponent } from './component/checkbox'; +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldCheckboxHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldCheckboxComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldCheckboxHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldCheckboxComponent, + ], +}) +export class AddonModDataFieldCheckboxModule {} diff --git a/src/addons/mod/data/fields/checkbox/component/addon-mod-data-field-checkbox.html b/src/addons/mod/data/fields/checkbox/component/addon-mod-data-field-checkbox.html new file mode 100644 index 000000000..9bfe1c173 --- /dev/null +++ b/src/addons/mod/data/fields/checkbox/component/addon-mod-data-field-checkbox.html @@ -0,0 +1,16 @@ + + + + {{option.key}} + + + + + {{ 'addon.mod_data.selectedrequired' | translate }} + + + + + + diff --git a/src/addons/mod/data/fields/checkbox/component/checkbox.ts b/src/addons/mod/data/fields/checkbox/component/checkbox.ts new file mode 100644 index 000000000..032574c9b --- /dev/null +++ b/src/addons/mod/data/fields/checkbox/component/checkbox.ts @@ -0,0 +1,70 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonModDataEntryField } from '@addons/mod/data/services/data'; +import { Component } from '@angular/core'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data checkbox field. + */ +@Component({ + selector: 'addon-mod-data-field-checkbox', + templateUrl: 'addon-mod-data-field-checkbox.html', +}) +export class AddonModDataFieldCheckboxComponent extends AddonModDataFieldPluginComponent { + + options: { + key: string; + value: string; + }[] = []; + + /** + * @inheritdoc + */ + protected init(): void { + if (this.displayMode) { + this.updateValue(this.value); + + return; + } + + this.options = this.field.param1.split(/\r?\n/).map((option) => ({ key: option, value: option })); + + const values: string[] = []; + if (this.editMode && this.value && this.value.content) { + this.value.content.split('##').forEach((value) => { + const x = this.options.findIndex((option) => value == option.key); + if (x >= 0) { + values.push(value); + } + }); + } + + if (this.searchMode) { + this.addControl('f_' + this.field.id + '_allreq'); + } + + this.addControl('f_' + this.field.id, values); + } + + /** + * @inheritdoc + */ + protected updateValue(value?: Partial): void { + this.value = value || {}; + this.value.content = value?.content?.split('##').join('
'); + } + +} diff --git a/src/addons/mod/data/fields/checkbox/services/handler.ts b/src/addons/mod/data/fields/checkbox/services/handler.ts new file mode 100644 index 000000000..a2f0b4e20 --- /dev/null +++ b/src/addons/mod/data/fields/checkbox/services/handler.ts @@ -0,0 +1,131 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { + AddonModDataEntryField, + AddonModDataField, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from '@addons/mod/data/services/data'; +import { AddonModDataFieldHandler } from '@addons/mod/data/services/data-fields-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFormFields } from '@singletons/form'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldCheckboxComponent } from '../component/checkbox'; + +/** + * Handler for checkbox data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldCheckboxHandlerService implements AddonModDataFieldHandler { + + name = 'AddonModDataFieldCheckboxHandler'; + type = 'checkbox'; + + /** + * @inheritdoc + */ + getComponent(): Type { + return AddonModDataFieldCheckboxComponent; + } + + /** + * @inheritdoc + */ + getFieldSearchData( + field: AddonModDataField, + inputData: CoreFormFields, + ): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + + const fieldName = 'f_' + field.id; + const reqName = 'f_' + field.id + '_allreq'; + + if (inputData[fieldName]) { + const values: AddonModDataSearchEntriesAdvancedFieldFormatted[] = []; + + values.push({ + name: fieldName, + value: inputData[fieldName], + }); + + if (inputData[reqName]) { + values.push({ + name: reqName, + value: true, + }); + } + + return values; + } + + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSubfieldData[] { + const fieldName = 'f_' + field.id; + + return [{ + fieldid: field.id, + value: inputData[fieldName] || [], + }]; + } + + /** + * @inheritdoc + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean { + const fieldName = 'f_' + field.id; + + const content = originalFieldData?.content || ''; + + return inputData[fieldName].join('##') != content; + } + + /** + * Check and get field requeriments. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the edit form. + * @return String with the notification or false. + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (field.required && (!inputData || !inputData.length || !inputData[0].value)) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { + originalContent.content = (offlineContent[''] && offlineContent[''].join('##')) || ''; + + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModDataFieldCheckboxHandler = makeSingleton(AddonModDataFieldCheckboxHandlerService); diff --git a/src/addons/mod/data/fields/date/component/addon-mod-data-field-date.html b/src/addons/mod/data/fields/date/component/addon-mod-data-field-date.html new file mode 100644 index 000000000..4d1c12b81 --- /dev/null +++ b/src/addons/mod/data/fields/date/component/addon-mod-data-field-date.html @@ -0,0 +1,16 @@ + + + + + + + {{ 'addon.mod_data.usedate' | translate }} + + + + + + + {{ displayDate | coreFormatDate:'strftimedate' }} + diff --git a/src/addons/mod/data/fields/date/component/date.ts b/src/addons/mod/data/fields/date/component/date.ts new file mode 100644 index 000000000..fcf20c918 --- /dev/null +++ b/src/addons/mod/data/fields/date/component/date.ts @@ -0,0 +1,70 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { CoreTimeUtils } from '@services/utils/time'; +import { Translate } from '@singletons'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data date field. + */ +@Component({ + selector: 'addon-mod-data-field-date', + templateUrl: 'addon-mod-data-field-date.html', +}) +export class AddonModDataFieldDateComponent extends AddonModDataFieldPluginComponent { + + format!: string; + displayDate?: number; + + /** + * @inheritdoc + */ + protected init(): void { + if (this.displayMode) { + return; + } + + let date: Date; + + // Calculate format to use. + this.format = CoreTimeUtils.fixFormatForDatetime(CoreTimeUtils.convertPHPToMoment( + Translate.instant('core.strftimedate'), + )); + + if (this.searchMode) { + this.addControl('f_' + this.field.id + '_z'); + + date = this.searchFields!['f_' + this.field.id + '_y'] + ? new Date(this.searchFields!['f_' + this.field.id + '_y'] + '-' + + this.searchFields!['f_' + this.field.id + '_m'] + '-' + this.searchFields!['f_' + this.field.id + '_d']) + : new Date(); + + this.searchFields!['f_' + this.field.id] = CoreTimeUtils.toDatetimeFormat(date.getTime()); + } else { + date = this.value?.content + ? new Date(parseInt(this.value.content, 10) * 1000) + : new Date(); + + this.displayDate = this.value?.content + ? parseInt(this.value.content, 10) * 1000 + : undefined; + + } + + this.addControl('f_' + this.field.id, CoreTimeUtils.toDatetimeFormat(date.getTime())); + } + +} diff --git a/src/addons/mod/data/fields/date/date.module.ts b/src/addons/mod/data/fields/date/date.module.ts new file mode 100644 index 000000000..05d33a1e0 --- /dev/null +++ b/src/addons/mod/data/fields/date/date.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldDateComponent } from './component/date'; +import { AddonModDataFieldDateHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldDateComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldDateHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldDateComponent, + ], +}) +export class AddonModDataFieldDateModule {} diff --git a/src/addons/mod/data/fields/date/services/handler.ts b/src/addons/mod/data/fields/date/services/handler.ts new file mode 100644 index 000000000..0fdde13ee --- /dev/null +++ b/src/addons/mod/data/fields/date/services/handler.ts @@ -0,0 +1,165 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModDataEntryField, + AddonModDataField, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from '@addons/mod/data/services/data'; +import { AddonModDataFieldHandler } from '@addons/mod/data/services/data-fields-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFormFields } from '@singletons/form'; +import { CoreTimeUtils } from '@services/utils/time'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldDateComponent } from '../component/date'; + +/** + * Handler for date data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldDateHandlerService implements AddonModDataFieldHandler { + + name = 'AddonModDataFieldDateHandler'; + type = 'date'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldDateComponent; + } + + /** + * @inheritdoc + */ + getFieldSearchData( + field: AddonModDataField, + inputData: CoreFormFields, + ): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + const fieldName = 'f_' + field.id; + const enabledName = 'f_' + field.id + '_z'; + + if (inputData[enabledName] && typeof inputData[fieldName] == 'string') { + const date = inputData[fieldName].substr(0, 10).split('-'); + + return [ + { + name: fieldName + '_y', + value: date[0], + }, + { + name: fieldName + '_m', + value: date[1], + }, + { + name: fieldName + '_d', + value: date[2], + }, + { + name: enabledName, + value: 1, + }, + ]; + } + + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSubfieldData[] { + const fieldName = 'f_' + field.id; + + if (typeof inputData[fieldName] != 'string') { + return []; + } + + const date = inputData[fieldName].substr(0, 10).split('-'); + + return [ + { + fieldid: field.id, + subfield: 'year', + value: date[0], + }, + { + fieldid: field.id, + subfield: 'month', + value: date[1], + }, + { + fieldid: field.id, + subfield: 'day', + value: date[2], + }, + ]; + } + + /** + * @inheritdoc + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean { + const fieldName = 'f_' + field.id; + const input = inputData[fieldName] && inputData[fieldName].substr(0, 10) || ''; + + const content = (originalFieldData && originalFieldData?.content && + CoreTimeUtils.toDatetimeFormat(parseInt(originalFieldData.content, 10) * 1000).substr(0, 10)) || ''; + + return input != content; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (field.required && + (!inputData || inputData.length < 2 || !inputData[0].value || !inputData[1].value || !inputData[2].value)) { + + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { + if (offlineContent['day']) { + let date = Date.UTC( + parseInt(offlineContent['year'], 10), + parseInt(offlineContent['month'], 10) - 1, + parseInt(offlineContent['day'], 10), + ); + date = Math.floor(date / 1000); + + originalContent.content = String(date) || ''; + } + + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModDataFieldDateHandler = makeSingleton(AddonModDataFieldDateHandlerService); diff --git a/src/addons/mod/data/fields/field.module.ts b/src/addons/mod/data/fields/field.module.ts new file mode 100644 index 000000000..293e00baf --- /dev/null +++ b/src/addons/mod/data/fields/field.module.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModDataFieldCheckboxModule } from './checkbox/checkbox.module'; +import { AddonModDataFieldDateModule } from './date/date.module'; +import { AddonModDataFieldFileModule } from './file/file.module'; +import { AddonModDataFieldLatlongModule } from './latlong/latlong.module'; +import { AddonModDataFieldMenuModule } from './menu/menu.module'; +import { AddonModDataFieldMultimenuModule } from './multimenu/multimenu.module'; +import { AddonModDataFieldNumberModule } from './number/number.module'; +import { AddonModDataFieldPictureModule } from './picture/picture.module'; +import { AddonModDataFieldRadiobuttonModule } from './radiobutton/radiobutton.module'; +import { AddonModDataFieldTextModule } from './text/text.module'; +import { AddonModDataFieldTextareaModule } from './textarea/textarea.module'; +import { AddonModDataFieldUrlModule } from './url/url.module'; + +@NgModule({ + imports: [ + AddonModDataFieldCheckboxModule, + AddonModDataFieldDateModule, + AddonModDataFieldFileModule, + AddonModDataFieldLatlongModule, + AddonModDataFieldMenuModule, + AddonModDataFieldMultimenuModule, + AddonModDataFieldNumberModule, + AddonModDataFieldPictureModule, + AddonModDataFieldRadiobuttonModule, + AddonModDataFieldTextModule, + AddonModDataFieldTextareaModule, + AddonModDataFieldUrlModule, + ], +}) +export class AddonModDataFieldModule { } diff --git a/src/addons/mod/data/fields/file/component/addon-mod-data-field-file.html b/src/addons/mod/data/fields/file/component/addon-mod-data-field-file.html new file mode 100644 index 000000000..4afdd5eb6 --- /dev/null +++ b/src/addons/mod/data/fields/file/component/addon-mod-data-field-file.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + +
+ +
+
diff --git a/src/addons/mod/data/fields/file/component/file.ts b/src/addons/mod/data/fields/file/component/file.ts new file mode 100644 index 000000000..2f1680027 --- /dev/null +++ b/src/addons/mod/data/fields/file/component/file.ts @@ -0,0 +1,81 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { Component } from '@angular/core'; +import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data'; +import { AddonModDataFieldPluginComponent } from '@addons/mod/data/classes/field-plugin-component'; +import { CoreFileSession } from '@services/file-session'; +import { CoreWSExternalFile } from '@services/ws'; +import { FileEntry } from '@ionic-native/file'; + +/** + * Component to render data file field. + */ +@Component({ + selector: 'addon-mod-data-field-file', + templateUrl: 'addon-mod-data-field-file.html', +}) +export class AddonModDataFieldFileComponent extends AddonModDataFieldPluginComponent { + + files: (CoreWSExternalFile | FileEntry)[] = []; + component?: string; + componentId?: number; + maxSizeBytes?: number; + + /** + * Get the files from the input value. + * + * @param value Input value. + * @return List of files. + */ + protected getFiles(value?: Partial): (CoreWSExternalFile | FileEntry)[] { + let files = value?.files || []; + + // Reduce to first element. + if (files.length > 0) { + files = [files[0]]; + } + + return files; + } + + /** + * @inheritdoc + */ + protected init(): void { + if (this.searchMode) { + this.addControl('f_' + this.field.id); + + return; + } + + this.component = AddonModDataProvider.COMPONENT; + this.componentId = this.database!.coursemodule; + + this.updateValue(this.value); + + if (this.editMode) { + this.maxSizeBytes = parseInt(this.field.param3, 10); + CoreFileSession.setFiles(this.component, this.database!.id + '_' + this.field.id, this.files); + } + } + + /** + * @inheritdoc + */ + protected updateValue(value?: Partial): void { + this.value = value; + this.files = this.getFiles(value); + } + +} diff --git a/src/addons/mod/data/fields/file/file.module.ts b/src/addons/mod/data/fields/file/file.module.ts new file mode 100644 index 000000000..e3ad12323 --- /dev/null +++ b/src/addons/mod/data/fields/file/file.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldFileComponent } from './component/file'; +import { AddonModDataFieldFileHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldFileComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldFileHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldFileComponent, + ], +}) +export class AddonModDataFieldFileModule {} diff --git a/src/addons/mod/data/fields/file/services/handler.ts b/src/addons/mod/data/fields/file/services/handler.ts new file mode 100644 index 000000000..77157c31e --- /dev/null +++ b/src/addons/mod/data/fields/file/services/handler.ts @@ -0,0 +1,136 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModDataEntryField, + AddonModDataField, + AddonModDataProvider, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from '@addons/mod/data/services/data'; +import { AddonModDataFieldHandler } from '@addons/mod/data/services/data-fields-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { FileEntry } from '@ionic-native/file'; +import { CoreFileSession } from '@services/file-session'; +import { CoreFormFields } from '@singletons/form'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldFileComponent } from '../component/file'; + +/** + * Handler for file data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldFileHandlerService implements AddonModDataFieldHandler { + + name = 'AddonModDataFieldFileHandler'; + type = 'file'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldFileComponent; + } + + /** + * @inheritdoc + */ + getFieldSearchData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + const fieldName = 'f_' + field.id; + + if (inputData[fieldName]) { + return [{ + name: fieldName, + value: inputData[fieldName], + }]; + } + + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(field: AddonModDataField): AddonModDataSubfieldData[] { + const files = this.getFieldEditFiles(field); + + return [{ + fieldid: field.id, + subfield: 'file', + files: files, + }]; + } + + /** + * @inheritdoc + */ + getFieldEditFiles(field: AddonModDataField): (CoreWSExternalFile | FileEntry)[] { + return CoreFileSession.getFiles(AddonModDataProvider.COMPONENT, field.dataid + '_' + field.id); + } + + /** + * @inheritdoc + */ + hasFieldDataChanged(field: AddonModDataField, inputData: CoreFormFields, originalFieldData: AddonModDataEntryField): boolean { + const files = CoreFileSession.getFiles(AddonModDataProvider.COMPONENT, field.dataid + '_' + field.id) || []; + let originalFiles = (originalFieldData && originalFieldData.files) || []; + + if (originalFiles.length) { + originalFiles = [originalFiles[0]]; + } + + return CoreFileUploader.areFileListDifferent(files, originalFiles); + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (field.required && (!inputData || !inputData.length || !inputData[0].value)) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + + /** + * @inheritdoc + */ + overrideData( + originalContent: AddonModDataEntryField, + offlineContent: CoreFormFields, + offlineFiles?: FileEntry[], + ): AddonModDataEntryField { + const uploadedFilesResult: CoreFileUploaderStoreFilesResult = offlineContent?.file; + + if (uploadedFilesResult && uploadedFilesResult.offline > 0 && offlineFiles && offlineFiles?.length > 0) { + originalContent.content = offlineFiles[0].name; + originalContent.files = [offlineFiles[0]]; + } else if (uploadedFilesResult && uploadedFilesResult.online && uploadedFilesResult.online.length > 0) { + originalContent.content = uploadedFilesResult.online[0].filename || ''; + originalContent.files = [uploadedFilesResult.online[0]]; + } + + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModDataFieldFileHandler = makeSingleton(AddonModDataFieldFileHandlerService); diff --git a/src/addons/mod/data/fields/latlong/component/addon-mod-data-field-latlong.html b/src/addons/mod/data/fields/latlong/component/addon-mod-data-field-latlong.html new file mode 100644 index 000000000..cb3e8c524 --- /dev/null +++ b/src/addons/mod/data/fields/latlong/component/addon-mod-data-field-latlong.html @@ -0,0 +1,27 @@ + + + + + +
+ + °N +
+
+ + °E +
+
+ + + {{ 'addon.mod_data.mylocation' | translate }} + +
+ +
+
+ + + + {{ formatLatLong(north, east) }} + diff --git a/src/addons/mod/data/fields/latlong/component/latlong.ts b/src/addons/mod/data/fields/latlong/component/latlong.ts new file mode 100644 index 000000000..ba17887d0 --- /dev/null +++ b/src/addons/mod/data/fields/latlong/component/latlong.ts @@ -0,0 +1,167 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonModDataFieldPluginComponent } from '@addons/mod/data/classes/field-plugin-component'; +import { AddonModDataEntryField } from '@addons/mod/data/services/data'; +import { Component } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { CoreAnyError } from '@classes/errors/error'; +import { CoreApp } from '@services/app'; +import { CoreGeolocation, CoreGeolocationError, CoreGeolocationErrorReason } from '@services/geolocation'; +import { CoreDomUtils } from '@services/utils/dom'; + +/** + * Component to render data latlong field. + */ +@Component({ + selector: 'addon-mod-data-field-latlong', + templateUrl: 'addon-mod-data-field-latlong.html', +}) +export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginComponent { + + north?: number; + east?: number; + locationServicesEnabled = false; + + constructor( + fb: FormBuilder, + protected sanitizer: DomSanitizer, + ) { + super(fb); + } + + /** + * Format latitude and longitude in a simple text. + * + * @param north Degrees north. + * @param east Degrees East. + * @return Readable Latitude and logitude. + */ + formatLatLong(north?: number, east?: number): string { + if (typeof north !== 'undefined' || typeof east !== 'undefined') { + north = north || 0; + east = east || 0; + const northFixed = Math.abs(north).toFixed(4); + const eastFixed = Math.abs(east).toFixed(4); + + return northFixed + (north < 0 ? '°S' : '°N') + ' ' + eastFixed + (east < 0 ? '°W' : '°E'); + } + + return ''; + } + + /** + * Get link to maps from latitude and longitude. + * + * @param north Degrees north. + * @param east Degrees East. + * @return Link to maps depending on platform. + */ + getLatLongLink(north?: number, east?: number): SafeUrl { + let url = ''; + if (typeof north !== 'undefined' || typeof east !== 'undefined') { + const northFixed = north ? north.toFixed(4) : '0.0000'; + const eastFixed = east ? east.toFixed(4) : '0.0000'; + + if (CoreApp.isIOS()) { + url = 'http://maps.apple.com/?ll=' + northFixed + ',' + eastFixed + '&near=' + northFixed + ',' + eastFixed; + } else { + url = 'geo:' + northFixed + ',' + eastFixed; + } + } + + return this.sanitizer.bypassSecurityTrustUrl(url); + } + + /** + * @inheritdoc + */ + protected async init(): Promise { + if (this.value) { + this.updateValue(this.value); + } + + if (this.editMode) { + this.addControl('f_' + this.field.id + '_0', this.north); + this.addControl('f_' + this.field.id + '_1', this.east); + this.locationServicesEnabled = await CoreGeolocation.canRequest(); + + } else if (this.searchMode) { + this.addControl('f_' + this.field.id); + } + } + + /** + * @inheritdoc + */ + protected updateValue(value?: Partial): void { + this.value = value; + this.north = (value && parseFloat(value.content!)) || undefined; + this.east = (value && parseFloat(value.content1!)) || undefined; + } + + /** + * Get user location. + * + * @param $event The event. + */ + async getLocation(event: Event): Promise { + event.preventDefault(); + + const modal = await CoreDomUtils.showModalLoading('addon.mod_data.gettinglocation', true); + + try { + const coordinates = await CoreGeolocation.getCoordinates(); + + this.form?.controls['f_' + this.field.id + '_0'].setValue(coordinates.latitude); + this.form?.controls['f_' + this.field.id + '_1'].setValue(coordinates.longitude); + } catch (error) { + this.showLocationErrorModal(error); + } + + modal.dismiss(); + } + + /** + * Show the appropriate error modal for the given error getting the location. + * + * @param error Location error. + */ + protected showLocationErrorModal(error: CoreAnyError | CoreGeolocationError): void { + if (error instanceof CoreGeolocationError) { + CoreDomUtils.showErrorModal(this.getGeolocationErrorMessage(error), true); + + return; + } + + CoreDomUtils.showErrorModalDefault(error, 'Error getting location'); + } + + /** + * Get error message from a geolocation error. + * + * @param error Geolocation error. + */ + protected getGeolocationErrorMessage(error: CoreGeolocationError): string { + // tslint:disable-next-line: switch-default + switch (error.reason) { + case CoreGeolocationErrorReason.PermissionDenied: + return 'addon.mod_data.locationpermissiondenied'; + case CoreGeolocationErrorReason.LocationNotEnabled: + return 'addon.mod_data.locationnotenabled'; + } + } + +} diff --git a/src/addons/mod/data/fields/latlong/latlong.module.ts b/src/addons/mod/data/fields/latlong/latlong.module.ts new file mode 100644 index 000000000..1dc6aacc5 --- /dev/null +++ b/src/addons/mod/data/fields/latlong/latlong.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldLatlongComponent } from './component/latlong'; +import { AddonModDataFieldLatlongHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldLatlongComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldLatlongHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldLatlongComponent, + ], +}) +export class AddonModDataFieldLatlongModule {} diff --git a/src/addons/mod/data/fields/latlong/services/handler.ts b/src/addons/mod/data/fields/latlong/services/handler.ts new file mode 100644 index 000000000..c55606f22 --- /dev/null +++ b/src/addons/mod/data/fields/latlong/services/handler.ts @@ -0,0 +1,138 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModDataEntryField, + AddonModDataField, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from '@addons/mod/data/services/data'; +import { AddonModDataFieldHandler } from '@addons/mod/data/services/data-fields-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFormFields } from '@singletons/form'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldLatlongComponent } from '../component/latlong'; + +/** + * Handler for latlong data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldLatlongHandlerService implements AddonModDataFieldHandler { + + name = 'AddonModDataFieldLatlongHandler'; + type = 'latlong'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldLatlongComponent; + } + + /** + * @inheritdoc + */ + getFieldSearchData( + field: AddonModDataField, + inputData: CoreFormFields, + ): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + const fieldName = 'f_' + field.id; + + if (inputData[fieldName]) { + return [{ + name: fieldName, + value: inputData[fieldName], + }]; + } + + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSubfieldData[] { + const fieldName = 'f_' + field.id; + + return [ + { + fieldid: field.id, + subfield: '0', + value: inputData[fieldName + '_0'] || '', + }, + { + fieldid: field.id, + subfield: '1', + value: inputData[fieldName + '_1'] || '', + }, + ]; + } + + /** + * @inheritdoc + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean { + const fieldName = 'f_' + field.id; + const lat = inputData[fieldName + '_0'] || ''; + const long = inputData[fieldName + '_1'] || ''; + const originalLat = (originalFieldData && originalFieldData.content) || ''; + const originalLong = (originalFieldData && originalFieldData.content1) || ''; + + return lat != originalLat || long != originalLong; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + let valueCount = 0; + + // The lat long class has two values that need to be checked. + inputData.forEach((value) => { + if (typeof value.value != 'undefined' && value.value != '') { + valueCount++; + } + }); + + // If we get here then only one field has been filled in. + if (valueCount == 1) { + return Translate.instant('addon.mod_data.latlongboth'); + } else if (field.required && valueCount == 0) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { + originalContent.content = offlineContent[0] || ''; + originalContent.content1 = offlineContent[1] || ''; + + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModDataFieldLatlongHandler = makeSingleton(AddonModDataFieldLatlongHandlerService); diff --git a/src/addons/mod/data/fields/menu/component/addon-mod-data-field-menu.html b/src/addons/mod/data/fields/menu/component/addon-mod-data-field-menu.html new file mode 100644 index 000000000..327531cac --- /dev/null +++ b/src/addons/mod/data/fields/menu/component/addon-mod-data-field-menu.html @@ -0,0 +1,11 @@ + + + + {{ 'addon.mod_data.menuchoose' | translate }} + {{option}} + + + + +{{ value.content }} diff --git a/src/addons/mod/data/fields/menu/component/menu.ts b/src/addons/mod/data/fields/menu/component/menu.ts new file mode 100644 index 000000000..21b56b2ec --- /dev/null +++ b/src/addons/mod/data/fields/menu/component/menu.ts @@ -0,0 +1,47 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data menu field. + */ +@Component({ + selector: 'addon-mod-data-field-menu', + templateUrl: 'addon-mod-data-field-menu.html', +}) +export class AddonModDataFieldMenuComponent extends AddonModDataFieldPluginComponent { + + options: string[] = []; + + /** + * Initialize field. + */ + protected init(): void { + if (this.displayMode) { + return; + } + + this.options = this.field.param1.split('\n'); + + let val: string | undefined; + if (this.editMode && this.value) { + val = this.value.content; + } + + this.addControl('f_' + this.field.id, val); + } + +} diff --git a/src/addons/mod/data/fields/menu/menu.module.ts b/src/addons/mod/data/fields/menu/menu.module.ts new file mode 100644 index 000000000..12a12d4fd --- /dev/null +++ b/src/addons/mod/data/fields/menu/menu.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldMenuComponent } from './component/menu'; +import { AddonModDataFieldMenuHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldMenuComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldMenuHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldMenuComponent, + ], +}) +export class AddonModDataFieldMenuModule {} diff --git a/src/addons/mod/data/fields/menu/services/handler.ts b/src/addons/mod/data/fields/menu/services/handler.ts new file mode 100644 index 000000000..ffd068aa2 --- /dev/null +++ b/src/addons/mod/data/fields/menu/services/handler.ts @@ -0,0 +1,116 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModDataEntryField, + AddonModDataField, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from '@addons/mod/data/services/data'; +import { AddonModDataFieldHandler } from '@addons/mod/data/services/data-fields-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFormFields } from '@singletons/form'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldMenuComponent } from '../component/menu'; + +/** + * Handler for menu data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldMenuHandlerService implements AddonModDataFieldHandler { + + name = 'AddonModDataFieldMenuHandler'; + type = 'menu'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldMenuComponent; + } + + /** + * @inheritdoc + */ + getFieldSearchData( + field: AddonModDataField, + inputData: CoreFormFields, + ): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + const fieldName = 'f_' + field.id; + if (inputData[fieldName]) { + return [{ + name: fieldName, + value: inputData[fieldName], + }]; + } + + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSubfieldData[] { + + const fieldName = 'f_' + field.id; + + if (inputData[fieldName]) { + return [{ + fieldid: field.id, + value: inputData[fieldName], + }]; + } + + return []; + } + + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean { + const fieldName = 'f_' + field.id; + const input = inputData[fieldName] || ''; + const content = originalFieldData?.content || ''; + + return input != content; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (field.required && (!inputData || !inputData.length || !inputData[0].value)) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { + originalContent.content = offlineContent[''] || ''; + + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModDataFieldMenuHandler = makeSingleton(AddonModDataFieldMenuHandlerService); diff --git a/src/addons/mod/data/fields/multimenu/component/addon-mod-data-field-multimenu.html b/src/addons/mod/data/fields/multimenu/component/addon-mod-data-field-multimenu.html new file mode 100644 index 000000000..56a062c1c --- /dev/null +++ b/src/addons/mod/data/fields/multimenu/component/addon-mod-data-field-multimenu.html @@ -0,0 +1,17 @@ + + + + {{option.key}} + + + + + + {{ 'addon.mod_data.selectedrequired' | translate }} + + + + + + diff --git a/src/addons/mod/data/fields/multimenu/component/multimenu.ts b/src/addons/mod/data/fields/multimenu/component/multimenu.ts new file mode 100644 index 000000000..88d2ce183 --- /dev/null +++ b/src/addons/mod/data/fields/multimenu/component/multimenu.ts @@ -0,0 +1,70 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonModDataEntryField } from '@addons/mod/data/services/data'; +import { Component } from '@angular/core'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data multimenu field. + */ +@Component({ + selector: 'addon-mod-data-field-multimenu', + templateUrl: 'addon-mod-data-field-multimenu.html', +}) +export class AddonModDataFieldMultimenuComponent extends AddonModDataFieldPluginComponent { + + options: { + key: string; + value: string; + }[] = []; + + /** + * @inheritdoc + */ + protected init(): void { + if (this.displayMode) { + this.updateValue(this.value); + + return; + } + + this.options = this.field.param1.split(/\r?\n/).map((option) => ({ key: option, value: option })); + + const values: string[] = []; + if (this.editMode && this.value?.content) { + this.value.content.split('##').forEach((value) => { + const x = this.options.findIndex((option) => value == option.key); + if (x >= 0) { + values.push(value); + } + }); + } + + if (this.searchMode) { + this.addControl('f_' + this.field.id + '_allreq'); + } + + this.addControl('f_' + this.field.id, values); + } + + /** + * @inheritdoc + */ + protected updateValue(value?: Partial): void { + this.value = value || {}; + this.value.content = value?.content && value.content.split('##').join('
'); + } + +} diff --git a/src/addons/mod/data/fields/multimenu/multimenu.module.ts b/src/addons/mod/data/fields/multimenu/multimenu.module.ts new file mode 100644 index 000000000..3ceb09691 --- /dev/null +++ b/src/addons/mod/data/fields/multimenu/multimenu.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldMultimenuComponent } from './component/multimenu'; +import { AddonModDataFieldMultimenuHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldMultimenuComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldMultimenuHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldMultimenuComponent, + ], +}) +export class AddonModDataFieldMultimenuModule {} diff --git a/src/addons/mod/data/fields/multimenu/services/handler.ts b/src/addons/mod/data/fields/multimenu/services/handler.ts new file mode 100644 index 000000000..db3b290e7 --- /dev/null +++ b/src/addons/mod/data/fields/multimenu/services/handler.ts @@ -0,0 +1,127 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModDataEntryField, + AddonModDataField, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from '@addons/mod/data/services/data'; +import { AddonModDataFieldHandler } from '@addons/mod/data/services/data-fields-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFormFields } from '@singletons/form'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldMultimenuComponent } from '../component/multimenu'; + +/** + * Handler for multimenu data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldMultimenuHandlerService implements AddonModDataFieldHandler { + + name = 'AddonModDataFieldMultimenuHandler'; + type = 'multimenu'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldMultimenuComponent; + } + + /** + * @inheritdoc + */ + getFieldSearchData( + field: AddonModDataField, + inputData: CoreFormFields, + ): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + const fieldName = 'f_' + field.id; + const reqName = 'f_' + field.id + '_allreq'; + + if (inputData[fieldName]) { + + const values: AddonModDataSearchEntriesAdvancedFieldFormatted[] = []; + values.push({ + name: fieldName, + value: inputData[fieldName], + }); + + if (inputData[reqName]) { + values.push({ + name: reqName, + value: true, + }); + } + + return values; + } + + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSubfieldData[] { + + const fieldName = 'f_' + field.id; + + return [{ + fieldid: field.id, + value: inputData[fieldName] || [], + }]; + } + + /** + * @inheritdoc + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean { + const fieldName = 'f_' + field.id; + const content = originalFieldData?.content || ''; + + return inputData[fieldName].join('##') != content; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (field.required && (!inputData || !inputData.length || !inputData[0].value)) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { + originalContent.content = (offlineContent[''] && offlineContent[''].join('##')) || ''; + + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModDataFieldMultimenuHandler = makeSingleton(AddonModDataFieldMultimenuHandlerService); diff --git a/src/addons/mod/data/fields/number/component/addon-mod-data-field-number.html b/src/addons/mod/data/fields/number/component/addon-mod-data-field-number.html new file mode 100644 index 000000000..2bd75978b --- /dev/null +++ b/src/addons/mod/data/fields/number/component/addon-mod-data-field-number.html @@ -0,0 +1,7 @@ + + + + + + +{{ value.content }} diff --git a/src/addons/mod/data/fields/number/component/number.ts b/src/addons/mod/data/fields/number/component/number.ts new file mode 100644 index 000000000..c6e37b6e7 --- /dev/null +++ b/src/addons/mod/data/fields/number/component/number.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data number field. + */ +@Component({ + selector: 'addon-mod-data-field-number', + templateUrl: 'addon-mod-data-field-number.html', +}) +export class AddonModDataFieldNumberComponent extends AddonModDataFieldPluginComponent{ + + /** + * @inheritdoc + */ + protected init(): void { + if (this.displayMode) { + return; + } + + let value: number | string | undefined; + if (this.editMode && this.value) { + const v = parseFloat(this.value.content || ''); + value = isNaN(v) ? '' : v; + } + + this.addControl('f_' + this.field.id, value); + } + +} diff --git a/src/addons/mod/data/fields/number/number.module.ts b/src/addons/mod/data/fields/number/number.module.ts new file mode 100644 index 000000000..5dd352b23 --- /dev/null +++ b/src/addons/mod/data/fields/number/number.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldNumberComponent } from './component/number'; +import { AddonModDataFieldNumberHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldNumberComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldNumberHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldNumberComponent, + ], +}) +export class AddonModDataFieldNumberModule {} diff --git a/src/addons/mod/data/fields/number/services/handler.ts b/src/addons/mod/data/fields/number/services/handler.ts new file mode 100644 index 000000000..de5400b06 --- /dev/null +++ b/src/addons/mod/data/fields/number/services/handler.ts @@ -0,0 +1,63 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonModDataEntryField, AddonModDataField, AddonModDataSubfieldData } from '@addons/mod/data/services/data'; +import { Injectable, Type } from '@angular/core'; +import { CoreFormFields } from '@singletons/form'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldTextHandlerService } from '../../text/services/handler'; +import { AddonModDataFieldNumberComponent } from '../component/number'; + +/** + * Handler for number data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldNumberHandlerService extends AddonModDataFieldTextHandlerService { + + name = 'AddonModDataFieldNumberHandler'; + type = 'number'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldNumberComponent; + } + + /** + * @inheritdoc + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean { + const fieldName = 'f_' + field.id; + const input = inputData[fieldName] || ''; + const content = originalFieldData?.content || ''; + + return input != content; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (field.required && (!inputData || !inputData.length || inputData[0].value == '')) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + +} +export const AddonModDataFieldNumberHandler = makeSingleton(AddonModDataFieldNumberHandlerService); diff --git a/src/addons/mod/data/fields/picture/component/addon-mod-data-field-picture.html b/src/addons/mod/data/fields/picture/component/addon-mod-data-field-picture.html new file mode 100644 index 000000000..edfd06cfe --- /dev/null +++ b/src/addons/mod/data/fields/picture/component/addon-mod-data-field-picture.html @@ -0,0 +1,22 @@ + + + + + + + {{ 'addon.mod_data.alttext' | translate }} + + + + + + + + + + + + + diff --git a/src/addons/mod/data/fields/picture/component/picture.ts b/src/addons/mod/data/fields/picture/component/picture.ts new file mode 100644 index 000000000..11d19be88 --- /dev/null +++ b/src/addons/mod/data/fields/picture/component/picture.ts @@ -0,0 +1,142 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data'; +import { Component } from '@angular/core'; +import { FileEntry } from '@ionic-native/file'; +import { CoreFileSession } from '@services/file-session'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreWSExternalFile } from '@services/ws'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data picture field. + */ +@Component({ + selector: 'addon-mod-data-field-picture', + templateUrl: 'addon-mod-data-field-picture.html', +}) +export class AddonModDataFieldPictureComponent extends AddonModDataFieldPluginComponent { + + files: (CoreWSExternalFile | FileEntry)[] = []; + component?: string; + componentId?: number; + maxSizeBytes?: number; + + image?: CoreWSExternalFile | FileEntry; + entryId?: number; + imageUrl?: string; + title?: string; + width?: string; + height?: string; + + /** + * Get the files from the input value. + * + * @param value Input value. + * @return List of files. + */ + protected getFiles(value?: Partial): (CoreWSExternalFile | FileEntry)[] { + let files = value?.files || []; + + // Reduce to first element. + if (files.length > 0) { + files = [files[0]]; + } + + return files; + } + + /** + * Find file in a list. + * + * @param files File list where to search. + * @param filenameSeek Filename to search. + * @return File found or false. + */ + protected findFile( + files: (CoreWSExternalFile | FileEntry)[], + filenameSeek: string, + ): CoreWSExternalFile | FileEntry | undefined { + return files.find((file) => ('name' in file ? file.name : file.filename) == filenameSeek) || undefined; + } + + /** + * @inheritdoc + */ + protected init(): void { + if (this.searchMode) { + this.addControl('f_' + this.field.id); + + return; + } + + this.component = AddonModDataProvider.COMPONENT; + this.componentId = this.database!.coursemodule; + + this.updateValue(this.value); + + if (this.editMode) { + this.maxSizeBytes = parseInt(this.field.param3, 10); + CoreFileSession.setFiles(this.component, this.database!.id + '_' + this.field.id, this.files); + + const alttext = (this.value && this.value.content1) || ''; + this.addControl('f_' + this.field.id + '_alttext', alttext); + } + } + + /** + * @inheritdoc + */ + protected updateValue(value?: Partial): void { + + // Edit mode, the list shouldn't change so there is no need to watch it. + const files = value?.files || []; + + // Get image or thumb. + if (files.length > 0) { + const filenameSeek = this.listMode + ? 'thumb_' + value?.content + : value?.content; + this.image = this.findFile(files, filenameSeek || ''); + + if (!this.image && this.listMode) { + this.image = this.findFile(files, value?.content || ''); + } + + if (this.image) { + this.files = [this.image]; + } + } else { + this.image = undefined; + this.files = []; + } + + if (!this.editMode) { + this.entryId = (value && value.recordid) || undefined; + this.title = (value && value.content1) || ''; + this.imageUrl = undefined; + setTimeout(() => { + if (this.image) { + this.imageUrl = 'name' in this.image + ? this.image.toURL() // Is Offline. + : this.image.fileurl; + } + }, 1); + + this.width = CoreDomUtils.formatPixelsSize(this.field.param1); + this.height = CoreDomUtils.formatPixelsSize(this.field.param2); + } + } + +} diff --git a/src/addons/mod/data/fields/picture/picture.module.ts b/src/addons/mod/data/fields/picture/picture.module.ts new file mode 100644 index 000000000..86806e2f2 --- /dev/null +++ b/src/addons/mod/data/fields/picture/picture.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldPictureComponent } from './component/picture'; +import { AddonModDataFieldPictureHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldPictureComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldPictureHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldPictureComponent, + ], +}) +export class AddonModDataFieldPictureModule {} diff --git a/src/addons/mod/data/fields/picture/services/handler.ts b/src/addons/mod/data/fields/picture/services/handler.ts new file mode 100644 index 000000000..cd1c26dac --- /dev/null +++ b/src/addons/mod/data/fields/picture/services/handler.ts @@ -0,0 +1,181 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModDataEntryField, + AddonModDataField, + AddonModDataProvider, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from '@addons/mod/data/services/data'; +import { AddonModDataFieldHandler } from '@addons/mod/data/services/data-fields-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { FileEntry } from '@ionic-native/file'; +import { CoreFileSession } from '@services/file-session'; +import { CoreFormFields } from '@singletons/form'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldPictureComponent } from '../component/picture'; + +/** + * Handler for picture data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldPictureHandlerService implements AddonModDataFieldHandler { + + name = 'AddonModDataFieldPictureHandler'; + type = 'picture'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldPictureComponent; + } + + /** + * @inheritdoc + */ + getFieldSearchData( + field: AddonModDataField, + inputData: CoreFormFields, + ): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + const fieldName = 'f_' + field.id; + + if (inputData[fieldName]) { + return [{ + name: fieldName, + value: inputData[fieldName], + }]; + } + + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSubfieldData[] { + const files = this.getFieldEditFiles(field); + const fieldName = 'f_' + field.id + '_alttext'; + + return [ + { + fieldid: field.id, + subfield: 'file', + files: files, + }, + { + fieldid: field.id, + subfield: 'alttext', + value: inputData[fieldName], + }, + ]; + } + + /** + * @inheritdoc + */ + getFieldEditFiles(field: AddonModDataField): (CoreWSExternalFile | FileEntry)[] { + return CoreFileSession.getFiles(AddonModDataProvider.COMPONENT, field.dataid + '_' + field.id); + } + + /** + * @inheritdoc + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean { + const fieldName = 'f_' + field.id + '_alttext'; + const altText = inputData[fieldName] || ''; + const originalAltText = originalFieldData?.content1 || ''; + if (altText != originalAltText) { + return true; + } + + const files = this.getFieldEditFiles(field) || []; + let originalFiles = originalFieldData?.files || []; + + // Get image. + if (originalFiles.length > 0) { + const filenameSeek = originalFieldData?.content || ''; + const file = originalFiles.find((file) => ('name' in file ? file.name : file.filename) == filenameSeek); + if (file) { + originalFiles = [file]; + } + } + + return CoreFileUploader.areFileListDifferent(files, originalFiles); + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (!field.required) { + return; + } + + if (!inputData || !inputData.length) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + + const found = inputData.some((input) => { + if (typeof input.subfield != 'undefined' && input.subfield == 'file') { + return !!input.value; + } + + return false; + }); + + if (!found) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + + /** + * @inheritdoc + */ + overrideData( + originalContent: AddonModDataEntryField, + offlineContent: CoreFormFields, + offlineFiles?: FileEntry[], + ): AddonModDataEntryField { + const uploadedFilesResult: CoreFileUploaderStoreFilesResult = offlineContent?.file; + + if (uploadedFilesResult && uploadedFilesResult.offline > 0 && offlineFiles && offlineFiles?.length > 0) { + originalContent.content = offlineFiles[0].name; + originalContent.files = [offlineFiles[0]]; + } else if (uploadedFilesResult && uploadedFilesResult.online && uploadedFilesResult.online.length > 0) { + originalContent.content = uploadedFilesResult.online[0].filename || ''; + originalContent.files = [uploadedFilesResult.online[0]]; + } + + originalContent.content1 = offlineContent.alttext || ''; + + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModDataFieldPictureHandler = makeSingleton(AddonModDataFieldPictureHandlerService); diff --git a/src/addons/mod/data/fields/radiobutton/component/addon-mod-data-field-radiobutton.html b/src/addons/mod/data/fields/radiobutton/component/addon-mod-data-field-radiobutton.html new file mode 100644 index 000000000..75e003b7c --- /dev/null +++ b/src/addons/mod/data/fields/radiobutton/component/addon-mod-data-field-radiobutton.html @@ -0,0 +1,11 @@ + + + + {{ 'addon.mod_data.menuchoose' | translate }} + {{option}} + + + + +{{ value.content }} diff --git a/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts b/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts new file mode 100644 index 000000000..eae23dd3c --- /dev/null +++ b/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { Component } from '@angular/core'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data radiobutton field. + */ +@Component({ + selector: 'addon-mod-data-field-radiobutton', + templateUrl: 'addon-mod-data-field-radiobutton.html', +}) +export class AddonModDataFieldRadiobuttonComponent extends AddonModDataFieldPluginComponent { + + options: string[] = []; + + /** + * Initialize field. + */ + protected init(): void { + if (this.displayMode) { + return; + } + + this.options = this.field.param1.split('\n'); + + let val: string | undefined; + if (this.editMode && this.value) { + val = this.value.content; + } + + this.addControl('f_' + this.field.id, val); + } + +} diff --git a/src/addons/mod/data/fields/radiobutton/radiobutton.module.ts b/src/addons/mod/data/fields/radiobutton/radiobutton.module.ts new file mode 100644 index 000000000..ab535000c --- /dev/null +++ b/src/addons/mod/data/fields/radiobutton/radiobutton.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldRadiobuttonComponent } from './component/radiobutton'; +import { AddonModDataFieldRadiobuttonHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldRadiobuttonComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldRadiobuttonHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldRadiobuttonComponent, + ], +}) +export class AddonModDataFieldRadiobuttonModule {} diff --git a/src/addons/mod/data/fields/radiobutton/services/handler.ts b/src/addons/mod/data/fields/radiobutton/services/handler.ts new file mode 100644 index 000000000..98f9bc526 --- /dev/null +++ b/src/addons/mod/data/fields/radiobutton/services/handler.ts @@ -0,0 +1,114 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModDataEntryField, + AddonModDataField, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from '@addons/mod/data/services/data'; +import { AddonModDataFieldHandler } from '@addons/mod/data/services/data-fields-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFormFields } from '@singletons/form'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldRadiobuttonComponent } from '../component/radiobutton'; + +/** + * Handler for checkbox data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldRadiobuttonHandlerService implements AddonModDataFieldHandler { + + name = 'AddonModDataFieldRadiobuttonHandler'; + type = 'radiobutton'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldRadiobuttonComponent; + } + + /** + * @inheritdoc + */ + getFieldSearchData( + field: AddonModDataField, + inputData: CoreFormFields, + ): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + const fieldName = 'f_' + field.id; + if (inputData[fieldName]) { + return [{ + name: fieldName, + value: inputData[fieldName], + }]; + } + + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSubfieldData[] { + const fieldName = 'f_' + field.id; + + return [{ + fieldid: field.id, + value: inputData[fieldName] || '', + }]; + } + + /** + * @inheritdoc + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean { + const fieldName = 'f_' + field.id; + const input = inputData[fieldName] || ''; + const content = originalFieldData?.content || ''; + + return input != content; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (field.required && (!inputData || !inputData.length || !inputData[0].value)) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { + originalContent.content = offlineContent[''] || ''; + + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModDataFieldRadiobuttonHandler = makeSingleton(AddonModDataFieldRadiobuttonHandlerService); diff --git a/src/addons/mod/data/fields/text/component/addon-mod-data-field-text.html b/src/addons/mod/data/fields/text/component/addon-mod-data-field-text.html new file mode 100644 index 000000000..af610893a --- /dev/null +++ b/src/addons/mod/data/fields/text/component/addon-mod-data-field-text.html @@ -0,0 +1,7 @@ + + + + + + +{{ value.content }} diff --git a/src/addons/mod/data/fields/text/component/text.ts b/src/addons/mod/data/fields/text/component/text.ts new file mode 100644 index 000000000..116458c5b --- /dev/null +++ b/src/addons/mod/data/fields/text/component/text.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data text field. + */ +@Component({ + selector: 'addon-mod-data-field-text', + templateUrl: 'addon-mod-data-field-text.html', +}) +export class AddonModDataFieldTextComponent extends AddonModDataFieldPluginComponent { + + /** + * @inheritdoc + */ + protected init(): void { + if (this.displayMode) { + return; + } + + let value: string | undefined; + if (this.editMode && this.value) { + value = this.value.content; + } + + this.addControl('f_' + this.field.id, value); + } + +} diff --git a/src/addons/mod/data/fields/text/services/handler.ts b/src/addons/mod/data/fields/text/services/handler.ts new file mode 100644 index 000000000..8c9ba3b92 --- /dev/null +++ b/src/addons/mod/data/fields/text/services/handler.ts @@ -0,0 +1,117 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AddonModDataEntryField, + AddonModDataField, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from '@addons/mod/data/services/data'; +import { AddonModDataFieldHandler } from '@addons/mod/data/services/data-fields-delegate'; +import { Injectable, Type } from '@angular/core'; +import { CoreFormFields } from '@singletons/form'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldTextComponent } from '../component/text'; + +/** + * Handler for number data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHandler { + + name = 'AddonModDataFieldTextHandler'; + type = 'text'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldTextComponent; + } + + /** + * @inheritdoc + */ + getFieldSearchData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + const fieldName = 'f_' + field.id; + + if (inputData[fieldName]) { + return [{ + name: fieldName, + value: inputData[fieldName], + }]; + } + + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, // eslint-disable-line @typescript-eslint/no-unused-vars + ): AddonModDataSubfieldData[] { + + const fieldName = 'f_' + field.id; + + return [{ + fieldid: field.id, + value: inputData[fieldName] || '', + }]; + } + + /** + * @inheritdoc + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean { + const fieldName = 'f_' + field.id; + const input = inputData[fieldName] || ''; + const content = originalFieldData?.content || ''; + + return input != content; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (field.required && (!inputData || !inputData.length || !inputData[0].value)) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { + originalContent.content = offlineContent[''] || ''; + + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} +export const AddonModDataFieldTextHandler = makeSingleton(AddonModDataFieldTextHandlerService); diff --git a/src/addons/mod/data/fields/text/text.module.ts b/src/addons/mod/data/fields/text/text.module.ts new file mode 100644 index 000000000..890f6b9c3 --- /dev/null +++ b/src/addons/mod/data/fields/text/text.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldTextComponent } from './component/text'; +import { AddonModDataFieldTextHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldTextComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldTextHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldTextComponent, + ], +}) +export class AddonModDataFieldTextModule {} diff --git a/src/addons/mod/data/fields/textarea/component/addon-mod-data-field-textarea.html b/src/addons/mod/data/fields/textarea/component/addon-mod-data-field-textarea.html new file mode 100644 index 000000000..e6d25010d --- /dev/null +++ b/src/addons/mod/data/fields/textarea/component/addon-mod-data-field-textarea.html @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/addons/mod/data/fields/textarea/component/textarea.ts b/src/addons/mod/data/fields/textarea/component/textarea.ts new file mode 100644 index 000000000..8b459b8ef --- /dev/null +++ b/src/addons/mod/data/fields/textarea/component/textarea.ts @@ -0,0 +1,65 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; +import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreWSExternalFile } from '@services/ws'; + +/** + * Component to render data number field. + */ +@Component({ + selector: 'addon-mod-data-field-textarea', + templateUrl: 'addon-mod-data-field-textarea.html', +}) +export class AddonModDataFieldTextareaComponent extends AddonModDataFieldPluginComponent { + + component?: string; + componentId?: number; + + /** + * Format value to be shown. Replacing plugin file Urls. + * + * @param value Value to replace. + * @return Replaced string to be rendered. + */ + format(value?: Partial): string { + const files: CoreWSExternalFile[] = (value && value.files) || []; + + return value ? CoreTextUtils.replacePluginfileUrls(value.content || '', files) : ''; + } + + /** + * Initialize field. + */ + protected init(): void { + this.component = AddonModDataProvider.COMPONENT; + this.componentId = this.database?.coursemodule; + + if (this.displayMode) { + return; + } + + let text: string | undefined; + // Check if rich text editor is enabled. + if (this.editMode) { + text = this.format(this.value); + } + + this.addControl('f_' + this.field.id, text); + } + +} diff --git a/src/addons/mod/data/fields/textarea/services/handler.ts b/src/addons/mod/data/fields/textarea/services/handler.ts new file mode 100644 index 000000000..2bfc9b85f --- /dev/null +++ b/src/addons/mod/data/fields/textarea/services/handler.ts @@ -0,0 +1,127 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonModDataEntryField, AddonModDataField, AddonModDataSubfieldData } from '@addons/mod/data/services/data'; +import { Injectable, Type } from '@angular/core'; +import { FileEntry } from '@ionic-native/file'; +import { CoreFormFields } from '@singletons/form'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModDataFieldTextHandlerService } from '../../text/services/handler'; +import { AddonModDataFieldTextareaComponent } from '../component/textarea'; + +/** + * Handler for textarea data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldTextareaHandlerService extends AddonModDataFieldTextHandlerService { + + name = 'AddonModDataFieldTextareaHandler'; + type = 'textarea'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldTextareaComponent; + } + + /** + * @inheritdoc + */ + getFieldEditData( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): AddonModDataSubfieldData[] { + const fieldName = 'f_' + field.id; + const files = this.getFieldEditFiles(field, inputData, originalFieldData); + + let text = CoreTextUtils.restorePluginfileUrls(inputData[fieldName] || '', files); + // Add some HTML to the text if needed. + text = CoreTextUtils.formatHtmlLines(text); + + // WS does not properly check if HTML content is blank when the field is required. + if (CoreTextUtils.htmlIsBlank(text)) { + text = ''; + } + + return [ + { + fieldid: field.id, + value: text, + }, + { + fieldid: field.id, + subfield: 'content1', + value: 1, + }, + { + fieldid: field.id, + subfield: 'itemid', + files: files, + }, + ]; + } + + /** + * @inheritdoc + */ + getFieldEditFiles( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): (CoreWSExternalFile | FileEntry)[] { + return (originalFieldData && originalFieldData.files) || []; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (!field.required) { + return; + } + + if (!inputData || !inputData.length) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + + const value = inputData.find((value) => value.subfield == ''); + + if (!value || CoreTextUtils.htmlIsBlank(value.value || '')) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { + originalContent.content = offlineContent[''] || ''; + if (originalContent.content.length > 0 && originalContent.files && originalContent.files.length > 0) { + // Take the original files since we cannot edit them on the app. + originalContent.content = CoreTextUtils.replacePluginfileUrls( + originalContent.content, + originalContent.files, + ); + } + + return originalContent; + } + +} +export const AddonModDataFieldTextareaHandler = makeSingleton(AddonModDataFieldTextareaHandlerService); diff --git a/src/addons/mod/data/fields/textarea/textarea.module.ts b/src/addons/mod/data/fields/textarea/textarea.module.ts new file mode 100644 index 000000000..7ee21e9be --- /dev/null +++ b/src/addons/mod/data/fields/textarea/textarea.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldTextareaComponent } from './component/textarea'; +import { AddonModDataFieldTextareaHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldTextareaComponent, + ], + imports: [ + CoreSharedModule, + CoreEditorComponentsModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldTextareaHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldTextareaComponent, + ], +}) +export class AddonModDataFieldTextareaModule {} diff --git a/src/addons/mod/data/fields/url/component/addon-mod-data-field-url.html b/src/addons/mod/data/fields/url/component/addon-mod-data-field-url.html new file mode 100644 index 000000000..059d0ceab --- /dev/null +++ b/src/addons/mod/data/fields/url/component/addon-mod-data-field-url.html @@ -0,0 +1,10 @@ + + + + + + + + {{ displayValue }} + {{ displayValue }} + diff --git a/src/addons/mod/data/fields/url/component/url.ts b/src/addons/mod/data/fields/url/component/url.ts new file mode 100644 index 000000000..0cde594db --- /dev/null +++ b/src/addons/mod/data/fields/url/component/url.ts @@ -0,0 +1,78 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { AddonModDataEntryField } from '@addons/mod/data/services/data'; +import { Component } from '@angular/core'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data url field. + */ +@Component({ + selector: 'addon-mod-data-field-url', + templateUrl: 'addon-mod-data-field-url.html', +}) +export class AddonModDataFieldUrlComponent extends AddonModDataFieldPluginComponent { + + autoLink = false; + displayValue = ''; + + /** + * @inheritdoc + */ + protected init(): void { + if (this.displayMode) { + return; + } + + let value: string | undefined; + if (this.editMode && this.value) { + value = this.value.content; + } + + this.addControl('f_' + this.field.id, value); + } + + /** + * Calculate data for show or list mode. + */ + protected calculateShowListData(): void { + if (!this.value || !this.value.content) { + return; + } + + const url = this.value.content; + const text = this.field.param2 || this.value.content1; // Param2 forces the text to display. + + this.autoLink = parseInt(this.field.param1, 10) === 1; + + if (this.autoLink) { + this.displayValue = text || url; + } else { + // No auto link, always display the URL. + this.displayValue = url; + } + } + + /** + * @inheritdoc + */ + protected updateValue(value?: Partial): void { + super.updateValue(value); + + if (this.displayMode) { + this.calculateShowListData(); + } + } + +} diff --git a/src/addons/mod/data/fields/url/services/handler.ts b/src/addons/mod/data/fields/url/services/handler.ts new file mode 100644 index 000000000..77ecbca8d --- /dev/null +++ b/src/addons/mod/data/fields/url/services/handler.ts @@ -0,0 +1,63 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AddonModDataField, AddonModDataSubfieldData } from '@addons/mod/data/services/data'; +import { Injectable, Type } from '@angular/core'; +import { CoreFormFields } from '@singletons/form'; +import { Translate, makeSingleton } from '@singletons'; +import { AddonModDataFieldTextHandlerService } from '../../text/services/handler'; +import { AddonModDataFieldUrlComponent } from '../component/url'; + +/** + * Handler for url data field plugin. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldUrlHandlerService extends AddonModDataFieldTextHandlerService { + + name = 'AddonModDataFieldUrlHandler'; + type = 'url'; + + /** + * @inheritdoc + */ + getComponent(): Type{ + return AddonModDataFieldUrlComponent; + } + + /** + * @inheritdoc + */ + getFieldEditData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSubfieldData[] { + const fieldName = 'f_' + field.id; + + return [ + { + fieldid: field.id, + subfield: '0', + value: (inputData[fieldName] && inputData[fieldName].trim()) || '', + }, + ]; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + if (field.required && (!inputData || !inputData.length || !inputData[0].value)) { + return Translate.instant('addon.mod_data.errormustsupplyvalue'); + } + } + +} +export const AddonModDataFieldUrlHandler = makeSingleton(AddonModDataFieldUrlHandlerService); diff --git a/src/addons/mod/data/fields/url/url.module.ts b/src/addons/mod/data/fields/url/url.module.ts new file mode 100644 index 000000000..99d8250e5 --- /dev/null +++ b/src/addons/mod/data/fields/url/url.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, APP_INITIALIZER } from '@angular/core'; +import { AddonModDataFieldsDelegate } from '../../services/data-fields-delegate'; +import { AddonModDataFieldUrlComponent } from './component/url'; +import { AddonModDataFieldUrlHandler } from './services/handler'; + +@NgModule({ + declarations: [ + AddonModDataFieldUrlComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModDataFieldsDelegate.registerHandler(AddonModDataFieldUrlHandler.instance); + }, + }, + ], + exports: [ + AddonModDataFieldUrlComponent, + ], +}) +export class AddonModDataFieldUrlModule {} diff --git a/src/addons/mod/data/lang.json b/src/addons/mod/data/lang.json new file mode 100644 index 000000000..920d6f014 --- /dev/null +++ b/src/addons/mod/data/lang.json @@ -0,0 +1,50 @@ +{ + "addentries": "Add entries", + "advancedsearch": "Advanced search", + "alttext": "Alternative text", + "approve": "Approve", + "approved": "Approved", + "ascending": "Ascending", + "authorfirstname": "Author first name", + "authorlastname": "Author surname", + "confirmdeleterecord": "Are you sure you want to delete this entry?", + "descending": "Descending", + "disapprove": "Undo approval", + "edittagsnotsupported": "Sorry, editing tags is not supported by the app.", + "emptyaddform": "You did not fill out any fields!", + "entrieslefttoadd": "You must add {{$a.entriesleft}} more entry/entries in order to complete this activity", + "entrieslefttoaddtoview": "You must add {{$a.entrieslefttoview}} more entry/entries before you can view other participants' entries.", + "errorapproving": "Error approving or unapproving entry.", + "errordeleting": "Error deleting entry.", + "errormustsupplyvalue": "You must supply a value here.", + "expired": "Sorry, this activity closed on {{$a}} and is no longer available", + "fields": "Fields", + "foundrecords": "Found records: {{$a.num}}/{{$a.max}} (Reset filters)", + "gettinglocation": "Getting location", + "latlongboth": "Both latitude and longitude are required.", + "locationpermissiondenied": "Permission to access your location has been denied.", + "locationnotenabled": "Location is not enabled", + "menuchoose": "Choose...", + "modulenameplural": "Databases", + "more": "More", + "mylocation": "My location", + "noaccess": "You do not have access to this page", + "nomatch": "No matching entries found!", + "norecords": "No entries in database", + "notapproved": "Entry is not approved yet.", + "notopenyet": "Sorry, this activity is not available until {{$a}}", + "numrecords": "{{$a}} entries", + "other": "Other", + "recordapproved": "Entry approved", + "recorddeleted": "Entry deleted", + "recorddisapproved": "Entry unapproved", + "resetsettings": "Reset filters", + "search": "Search", + "searchbytagsnotsupported": "Sorry, searching by tags is not supported by the app.", + "selectedrequired": "All selected required", + "single": "View single", + "tagarea_data_records": "Data records", + "timeadded": "Time added", + "timemodified": "Time modified", + "usedate": "Include in search." +} \ No newline at end of file diff --git a/src/addons/mod/data/pages/edit/edit.html b/src/addons/mod/data/pages/edit/edit.html new file mode 100644 index 000000000..a4123f62c --- /dev/null +++ b/src/addons/mod/data/pages/edit/edit.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + {{ 'core.save' | translate }} + + + + + + + + + {{ 'core.groupsvisible' | translate }} + {{ 'core.groupsseparate' | translate }} + + + + {{groupOpt.name}} + + + + +
+ + +
+ +
+
+
+
diff --git a/src/addons/mod/data/pages/edit/edit.ts b/src/addons/mod/data/pages/edit/edit.ts new file mode 100644 index 000000000..e3becd365 --- /dev/null +++ b/src/addons/mod/data/pages/edit/edit.ts @@ -0,0 +1,451 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, ViewChild, ElementRef, Type } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { CoreError } from '@classes/errors/error'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreTag } from '@features/tag/services/tag'; +import { IonContent } from '@ionic/angular'; +import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreForms } from '@singletons/form'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModDataComponentsCompileModule } from '../../components/components-compile.module'; +import { + AddonModDataData, + AddonModDataField, + AddonModDataProvider, + AddonModData, + AddonModDataTemplateType, + AddonModDataEntry, + AddonModDataEntryFields, + AddonModDataEditEntryResult, + AddonModDataAddEntryResult, + AddonModDataEntryWSField, +} from '../../services/data'; +import { AddonModDataHelper } from '../../services/data-helper'; + +/** + * Page that displays the view edit page. + */ +@Component({ + selector: 'page-addon-mod-data-edit', + templateUrl: 'edit.html', + styleUrls: ['../../data.scss', '../../data-forms.scss'], +}) +export class AddonModDataEditPage implements OnInit { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild('editFormEl') formElement!: ElementRef; + + protected entryId?: number; + protected fieldsArray: AddonModDataField[] = []; + protected siteId: string; + protected offline = false; + protected forceLeave = false; // To allow leaving the page without checking for changes. + protected initialSelectedGroup?: number; + protected isEditing = false; + + entry?: AddonModDataEntry; + fields: Record = {}; + courseId!: number; + module!: CoreCourseModule; + database?: AddonModDataData; + title = ''; + component = AddonModDataProvider.COMPONENT; + loaded = false; + selectedGroup = 0; + cssClass = ''; + groupInfo?: CoreGroupInfo; + editFormRender = ''; + editForm: FormGroup; + extraImports: Type[] = [AddonModDataComponentsCompileModule]; + jsData? : { + fields: Record; + database?: AddonModDataData; + contents: AddonModDataEntryFields; + errors?: Record; + form: FormGroup; + }; + + errors: Record = {}; + + constructor() { + this.siteId = CoreSites.getCurrentSiteId(); + this.editForm = new FormGroup({}); + } + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.module = CoreNavigator.getRouteParam('module')!; + this.entryId = CoreNavigator.getRouteNumberParam('entryId') || undefined; + this.courseId = CoreNavigator.getRouteNumberParam('courseId')!; + this.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0; + + // If entryId is lower than 0 or null, it is a new entry or an offline entry. + this.isEditing = typeof this.entryId != 'undefined' && this.entryId > 0; + + this.title = this.module.name; + + this.fetchEntryData(true); + } + + /** + * Check if we can leave the page or not and ask to confirm the lost of data. + * + * @return True if we can leave, false otherwise. + */ + async canLeave(): Promise { + if (this.forceLeave || !this.entry) { + return true; + } + + const inputData = this.editForm.value; + + let changed = AddonModDataHelper.hasEditDataChanged(inputData, this.fieldsArray, this.entry.contents); + changed = changed || (!this.isEditing && this.initialSelectedGroup != this.selectedGroup); + + if (changed) { + // Show confirmation if some data has been modified. + await CoreDomUtils.showConfirm(Translate.instant('coentryre.confirmcanceledit')); + } + + // Delete the local files from the tmp folder. + const files = await AddonModDataHelper.getEditTmpFiles(inputData, this.fieldsArray, this.entry!.contents); + CoreFileUploader.clearTmpFiles(files); + + CoreForms.triggerFormCancelledEvent(this.formElement, this.siteId); + + return true; + } + + /** + * Fetch the entry data. + * + * @param refresh To refresh all downloaded data. + * @return Resolved when done. + */ + protected async fetchEntryData(refresh = false): Promise { + try { + this.database = await AddonModData.getDatabase(this.courseId, this.module.id); + this.title = this.database.name || this.title; + this.cssClass = 'addon-data-entries-' + this.database.id; + + this.fieldsArray = await AddonModData.getFields(this.database.id, { cmId: this.module.id }); + this.fields = CoreUtils.arrayToObject(this.fieldsArray, 'id'); + + const entry = await AddonModDataHelper.fetchEntry(this.database, this.fieldsArray, this.entryId || 0); + this.entry = entry.entry; + + // Load correct group. + this.selectedGroup = this.entry.groupid; + + // Check permissions when adding a new entry or offline entry. + if (!this.isEditing) { + let haveAccess = false; + + if (refresh) { + this.groupInfo = await CoreGroups.getActivityGroupInfo(this.database.coursemodule); + this.selectedGroup = CoreGroups.validateGroupId(this.selectedGroup, this.groupInfo); + this.initialSelectedGroup = this.selectedGroup; + } + + if (this.groupInfo?.groups && this.groupInfo.groups.length > 0) { + if (refresh) { + const canAddGroup: Record = {}; + + await Promise.all(this.groupInfo.groups.map(async (group) => { + const accessData = await AddonModData.getDatabaseAccessInformation(this.database!.id, { + cmId: this.module.id, groupId: group.id }); + + canAddGroup[group.id] = accessData.canaddentry; + })); + + this.groupInfo.groups = this.groupInfo.groups.filter((group) => !!canAddGroup[group.id]); + + haveAccess = canAddGroup[this.selectedGroup]; + } else { + // Groups already filtered, so it have access. + haveAccess = true; + } + } else { + const accessData = await AddonModData.getDatabaseAccessInformation(this.database.id, { cmId: this.module.id }); + haveAccess = accessData.canaddentry; + } + + if (!haveAccess) { + // You shall not pass, go back. + CoreDomUtils.showErrorModal('addon.mod_data.noaccess', true); + + // Go back to entry list. + this.forceLeave = true; + CoreNavigator.back(); + + return; + } + } + + this.editFormRender = this.displayEditFields(); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } + + this.loaded = true; + } + + /** + * Saves data. + * + * @param e Event. + * @return Resolved when done. + */ + async save(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + const inputData = this.editForm.value; + + try { + let changed = AddonModDataHelper.hasEditDataChanged( + inputData, + this.fieldsArray, + this.entry?.contents || {}, + ); + + changed = changed || (!this.isEditing && this.initialSelectedGroup != this.selectedGroup); + if (!changed) { + if (this.entryId) { + await this.returnToEntryList(); + + return; + } + + // New entry, no changes means no field filled, warn the user. + throw new CoreError(Translate.instant('addon.mod_data.emptyaddform')); + } + + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + // Create an ID to assign files. + const entryTemp = this.entryId ? this.entryId : - (new Date().getTime()); + let editData: AddonModDataEntryWSField[] = []; + + try { + try { + editData = await AddonModDataHelper.getEditDataFromForm( + inputData, + this.fieldsArray, + this.database!.id, + entryTemp, + this.entry?.contents || {}, + this.offline, + ); + } catch (error) { + if (this.offline) { + throw error; + } + // Cannot submit in online, prepare for offline usage. + this.offline = true; + + editData = await AddonModDataHelper.getEditDataFromForm( + inputData, + this.fieldsArray, + this.database!.id, + entryTemp, + this.entry?.contents || {}, + this.offline, + ); + } + + if (editData.length <= 0) { + // No field filled, warn the user. + throw new CoreError(Translate.instant('addon.mod_data.emptyaddform')); + } + + let updateEntryResult: AddonModDataEditEntryResult | AddonModDataAddEntryResult | undefined; + if (this.isEditing) { + updateEntryResult = await AddonModData.editEntry( + this.database!.id, + this.entryId!, + this.courseId, + editData, + this.fieldsArray, + this.siteId, + this.offline, + ); + } else { + updateEntryResult = await AddonModData.addEntry( + this.database!.id, + entryTemp, + this.courseId, + editData, + this.selectedGroup, + this.fieldsArray, + this.siteId, + this.offline, + ); + } + + // This is done if entry is updated when editing or creating if not. + if ((this.isEditing && 'updated' in updateEntryResult && updateEntryResult.updated) || + (!this.isEditing && 'newentryid' in updateEntryResult && updateEntryResult.newentryid)) { + + CoreForms.triggerFormSubmittedEvent(this.formElement, updateEntryResult.sent, this.siteId); + + const promises: Promise[] = []; + + if (updateEntryResult.sent) { + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'data' }); + + if (this.isEditing) { + promises.push(AddonModData.invalidateEntryData(this.database!.id, this.entryId!, this.siteId)); + } + promises.push(AddonModData.invalidateEntriesData(this.database!.id, this.siteId)); + } + + try { + await Promise.all(promises); + CoreEvents.trigger( + AddonModDataProvider.ENTRY_CHANGED, + { dataId: this.database!.id, entryId: this.entryId }, + + this.siteId, + ); + } finally { + this.returnToEntryList(); + } + } else { + this.errors = {}; + if (updateEntryResult.fieldnotifications) { + updateEntryResult.fieldnotifications.forEach((fieldNotif) => { + const field = this.fieldsArray.find((field) => field.name == fieldNotif.fieldname); + if (field) { + this.errors[field.id] = fieldNotif.notification; + } + }); + } + this.jsData!.errors = this.errors; + + setTimeout(() => { + this.scrollToFirstError(); + }); + } + } finally { + modal.dismiss(); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Cannot edit entry', true); + } + } + + /** + * Set group to see the database. + * + * @param groupId Group identifier to set. + * @return Resolved when done. + */ + setGroup(groupId: number): Promise { + this.selectedGroup = groupId; + this.loaded = false; + + return this.fetchEntryData(); + } + + /** + * Displays Edit Search Fields. + * + * @return Generated HTML. + */ + protected displayEditFields(): string { + this.jsData = { + fields: this.fields, + contents: CoreUtils.clone(this.entry?.contents) || {}, + form: this.editForm, + database: this.database, + errors: this.errors, + }; + + let template = AddonModDataHelper.getTemplate(this.database!, AddonModDataTemplateType.ADD, this.fieldsArray); + + // Replace the fields found on template. + this.fieldsArray.forEach((field) => { + let replace = '[[' + field.name + ']]'; + replace = replace.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); + let replaceRegEx = new RegExp(replace, 'gi'); + + // Replace field by a generic directive. + const render = ''; + template = template.replace(replaceRegEx, render); + + // Replace the field id tag. + replace = '[[' + field.name + '#id]]'; + replace = replace.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); + replaceRegEx = new RegExp(replace, 'gi'); + + template = template.replace(replaceRegEx, 'field_' + field.id); + }); + + // Editing tags is not supported. + const replaceRegEx = new RegExp('##tags##', 'gi'); + const message = CoreTag.areTagsAvailableInSite() + ? '

{{ \'addon.mod_data.edittagsnotsupported\' | translate }}

' + : ''; + template = template.replace(replaceRegEx, message); + + return template; + } + + /** + * Return to the entry list (previous page) discarding temp data. + * + * @return Resolved when done. + */ + protected async returnToEntryList(): Promise { + const inputData = this.editForm.value; + + try { + const files = await AddonModDataHelper.getEditTmpFiles( + inputData, + this.fieldsArray, + this.entry?.contents || {}, + ); + + CoreFileUploader.clearTmpFiles(files); + } finally { + // Go back to entry list. + this.forceLeave = true; + CoreNavigator.back(); + } + } + + /** + * Scroll to first error or to the top if not found. + */ + protected scrollToFirstError(): void { + if (!CoreDomUtils.scrollToElementBySelector(this.formElement.nativeElement, this.content, '.addon-data-error')) { + this.content?.scrollToTop(); + } + } + +} diff --git a/src/addons/mod/data/pages/entry/entry.html b/src/addons/mod/data/pages/entry/entry.html new file mode 100644 index 000000000..4dcfe87e7 --- /dev/null +++ b/src/addons/mod/data/pages/entry/entry.html @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + + {{ 'core.groupsvisible' | translate }} + {{ 'core.groupsseparate' | translate }} + + + + {{groupOpt.name}} + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + {{ 'core.previous' | translate }} + + + + + {{ 'core.next' | translate }} + + + + + +
+
diff --git a/src/addons/mod/data/pages/entry/entry.ts b/src/addons/mod/data/pages/entry/entry.ts new file mode 100644 index 000000000..7e3f89c8f --- /dev/null +++ b/src/addons/mod/data/pages/entry/entry.ts @@ -0,0 +1,414 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnDestroy, ViewChild, ChangeDetectorRef, OnInit, Type } from '@angular/core'; +import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments'; +import { CoreComments } from '@features/comments/services/comments'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreRatingInfo } from '@features/rating/services/rating'; +import { IonContent, IonRefresher } from '@ionic/angular'; +import { CoreGroups, CoreGroupInfo } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { AddonModDataComponentsCompileModule } from '../../components/components-compile.module'; +import { AddonModDataProvider, + AddonModData, + AddonModDataData, + AddonModDataGetDataAccessInformationWSResponse, + AddonModDataField, + AddonModDataTemplateType, + AddonModDataTemplateMode, + AddonModDataEntry, +} from '../../services/data'; +import { AddonModDataHelper } from '../../services/data-helper'; +import { AddonModDataSyncProvider } from '../../services/data-sync'; + +/** + * Page that displays the view entry page. + */ +@Component({ + selector: 'page-addon-mod-data-entry', + templateUrl: 'entry.html', + styleUrls: ['../../data.scss'], +}) +export class AddonModDataEntryPage implements OnInit, OnDestroy { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild(CoreCommentsCommentsComponent) comments?: CoreCommentsCommentsComponent; + + protected entryId?: number; + protected syncObserver: CoreEventObserver; // It will observe the sync auto event. + protected entryChangedObserver: CoreEventObserver; // It will observe the changed entry event. + protected fields: Record = {}; + protected fieldsArray: AddonModDataField[] = []; + + module!: CoreCourseModule; + courseId!: number; + offset?: number; + title = ''; + moduleName = 'data'; + component = AddonModDataProvider.COMPONENT; + entryLoaded = false; + renderingEntry = false; + loadingComments = false; + loadingRating = false; + selectedGroup = 0; + entry?: AddonModDataEntry; + hasPrevious = false; + hasNext = false; + access?: AddonModDataGetDataAccessInformationWSResponse; + database?: AddonModDataData; + groupInfo?: CoreGroupInfo; + showComments = false; + entryHtml = ''; + siteId: string; + extraImports: Type[] = [AddonModDataComponentsCompileModule]; + jsData? : { + fields: Record; + entries: Record; + database: AddonModDataData; + module: CoreCourseModule; + group: number; + }; + + ratingInfo?: CoreRatingInfo; + isPullingToRefresh = false; // Whether the last fetching of data was started by a pull-to-refresh action + commentsEnabled = false; + + constructor( + private cdr: ChangeDetectorRef, + ) { + this.moduleName = CoreCourse.translateModuleName('data'); + this.siteId = CoreSites.getCurrentSiteId(); + + // Refresh data if this discussion is synchronized automatically. + this.syncObserver = CoreEvents.on(AddonModDataSyncProvider.AUTO_SYNCED, (data) => { + if (typeof data.entryId == 'undefined') { + return; + } + + if ((data.entryId == this.entryId || data.offlineEntryId == this.entryId) && this.database?.id == data.dataId) { + if (data.deleted) { + // If deleted, go back. + CoreNavigator.back(); + } else { + this.entryId = data.entryId; + this.entryLoaded = false; + this.fetchEntryData(true); + } + } + }, this.siteId); + + // Refresh entry on change. + this.entryChangedObserver = CoreEvents.on(AddonModDataProvider.ENTRY_CHANGED, (data) => { + if (data.entryId == this.entryId && this.database?.id == data.dataId) { + if (data.deleted) { + // If deleted, go back. + CoreNavigator.back(); + } else { + this.entryLoaded = false; + this.fetchEntryData(true); + } + } + }, this.siteId); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.module = CoreNavigator.getRouteParam('module')!; + this.entryId = CoreNavigator.getRouteNumberParam('entryId') || undefined; + this.courseId = CoreNavigator.getRouteNumberParam('courseId')!; + this.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0; + this.offset = CoreNavigator.getRouteNumberParam('offset'); + this.title = this.module.name; + + this.commentsEnabled = !CoreComments.areCommentsDisabledInSite(); + + await this.fetchEntryData(); + this.logView(); + } + + /** + * Fetch the entry data. + * + * @param refresh Whether to refresh the current data or not. + * @param isPtr Whether is a pull to refresh action. + * @return Resolved when done. + */ + protected async fetchEntryData(refresh = false, isPtr = false): Promise { + this.isPullingToRefresh = isPtr; + + try { + this.database = await AddonModData.getDatabase(this.courseId, this.module.id); + this.title = this.database.name || this.title; + + this.fieldsArray = await AddonModData.getFields(this.database.id, { cmId: this.module.id }); + this.fields = CoreUtils.arrayToObject(this.fieldsArray, 'id'); + + await this.setEntryFromOffset(); + + this.access = await AddonModData.getDatabaseAccessInformation(this.database.id, { cmId: this.module.id }); + + this.groupInfo = await CoreGroups.getActivityGroupInfo(this.database.coursemodule); + this.selectedGroup = CoreGroups.validateGroupId(this.selectedGroup, this.groupInfo); + + const actions = AddonModDataHelper.getActions(this.database, this.access, this.entry!); + + const template = AddonModDataHelper.getTemplate(this.database, AddonModDataTemplateType.SINGLE, this.fieldsArray); + this.entryHtml = AddonModDataHelper.displayShowFields( + template, + this.fieldsArray, + this.entry!, + this.offset, + AddonModDataTemplateMode.SHOW, + actions, + ); + + this.showComments = actions.comments; + + const entries: Record = {}; + entries[this.entryId!] = this.entry!; + + // Pass the input data to the component. + this.jsData = { + fields: this.fields, + entries: entries, + database: this.database, + module: this.module, + group: this.selectedGroup, + }; + } catch (error) { + if (!refresh) { + // Some call failed, retry without using cache since it might be a new activity. + return this.refreshAllData(isPtr); + } + + CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } finally { + this.content?.scrollToTop(); + this.entryLoaded = true; + } + } + + /** + * Go to selected entry without changing state. + * + * @param offset Entry offset. + * @return Resolved when done. + */ + async gotoEntry(offset: number): Promise { + this.offset = offset; + this.entryId = undefined; + this.entry = undefined; + this.entryLoaded = false; + + await this.fetchEntryData(); + this.logView(); + } + + /** + * Refresh all the data. + * + * @param isPtr Whether is a pull to refresh action. + * @return Promise resolved when done. + */ + protected async refreshAllData(isPtr?: boolean): Promise { + const promises: Promise[] = []; + + promises.push(AddonModData.invalidateDatabaseData(this.courseId)); + if (this.database) { + promises.push(AddonModData.invalidateEntryData(this.database.id, this.entryId!)); + promises.push(CoreGroups.invalidateActivityGroupInfo(this.database.coursemodule)); + promises.push(AddonModData.invalidateEntriesData(this.database.id)); + promises.push(AddonModData.invalidateFieldsData(this.database.id)); + + if (this.database.comments && this.entry && this.entry.id > 0 && this.commentsEnabled && this.comments) { + // Refresh comments. Don't add it to promises because we don't want the comments fetch to block the entry fetch. + this.comments.doRefresh().catch(() => { + // Ignore errors. + }); + } + } + + await Promise.all(promises).finally(() => + this.fetchEntryData(true, isPtr)); + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @return Promise resolved when done. + */ + refreshDatabase(refresher?: IonRefresher): void { + if (!this.entryLoaded) { + return; + } + + this.refreshAllData(true).finally(() => { + refresher?.complete(); + }); + } + + /** + * Set group to see the database. + * + * @param groupId Group identifier to set. + * @return Resolved when done. + */ + async setGroup(groupId: number): Promise { + this.selectedGroup = groupId; + this.offset = undefined; + this.entry = undefined; + this.entryId = undefined; + this.entryLoaded = false; + + await this.fetchEntryData(); + this.logView(); + } + + /** + * Convenience function to fetch the entry and set next/previous entries. + * + * @return Resolved when done. + */ + protected async setEntryFromOffset(): Promise { + if (typeof this.offset == 'undefined' && typeof this.entryId != 'undefined') { + // Entry id passed as navigation parameter instead of the offset. + // We don't display next/previous buttons in this case. + this.hasNext = false; + this.hasPrevious = false; + + const entry = await AddonModDataHelper.fetchEntry(this.database!, this.fieldsArray, this.entryId); + this.entry = entry.entry; + this.ratingInfo = entry.ratinginfo; + + return; + } + + const perPage = AddonModDataProvider.PER_PAGE; + const page = typeof this.offset != 'undefined' && this.offset >= 0 + ? Math.floor(this.offset / perPage) + : 0; + + const entries = await AddonModDataHelper.fetchEntries(this.database!, this.fieldsArray, { + groupId: this.selectedGroup, + sort: 0, + order: 'DESC', + page, + perPage, + }); + + const pageEntries = (entries.offlineEntries || []).concat(entries.entries); + + // Index of the entry when concatenating offline and online page entries. + let pageIndex = 0; + if (typeof this.offset == 'undefined') { + // No offset passed, display the first entry. + pageIndex = 0; + } else if (this.offset > 0) { + // Online entry. + pageIndex = this.offset % perPage + (entries.offlineEntries?.length || 0); + } else { + // Offline entry. + pageIndex = this.offset + (entries.offlineEntries?.length || 0); + } + + this.entry = pageEntries[pageIndex]; + this.entryId = this.entry.id; + + this.hasPrevious = page > 0 || pageIndex > 0; + + if (pageIndex + 1 < pageEntries.length) { + // Not the last entry on the page; + this.hasNext = true; + } else if (pageEntries.length < perPage) { + // Last entry of the last page. + this.hasNext = false; + } else { + // Last entry of the page, check if there are more pages. + const entries = await AddonModData.getEntries(this.database!.id, { + groupId: this.selectedGroup, + page: page + 1, + perPage: perPage, + }); + this.hasNext = entries?.entries?.length > 0; + } + + if (this.entryId > 0) { + // Online entry, we need to fetch the the rating info. + const entry = await AddonModData.getEntry(this.database!.id, this.entryId, { cmId: this.module.id }); + this.ratingInfo = entry.ratinginfo; + } + } + + /** + * Function called when entry is being rendered. + */ + setRenderingEntry(rendering: boolean): void { + this.renderingEntry = rendering; + this.cdr.detectChanges(); + } + + /** + * Function called when comments component is loading data. + */ + setLoadingComments(loading: boolean): void { + this.loadingComments = loading; + this.cdr.detectChanges(); + } + + /** + * Function called when rate component is loading data. + */ + setLoadingRating(loading: boolean): void { + this.loadingRating = loading; + this.cdr.detectChanges(); + } + + /** + * Function called when rating is updated online. + */ + ratingUpdated(): void { + AddonModData.invalidateEntryData(this.database!.id, this.entryId!); + } + + /** + * Log viewing the activity. + * + * @return Promise resolved when done. + */ + protected async logView(): Promise { + if (!this.database || !this.database.id) { + return; + } + + await CoreUtils.ignoreErrors(AddonModData.logView(this.database.id, this.database.name)); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.syncObserver?.off(); + this.entryChangedObserver?.off(); + } + +} diff --git a/src/addons/mod/data/pages/index/index.html b/src/addons/mod/data/pages/index/index.html new file mode 100644 index 000000000..9d1564188 --- /dev/null +++ b/src/addons/mod/data/pages/index/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/data/pages/index/index.ts b/src/addons/mod/data/pages/index/index.ts new file mode 100644 index 000000000..0e982371b --- /dev/null +++ b/src/addons/mod/data/pages/index/index.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, ViewChild } from '@angular/core'; +import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page'; +import { CoreNavigator } from '@services/navigator'; +import { AddonModDataIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a data. + */ +@Component({ + selector: 'page-addon-mod-data-index', + templateUrl: 'index.html', +}) +export class AddonModDataIndexPage extends CoreCourseModuleMainActivityPage implements OnInit { + + @ViewChild(AddonModDataIndexComponent) activityComponent?: AddonModDataIndexComponent; + + group = 0; + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + this.group = CoreNavigator.getRouteNumberParam('group') || 0; + } + +} diff --git a/src/addons/mod/data/services/data-fields-delegate.ts b/src/addons/mod/data/services/data-fields-delegate.ts new file mode 100644 index 000000000..0c79e29a0 --- /dev/null +++ b/src/addons/mod/data/services/data-fields-delegate.ts @@ -0,0 +1,267 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Type } from '@angular/core'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { AddonModDataDefaultFieldHandler } from './handlers/default-field'; +import { makeSingleton } from '@singletons'; +import { AddonModDataEntryField, + AddonModDataField, + AddonModDataSearchEntriesAdvancedFieldFormatted, + AddonModDataSubfieldData, +} from './data'; +import { CoreFormFields } from '@singletons/form'; +import { CoreWSExternalFile } from '@services/ws'; +import { FileEntry } from '@ionic-native/file'; + +/** + * Interface that all fields handlers must implement. + */ +export interface AddonModDataFieldHandler extends CoreDelegateHandler { + + /** + * Name of the type of data field the handler supports. E.g. 'checkbox'. + */ + type: string; + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param field The field object. + * @return The component to use, undefined if not found. + */ + getComponent?(plugin: AddonModDataField): Type | undefined; + + /** + * Get field search data in the input data. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @return With name and value of the data to be sent. + */ + getFieldSearchData?( + field: AddonModDataField, + inputData: CoreFormFields, + ): AddonModDataSearchEntriesAdvancedFieldFormatted[]; + + /** + * Get field edit data in the input data. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the edit form. + * @return With name and value of the data to be sent. + */ + getFieldEditData?( + field: AddonModDataField, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): AddonModDataSubfieldData[]; + + /** + * Get field data in changed. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the edit form. + * @param originalFieldData Original field entered data. + * @return If the field has changes. + */ + hasFieldDataChanged?( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): boolean; + + /** + * Get field edit files in the input data. + * + * @param field Defines the field.. + * @return With name and value of the data to be sent. + */ + getFieldEditFiles?( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): (CoreWSExternalFile | FileEntry)[]; + + /** + * Check and get field requeriments. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the edit form. + * @return String with the notification or false. + */ + getFieldsNotifications?(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined; + + /** + * Override field content data with offline submission. + * + * @param originalContent Original data to be overriden. + * @param offlineContent Array with all the offline data to override. + * @param offlineFiles Array with all the offline files in the field. + * @return Data overriden + */ + overrideData?( + originalContent: AddonModDataEntryField, + offlineContent: CoreFormFields, + offlineFiles?: FileEntry[], + ): AddonModDataEntryField; +} + +/** + * Delegate to register database fields handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataFieldsDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor( + protected defaultHandler: AddonModDataDefaultFieldHandler, + ) { + super('AddonModDataFieldsDelegate', true); + } + + /** + * Get the component to use for a certain field field. + * + * @param field The field object. + * @return Promise resolved with the component to use, undefined if not found. + */ + getComponentForField(field: AddonModDataField): Promise | undefined> { + return Promise.resolve(this.executeFunctionOnEnabled(field.type, 'getComponent', [field])); + } + + /** + * Get database data in the input data to search. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @return Name and data field. + */ + getFieldSearchData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + return this.executeFunctionOnEnabled(field.type, 'getFieldSearchData', [field, inputData]) || []; + } + + /** + * Get database data in the input data to add or update entry. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @param originalFieldData Original field entered data. + * @return Name and data field. + */ + getFieldEditData( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: AddonModDataEntryField, + ): AddonModDataSubfieldData[] { + return this.executeFunctionOnEnabled(field.type, 'getFieldEditData', [field, inputData, originalFieldData]) || []; + } + + /** + * Get database data in the input files to add or update entry. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @param originalFieldData Original field entered data. + * @return Name and data field. + */ + getFieldEditFiles( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: CoreFormFields, + ): (CoreWSExternalFile | FileEntry)[] { + return this.executeFunctionOnEnabled(field.type, 'getFieldEditFiles', [field, inputData, originalFieldData]) || []; + } + + /** + * Check and get field requeriments. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the edit form. + * @return String with the notification or false. + */ + getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { + return this.executeFunctionOnEnabled(field.type, 'getFieldsNotifications', [field, inputData]); + } + + /** + * Check if field type manage files or not. + * + * @param field Defines the field to be checked. + * @return If the field type manages files. + */ + hasFiles(field: AddonModDataField): boolean { + return this.hasFunction(field.type, 'getFieldEditFiles'); + } + + /** + * Check if the data has changed for a certain field. + * + * @param field Defines the field to be rendered. + * @param inputData Data entered in the search form. + * @param originalFieldData Original field entered data. + * @return If the field has changes. + */ + hasFieldDataChanged( + field: AddonModDataField, + inputData: CoreFormFields, + originalFieldData: CoreFormFields, + ): boolean { + return !!this.executeFunctionOnEnabled( + field.type, + 'hasFieldDataChanged', + [field, inputData, originalFieldData], + ); + } + + /** + * Check if a field plugin is supported. + * + * @param pluginType Type of the plugin. + * @return True if supported, false otherwise. + */ + isPluginSupported(pluginType: string): boolean { + return this.hasHandler(pluginType, true); + } + + /** + * Override field content data with offline submission. + * + * @param field Defines the field to be rendered. + * @param originalContent Original data to be overriden. + * @param offlineContent Array with all the offline data to override. + * @param offlineFiles Array with all the offline files in the field. + * @return Data overriden + */ + overrideData( + field: AddonModDataField, + originalContent: AddonModDataEntryField, + offlineContent: CoreFormFields, + offlineFiles?: FileEntry[], + ): AddonModDataEntryField { + originalContent = originalContent || {}; + + if (!offlineContent) { + return originalContent; + } + + return this.executeFunctionOnEnabled(field.type, 'overrideData', [originalContent, offlineContent, offlineFiles]) || + originalContent; + } + +} +export const AddonModDataFieldsDelegate = makeSingleton(AddonModDataFieldsDelegateService); diff --git a/src/addons/mod/data/services/data-helper.ts b/src/addons/mod/data/services/data-helper.ts new file mode 100644 index 000000000..567f7d8bb --- /dev/null +++ b/src/addons/mod/data/services/data-helper.ts @@ -0,0 +1,790 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextLevel } from '@/core/constants'; +import { Injectable } from '@angular/core'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreRatingOffline } from '@features/rating/services/rating-offline'; +import { FileEntry } from '@ionic-native/file'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreFormFields } from '@singletons/form'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { + AddonModDataEntry, + AddonModData, + AddonModDataProvider, + AddonModDataSearchEntriesOptions, + AddonModDataEntries, + AddonModDataEntryFields, + AddonModDataAction, + AddonModDataGetEntryFormatted, + AddonModDataData, + AddonModDataTemplateType, + AddonModDataGetDataAccessInformationWSResponse, + AddonModDataTemplateMode, + AddonModDataField, + AddonModDataEntryWSField, +} from './data'; +import { AddonModDataFieldsDelegate } from './data-fields-delegate'; +import { AddonModDataOffline, AddonModDataOfflineAction } from './data-offline'; + +/** + * Service that provides helper functions for datas. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataHelperProvider { + + /** + * Returns the record with the offline actions applied. + * + * @param record Entry to modify. + * @param offlineActions Offline data with the actions done. + * @param fields Entry defined fields indexed by fieldid. + * @return Promise resolved when done. + */ + protected async applyOfflineActions( + record: AddonModDataEntry, + offlineActions: AddonModDataOfflineAction[], + fields: AddonModDataField[], + ): Promise { + const promises: Promise[] = []; + + offlineActions.forEach((action) => { + record.timemodified = action.timemodified; + record.hasOffline = true; + const offlineContents: Record = {}; + + switch (action.action) { + case AddonModDataAction.APPROVE: + record.approved = true; + break; + case AddonModDataAction.DISAPPROVE: + record.approved = false; + break; + case AddonModDataAction.DELETE: + record.deleted = true; + break; + case AddonModDataAction.ADD: + case AddonModDataAction.EDIT: + record.groupid = action.groupid; + + action.fields.forEach((offlineContent) => { + if (typeof offlineContents[offlineContent.fieldid] == 'undefined') { + offlineContents[offlineContent.fieldid] = {}; + } + + if (offlineContent.subfield) { + offlineContents[offlineContent.fieldid][offlineContent.subfield] = + CoreTextUtils.parseJSON(offlineContent.value); + } else { + offlineContents[offlineContent.fieldid][''] = CoreTextUtils.parseJSON(offlineContent.value); + } + }); + + // Override field contents. + fields.forEach((field) => { + if (AddonModDataFieldsDelegate.hasFiles(field)) { + promises.push(this.getStoredFiles(record.dataid, record.id, field.id).then((offlineFiles) => { + record.contents[field.id] = AddonModDataFieldsDelegate.overrideData( + field, + record.contents[field.id], + offlineContents[field.id], + offlineFiles, + ); + record.contents[field.id].fieldid = field.id; + + return; + })); + } else { + record.contents[field.id] = AddonModDataFieldsDelegate.overrideData( + field, + record.contents[field.id], + offlineContents[field.id], + ); + record.contents[field.id].fieldid = field.id; + } + }); + break; + default: + break; + } + }); + + await Promise.all(promises); + + return record; + } + + /** + * Approve or disapprove a database entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param approve True to approve, false to disapprove. + * @param courseId Course ID. It not defined, it will be fetched. + * @param siteId Site ID. If not defined, current site. + */ + async approveOrDisapproveEntry( + dataId: number, + entryId: number, + approve: boolean, + courseId?: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + try { + courseId = await this.getActivityCourseIdIfNotSet(dataId, courseId, siteId); + + try { + // Approve/disapprove entry. + await AddonModData.approveEntry(dataId, entryId, approve, courseId, siteId); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_data.errorapproving', true); + + throw error; + } + + const promises: Promise[] = []; + + promises.push(AddonModData.invalidateEntryData(dataId, entryId, siteId)); + promises.push(AddonModData.invalidateEntriesData(dataId, siteId)); + + await CoreUtils.ignoreErrors(Promise.all(promises)); + + CoreEvents.trigger(AddonModDataProvider.ENTRY_CHANGED, { dataId: dataId, entryId: entryId }, siteId); + + CoreDomUtils.showToast(approve ? 'addon.mod_data.recordapproved' : 'addon.mod_data.recorddisapproved', true, 3000); + } catch { + // Ignore error, it was already displayed. + } finally { + modal.dismiss(); + } + } + + /** + * Displays fields for being shown. + * + * @param template Template HMTL. + * @param fields Fields that defines every content in the entry. + * @param entry Entry. + * @param offset Entry offset. + * @param mode Mode list or show. + * @param actions Actions that can be performed to the record. + * @return Generated HTML. + */ + displayShowFields( + template: string, + fields: AddonModDataField[], + entry: AddonModDataEntry, + offset = 0, + mode: AddonModDataTemplateMode, + actions: Record, + ): string { + + if (!template) { + return ''; + } + + // Replace the fields found on template. + fields.forEach((field) => { + let replace = '[[' + field.name + ']]'; + replace = replace.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); + const replaceRegex = new RegExp(replace, 'gi'); + + // Replace field by a generic directive. + const render = ''; + template = template.replace(replaceRegex, render); + }); + + for (const action in actions) { + const replaceRegex = new RegExp('##' + action + '##', 'gi'); + // Is enabled? + if (actions[action]) { + let render = ''; + if (action == AddonModDataAction.MOREURL) { + // Render more url directly because it can be part of an HTML attribute. + render = CoreSites.getCurrentSite()!.getURL() + '/mod/data/view.php?d={{database.id}}&rid=' + entry.id; + } else if (action == 'approvalstatus') { + render = Translate.instant('addon.mod_data.' + (entry.approved ? 'approved' : 'notapproved')); + } else { + render = ''; + } + template = template.replace(replaceRegex, render); + } else { + template = template.replace(replaceRegex, ''); + } + } + + return template; + } + + /** + * Get online and offline entries, or search entries. + * + * @param database Database object. + * @param fields The fields that define the contents. + * @param options Other options. + * @return Promise resolved when the database is retrieved. + */ + async fetchEntries( + database: AddonModDataData, + fields: AddonModDataField[], + options: AddonModDataSearchEntriesOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + options.groupId = options.groupId || 0; + options.page = options.page || 0; + + const offlineActions: Record = {}; + const result: AddonModDataEntries = { + entries: [], + totalcount: 0, + offlineEntries: [], + }; + options.siteId = site.id; + + const offlinePromise = AddonModDataOffline.getDatabaseEntries(database.id, site.id).then((actions) => { + result.hasOfflineActions = !!actions.length; + + actions.forEach((action) => { + if (typeof offlineActions[action.entryid] == 'undefined') { + offlineActions[action.entryid] = []; + } + offlineActions[action.entryid].push(action); + + // We only display new entries in the first page when not searching. + if (action.action == AddonModDataAction.ADD && options.page == 0 && !options.search && !options.advSearch && + (!action.groupid || !options.groupId || action.groupid == options.groupId)) { + result.offlineEntries!.push({ + id: action.entryid, + canmanageentry: true, + approved: !database.approval || database.manageapproved, + dataid: database.id, + groupid: action.groupid, + timecreated: -action.entryid, + timemodified: -action.entryid, + userid: site.getUserId(), + fullname: site.getInfo()?.fullname, + contents: {}, + }); + } + + }); + + // Sort offline entries by creation time. + result.offlineEntries!.sort((a, b) => b.timecreated - a.timecreated); + + return; + }); + + const ratingsPromise = CoreRatingOffline.hasRatings('mod_data', 'entry', ContextLevel.MODULE, database.coursemodule) + .then((hasRatings) => { + result.hasOfflineRatings = hasRatings; + + return; + }); + + let fetchPromise: Promise; + if (options.search || options.advSearch) { + fetchPromise = AddonModData.searchEntries(database.id, options).then((searchResult) => { + result.entries = searchResult.entries; + result.totalcount = searchResult.totalcount; + result.maxcount = searchResult.maxcount; + + return; + }); + } else { + fetchPromise = AddonModData.getEntries(database.id, options).then((entriesResult) => { + result.entries = entriesResult.entries; + result.totalcount = entriesResult.totalcount; + + return; + }); + } + await Promise.all([offlinePromise, ratingsPromise, fetchPromise]); + + // Apply offline actions to online and offline entries. + const promises: Promise[] = []; + result.entries.forEach((entry) => { + promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields)); + }); + + result.offlineEntries!.forEach((entry) => { + promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields)); + }); + + await Promise.all(promises); + + return result; + } + + /** + * Fetch an online or offline entry. + * + * @param database Database. + * @param fields List of database fields. + * @param entryId Entry ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the entry. + */ + async fetchEntry( + database: AddonModDataData, + fields: AddonModDataField[], + entryId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const offlineActions = await AddonModDataOffline.getEntryActions(database.id, entryId, site.id); + + let response: AddonModDataGetEntryFormatted; + if (entryId > 0) { + // Online entry. + response = await AddonModData.getEntry(database.id, entryId, { cmId: database.coursemodule, siteId: site.id }); + } else { + // Offline entry or new entry. + response = { + entry: { + id: entryId, + userid: site.getUserId(), + groupid: 0, + dataid: database.id, + timecreated: -entryId, + timemodified: -entryId, + approved: !database.approval || database.manageapproved, + canmanageentry: true, + fullname: site.getInfo()?.fullname, + contents: {}, + }, + }; + } + + await this.applyOfflineActions(response.entry, offlineActions, fields); + + return response; + } + + /** + * Returns an object with all the actions that the user can do over the record. + * + * @param database Database activity. + * @param accessInfo Access info to the activity. + * @param entry Entry or record where the actions will be performed. + * @return Keyed with the action names and boolean to evalute if it can or cannot be done. + */ + getActions( + database: AddonModDataData, + accessInfo: AddonModDataGetDataAccessInformationWSResponse, + entry: AddonModDataEntry, + ): Record { + return { + add: false, // Not directly used on entries. + more: true, + moreurl: true, + user: true, + userpicture: true, + timeadded: true, + timemodified: true, + tags: true, + + edit: entry.canmanageentry && !entry.deleted, // This already checks capabilities and readonly period. + delete: entry.canmanageentry, + approve: database.approval && accessInfo.canapprove && !entry.approved && !entry.deleted, + disapprove: database.approval && accessInfo.canapprove && entry.approved && !entry.deleted, + + approvalstatus: database.approval, + comments: database.comments, + + // Unsupported actions. + delcheck: false, + export: false, + }; + } + + /** + * Convenience function to get the course id of the database. + * + * @param dataId Database id. + * @param courseId Course id, if known. + * @param siteId Site id, if not set, current site will be used. + * @return Resolved with course Id when done. + */ + protected async getActivityCourseIdIfNotSet(dataId: number, courseId?: number, siteId?: string): Promise { + if (courseId) { + return courseId; + } + + const module = await CoreCourse.getModuleBasicInfoByInstance(dataId, 'data', siteId); + + return module.course; + } + + /** + * Returns the default template of a certain type. + * + * Based on Moodle function data_generate_default_template. + * + * @param type Type of template. + * @param fields List of database fields. + * @return Template HTML. + */ + getDefaultTemplate(type: AddonModDataTemplateType, fields: AddonModDataField[]): string { + if (type == AddonModDataTemplateType.LIST_HEADER || type == AddonModDataTemplateType.LIST_FOOTER) { + return ''; + } + + const html: string[] = []; + + if (type == AddonModDataTemplateType.LIST) { + html.push('##delcheck##
'); + } + + html.push( + '
', + '', + '', + ); + + fields.forEach((field) => { + html.push( + '', + '', + '', + '', + ); + }); + + if (type == AddonModDataTemplateType.LIST) { + html.push( + '', + '', + '', + ); + } else if (type == AddonModDataTemplateType.SINGLE) { + html.push( + '', + '', + '', + ); + } else if (type == AddonModDataTemplateType.SEARCH) { + html.push( + '', + '', + '', + '', + '', + '', + '', + '', + ); + } + + html.push( + '', + '
', + field.name, + ': [[', + field.name, + ']]
', + '##edit## ##more## ##delete## ##approve## ##disapprove## ##export##', + '
', + '##edit## ##delete## ##approve## ##disapprove## ##export##', + '
Author first name: ##firstname##
Author surname: ##lastname##
', + '
', + ); + + if (type == AddonModDataTemplateType.LIST) { + html.push('
'); + } + + return html.join(''); + } + + /** + * Retrieve the entered data in the edit form. + * We don't use ng-model because it doesn't detect changes done by JavaScript. + * + * @param inputData Array with the entered form values. + * @param fields Fields that defines every content in the entry. + * @param dataId Database Id. If set, files will be uploaded and itemId set. + * @param entryId Entry Id. + * @param entryContents Original entry contents. + * @param offline True to prepare the data for an offline uploading, false otherwise. + * @param siteId Site ID. If not defined, current site. + * @return That contains object with the answers. + */ + async getEditDataFromForm( + inputData: CoreFormFields, + fields: AddonModDataField[], + dataId: number, + entryId: number, + entryContents: AddonModDataEntryFields, + offline: boolean = false, + siteId?: string, + ): Promise { + if (!inputData) { + return []; + } + + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Filter and translate fields to each field plugin. + const entryFieldDataToSend: AddonModDataEntryWSField[] = []; + + const promises = fields.map(async (field) => { + const fieldData = AddonModDataFieldsDelegate.getFieldEditData(field, inputData, entryContents[field.id]); + if (!fieldData) { + return; + } + const proms = fieldData.map(async (fieldSubdata) => { + let value = fieldSubdata.value; + + // Upload Files if asked. + if (dataId && fieldSubdata.files) { + value = await this.uploadOrStoreFiles( + dataId, + 0, + entryId, + fieldSubdata.fieldid, + fieldSubdata.files, + offline, + siteId, + ); + } + + // WS wants values in JSON format. + entryFieldDataToSend.push({ + fieldid: fieldSubdata.fieldid, + subfield: fieldSubdata.subfield || '', + value: value ? JSON.stringify(value) : '', + }); + + return; + }); + + await Promise.all(proms); + }); + + await Promise.all(promises); + + return entryFieldDataToSend; + } + + /** + * Retrieve the temp files to be updated. + * + * @param inputData Array with the entered form values. + * @param fields Fields that defines every content in the entry. + * @param entryContents Original entry contents indexed by field id. + * @return That contains object with the files. + */ + async getEditTmpFiles( + inputData: CoreFormFields, + fields: AddonModDataField[], + entryContents: AddonModDataEntryFields, + ): Promise<(CoreWSExternalFile | FileEntry)[]> { + if (!inputData) { + return []; + } + + // Filter and translate fields to each field plugin. + const promises = fields.map((field) => + AddonModDataFieldsDelegate.getFieldEditFiles(field, inputData, entryContents[field.id])); + + const fieldsFiles = await Promise.all(promises); + + return fieldsFiles.reduce((files, fieldFiles) => files.concat(fieldFiles), []); + } + + /** + * Get a list of stored attachment files for a new entry. See $mmaModDataHelper#storeFiles. + * + * @param dataId Database ID. + * @param entryId Entry ID or, if creating, timemodified. + * @param fieldId Field ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getStoredFiles(dataId: number, entryId: number, fieldId: number, siteId?: string): Promise { + const folderPath = await AddonModDataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId); + + try { + return CoreFileUploader.getStoredFiles(folderPath); + } catch { + // Ignore not found files. + return []; + } + } + + /** + * Returns the template of a certain type. + * + * @param data Database object. + * @param type Type of template. + * @param fields List of database fields. + * @return Template HTML. + */ + getTemplate(data: AddonModDataData, type: AddonModDataTemplateType, fields: AddonModDataField[]): string { + let template = data[type] || this.getDefaultTemplate(type, fields); + + if (type != AddonModDataTemplateType.LIST_HEADER && type != AddonModDataTemplateType.LIST_FOOTER) { + // Try to fix syntax errors so the template can be parsed by Angular. + template = CoreDomUtils.fixHtml(template); + } + + // Add core-link directive to links. + template = template.replace( + /]*href="[^>]*)>/ig, + (match, attributes) => '', + ); + + return template; + } + + /** + * Check if data has been changed by the user. + * + * @param inputData Object with the entered form values. + * @param fields Fields that defines every content in the entry. + * @param dataId Database Id. If set, fils will be uploaded and itemId set. + * @param entryContents Original entry contents indexed by field id. + * @return True if changed, false if not. + */ + hasEditDataChanged( + inputData: CoreFormFields, + fields: AddonModDataField[], + entryContents: AddonModDataEntryFields, + ): boolean { + return fields.some((field) => + AddonModDataFieldsDelegate.hasFieldDataChanged(field, inputData, entryContents[field.id])); + } + + /** + * Displays a confirmation modal for deleting an entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param courseId Course ID. It not defined, it will be fetched. + * @param siteId Site ID. If not defined, current site. + */ + async showDeleteEntryModal(dataId: number, entryId: number, courseId?: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + try { + await CoreDomUtils.showDeleteConfirm('addon.mod_data.confirmdeleterecord'); + + const modal = await CoreDomUtils.showModalLoading(); + + try { + if (entryId > 0) { + courseId = await this.getActivityCourseIdIfNotSet(dataId, courseId, siteId); + } + + AddonModData.deleteEntry(dataId, entryId, courseId!, siteId); + } catch (message) { + CoreDomUtils.showErrorModalDefault(message, 'addon.mod_data.errordeleting', true); + + modal.dismiss(); + + return; + } + + try { + await AddonModData.invalidateEntryData(dataId, entryId, siteId); + await AddonModData.invalidateEntriesData(dataId, siteId); + } catch (error) { + // Ignore errors. + } + + CoreEvents.trigger(AddonModDataProvider.ENTRY_CHANGED, { dataId, entryId, deleted: true }, siteId); + + CoreDomUtils.showToast('addon.mod_data.recorddeleted', true, 3000); + + modal.dismiss(); + } catch { + // Ignore error, it was already displayed. + } + + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param dataId Database ID. + * @param entryId Entry ID or, if creating, timemodified. + * @param fieldId Field ID. + * @param files List of files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected otherwise. + */ + async storeFiles( + dataId: number, + entryId: number, + fieldId: number, + files: (CoreWSExternalFile | FileEntry)[], + siteId?: string, + ): Promise { + // Get the folder where to store the files. + const folderPath = await AddonModDataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId); + + return CoreFileUploader.storeFilesToUpload(folderPath, files); + } + + /** + * Upload or store some files, depending if the user is offline or not. + * + * @param dataId Database ID. + * @param itemId Draft ID to use. Undefined or 0 to create a new draft ID. + * @param entryId Entry ID or, if creating, timemodified. + * @param fieldId Field ID. + * @param files List of files. + * @param offline True if files sould be stored for offline, false to upload them. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the itemId for the uploaded file/s. + */ + async uploadOrStoreFiles( + dataId: number, + itemId: number = 0, + entryId: number, + fieldId: number, + files: (CoreWSExternalFile | FileEntry)[], + offline: boolean, + siteId?: string, + ): Promise { + if (!files.length) { + return 0; + } + + if (offline) { + return this.storeFiles(dataId, entryId, fieldId, files, siteId); + } + + return CoreFileUploader.uploadOrReuploadFiles(files, AddonModDataProvider.COMPONENT, itemId, siteId); + } + +} +export const AddonModDataHelper = makeSingleton(AddonModDataHelperProvider); diff --git a/src/addons/mod/data/services/data-offline.ts b/src/addons/mod/data/services/data-offline.ts new file mode 100644 index 000000000..ffc317d4d --- /dev/null +++ b/src/addons/mod/data/services/data-offline.ts @@ -0,0 +1,290 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreFile } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonModDataAction, AddonModDataEntryWSField } from './data'; +import { AddonModDataEntryDBRecord, DATA_ENTRY_TABLE } from './database/data'; + +/** + * Service to handle Offline data. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataOfflineProvider { + + /** + * Delete all the actions of an entry. + * + * @param dataId Database ID. + * @param entryId Database entry ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteAllEntryActions(dataId: number, entryId: number, siteId?: string): Promise { + const actions = await this.getEntryActions(dataId, entryId, siteId); + + const promises = actions.map((action) => { + this.deleteEntry(dataId, entryId, action.action, siteId); + }); + + await Promise.all(promises); + } + + /** + * Delete an stored entry. + * + * @param dataId Database ID. + * @param entryId Database entry Id. + * @param action Action to be done + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteEntry(dataId: number, entryId: number, action: AddonModDataAction, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await this.deleteEntryFiles(dataId, entryId, action, site.id); + + await site.getDb().deleteRecords(DATA_ENTRY_TABLE, { + dataid: dataId, + entryid: entryId, + action, + }); + } + + /** + * Delete entry offline files. + * + * @param dataId Database ID. + * @param entryId Database entry ID. + * @param action Action to be done. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + protected async deleteEntryFiles(dataId: number, entryId: number, action: AddonModDataAction, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const entry = await CoreUtils.ignoreErrors(this.getEntry(dataId, entryId, action, site.id)); + + if (!entry || !entry.fields) { + // Entry not found or no fields, ignore. + return; + } + + const promises: Promise[] = []; + + entry.fields.forEach((field) => { + const value = CoreTextUtils.parseJSON(field.value); + + if (!value.offline) { + return; + } + + const promise = this.getEntryFieldFolder(dataId, entryId, field.fieldid, site.id).then((folderPath) => + CoreFileUploader.getStoredFiles(folderPath)).then((files) => + CoreFileUploader.clearTmpFiles(files)).catch(() => { // Files not found, ignore. + }); + + promises.push(promise); + }); + + await Promise.all(promises); + } + + /** + * Get all the stored entry data from all the databases. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with entries. + */ + async getAllEntries(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const entries = await site.getDb().getAllRecords(DATA_ENTRY_TABLE); + + return entries.map(this.parseRecord.bind(this)); + } + + /** + * Get all the stored entry actions from a certain database, sorted by modification time. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with entries. + */ + async getDatabaseEntries(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const entries = await site.getDb().getRecords( + DATA_ENTRY_TABLE, + { dataid: dataId }, + 'timemodified', + ); + + return entries.map(this.parseRecord.bind(this)); + } + + /** + * Get an stored entry data. + * + * @param dataId Database ID. + * @param entryId Database entry Id. + * @param action Action to be done + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with entry. + */ + async getEntry( + dataId: number, + entryId: number, + action: AddonModDataAction, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const entry = await site.getDb().getRecord(DATA_ENTRY_TABLE, { + dataid: dataId, entryid: entryId, + action, + }); + + return this.parseRecord(entry); + } + + /** + * Get an all stored entry actions data. + * + * @param dataId Database ID. + * @param entryId Database entry Id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with entry actions. + */ + async getEntryActions(dataId: number, entryId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const entries = await site.getDb().getRecords( + DATA_ENTRY_TABLE, + { dataid: dataId, entryid: entryId }, + ); + + return entries.map(this.parseRecord.bind(this)); + } + + /** + * Check if there are offline entries to send. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline answers, false otherwise. + */ + async hasOfflineData(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return CoreUtils.promiseWorks( + site.getDb().recordExists(DATA_ENTRY_TABLE, { dataid: dataId }), + ); + } + + /** + * Get the path to the folder where to store files for offline files in a database. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + protected async getDatabaseFolder(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const siteFolderPath = CoreFile.getSiteFolder(site.getId()); + const folderPath = 'offlinedatabase/' + dataId; + + return CoreTextUtils.concatenatePaths(siteFolderPath, folderPath); + } + + /** + * Get the path to the folder where to store files for a new offline entry. + * + * @param dataId Database ID. + * @param entryId The ID of the entry. + * @param fieldId Field ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the path. + */ + async getEntryFieldFolder(dataId: number, entryId: number, fieldId: number, siteId?: string): Promise { + const folderPath = await this.getDatabaseFolder(dataId, siteId); + + return CoreTextUtils.concatenatePaths(folderPath, entryId + '_' + fieldId); + } + + /** + * Parse "fields" of an offline record. + * + * @param record Record object + * @return Record object with columns parsed. + */ + protected parseRecord(record: AddonModDataEntryDBRecord): AddonModDataOfflineAction { + return Object.assign(record, { + fields: CoreTextUtils.parseJSON(record.fields), + }); + } + + /** + * Save an entry data to be sent later. + * + * @param dataId Database ID. + * @param entryId Database entry Id. If action is add entryId should be 0 and -timemodified will be used. + * @param action Action to be done to the entry: [add, edit, delete, approve, disapprove] + * @param courseId Course ID of the database. + * @param groupId Group ID. Only provided when adding. + * @param fields Array of field data of the entry if needed. + * @param timemodified The time the entry was modified. If not defined, current time. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveEntry( + dataId: number, + entryId: number, + action: AddonModDataAction, + courseId: number, + groupId = 0, + fields?: AddonModDataEntryWSField[], + timemodified?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + timemodified = timemodified || new Date().getTime(); + entryId = typeof entryId == 'undefined' || entryId === null ? -timemodified : entryId; + + const entry: AddonModDataEntryDBRecord = { + dataid: dataId, + courseid: courseId, + groupid: groupId, + action, + entryid: entryId, + fields: JSON.stringify(fields || []), + timemodified, + }; + + await site.getDb().insertRecord(DATA_ENTRY_TABLE, entry); + + return entry; + } + +} +export const AddonModDataOffline = makeSingleton(AddonModDataOfflineProvider); + +/** + * Entry action stored offline. + */ +export type AddonModDataOfflineAction = Omit & { + fields: AddonModDataEntryWSField[]; +}; diff --git a/src/addons/mod/data/services/data-sync.ts b/src/addons/mod/data/services/data-sync.ts new file mode 100644 index 000000000..92d0d94c9 --- /dev/null +++ b/src/addons/mod/data/services/data-sync.ts @@ -0,0 +1,499 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextLevel } from '@/core/constants'; +import { Injectable } from '@angular/core'; +import { CoreSyncBlockedError } from '@classes/base-sync'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { CoreRatingSync } from '@features/rating/services/rating-sync'; +import { FileEntry } from '@ionic-native/file'; +import { CoreApp } from '@services/app'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { Translate, makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModDataProvider, AddonModData, AddonModDataData, AddonModDataAction } from './data'; +import { AddonModDataHelper } from './data-helper'; +import { AddonModDataOffline, AddonModDataOfflineAction } from './data-offline'; + +/** + * Service to sync databases. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_data_autom_synced'; + + protected componentTranslatableString = 'data'; + + constructor() { + super('AddonModDataSyncProvider'); + } + + /** + * Check if a database has data to synchronize. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has data to sync, false otherwise. + */ + hasDataToSync(dataId: number, siteId?: string): Promise { + return AddonModDataOffline.hasOfflineData(dataId, siteId); + } + + /** + * Try to synchronize all the databases in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllDatabases(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all databases', this.syncAllDatabasesFunc.bind(this, !!force), siteId); + } + + /** + * Sync all pending databases on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllDatabasesFunc(force: boolean, siteId: string): Promise { + const promises: Promise[] = []; + + // Get all data answers pending to be sent in the site. + promises.push(AddonModDataOffline.getAllEntries(siteId).then(async (offlineActions) => { + // Get data id. + let dataIds: number[] = offlineActions.map((action) => action.dataid); + // Get unique values. + dataIds = dataIds.filter((id, pos) => dataIds.indexOf(id) == pos); + + const entriesPromises = dataIds.map(async (dataId) => { + const result = force + ? await this.syncDatabase(dataId, siteId) + : await this.syncDatabaseIfNeeded(dataId, siteId); + + if (result && result.updated) { + // Sync done. Send event. + CoreEvents.trigger(AddonModDataSyncProvider.AUTO_SYNCED, { + dataId: dataId, + warnings: result.warnings, + }, siteId); + } + }); + + await Promise.all(entriesPromises); + + return; + })); + + promises.push(this.syncRatings(undefined, force, siteId)); + + await Promise.all(promises); + } + + /** + * Sync a database only if a certain time has passed since the last time. + * + * @param dataId Database ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is synced or if it doesn't need to be synced. + */ + async syncDatabaseIfNeeded(dataId: number, siteId?: string): Promise { + const needed = await this.isSyncNeeded(dataId, siteId); + + if (needed) { + return this.syncDatabase(dataId, siteId); + } + } + + /** + * Synchronize a data. + * + * @param dataId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + syncDatabase(dataId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (this.isSyncing(dataId, siteId)) { + // There's already a sync ongoing for this database, return the promise. + return this.getOngoingSync(dataId, siteId)!; + } + + // Verify that database isn't blocked. + if (CoreSync.isBlocked(AddonModDataProvider.COMPONENT, dataId, siteId)) { + this.logger.debug(`Cannot sync database '${dataId}' because it is blocked.`); + + throw new CoreSyncBlockedError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug(`Try to sync data '${dataId}' in site ${siteId}'`); + + const syncPromise = this.performSyncDatabase(dataId, siteId); + + return this.addOngoingSync(dataId, syncPromise, siteId); + } + + /** + * Perform the database syncronization. + * + * @param dataId Data ID. + * @param siteId Site ID. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async performSyncDatabase(dataId: number, siteId: string): Promise { + // Sync offline logs. + await CoreUtils.ignoreErrors( + CoreCourseLogHelper.syncActivity(AddonModDataProvider.COMPONENT, dataId, siteId), + ); + + const result: AddonModDataSyncResult = { + warnings: [], + updated: false, + }; + + // Get answers to be sent. + const offlineActions: AddonModDataOfflineAction[] = + await CoreUtils.ignoreErrors(AddonModDataOffline.getDatabaseEntries(dataId, siteId), []); + + if (!offlineActions.length) { + // Nothing to sync. + await CoreUtils.ignoreErrors(this.setSyncTime(dataId, siteId)); + + return result; + } + + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + const courseId = offlineActions[0].courseid; + + // Send the answers. + const database = await AddonModData.getDatabaseById(courseId, dataId, { siteId }); + + const offlineEntries: Record = {}; + + offlineActions.forEach((entry) => { + if (typeof offlineEntries[entry.entryid] == 'undefined') { + offlineEntries[entry.entryid] = []; + } + + offlineEntries[entry.entryid].push(entry); + }); + + const promises = CoreUtils.objectToArray(offlineEntries).map((entryActions) => + this.syncEntry(database, entryActions, result, siteId)); + + await Promise.all(promises); + + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + await CoreUtils.ignoreErrors(AddonModData.invalidateContent(database.coursemodule, courseId, siteId)); + } + + // Sync finished, set sync time. + await CoreUtils.ignoreErrors(this.setSyncTime(dataId, siteId)); + + return result; + } + + /** + * Synchronize an entry. + * + * @param database Database. + * @param entryActions Entry actions. + * @param result Object with the result of the sync. + * @param siteId Site ID. + * @return Promise resolved if success, rejected otherwise. + */ + protected async syncEntry( + database: AddonModDataData, + entryActions: AddonModDataOfflineAction[], + result: AddonModDataSyncResult, + siteId: string, + ): Promise { + const synEntryResult = await this.performSyncEntry(database, entryActions, result, siteId); + + if (synEntryResult.discardError) { + // Submission was discarded, add a warning. + const message = Translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: database.name, + error: synEntryResult.discardError, + }); + + if (result.warnings.indexOf(message) == -1) { + result.warnings.push(message); + } + } + + // Sync done. Send event. + CoreEvents.trigger(AddonModDataSyncProvider.AUTO_SYNCED, { + dataId: database.id, + entryId: synEntryResult.entryId, + offlineEntryId: synEntryResult.offlineId, + warnings: result.warnings, + deleted: synEntryResult.deleted, + }, siteId); + } + + /** + * Perform the synchronization of an entry. + * + * @param database Database. + * @param entryActions Entry actions. + * @param result Object with the result of the sync. + * @param siteId Site ID. + * @return Promise resolved if success, rejected otherwise. + */ + protected async performSyncEntry( + database: AddonModDataData, + entryActions: AddonModDataOfflineAction[], + result: AddonModDataSyncResult, + siteId: string, + ): Promise { + let entryId = entryActions[0].entryid; + + const entryResult: AddonModDataSyncEntryResult = { + deleted: false, + entryId: entryId, + }; + + const editAction = entryActions.find((action) => + action.action == AddonModDataAction.ADD || action.action == AddonModDataAction.EDIT); + const approveAction = entryActions.find((action) => + action.action == AddonModDataAction.APPROVE || action.action == AddonModDataAction.DISAPPROVE); + const deleteAction = entryActions.find((action) => action.action == AddonModDataAction.DELETE); + + const options: CoreCourseCommonModWSOptions = { + cmId: database.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + let timemodified = 0; + if (entryId > 0) { + try { + const entry = await AddonModData.getEntry(database.id, entryId, options); + + timemodified = entry.entry.timemodified; + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means the entry has been deleted. + timemodified = -1; + } else { + throw error; + } + } + + } else if (editAction) { + // New entry. + entryResult.offlineId = entryId; + timemodified = 0; + } else { + // New entry but the add action is missing, discard. + timemodified = -1; + } + + if (timemodified < 0 || timemodified >= entryActions[0].timemodified) { + // The entry was not found in Moodle or the entry has been modified, discard the action. + result.updated = true; + entryResult.discardError = Translate.instant('addon.mod_data.warningsubmissionmodified'); + + await AddonModDataOffline.deleteAllEntryActions(database.id, entryId, siteId); + + return entryResult; + } + + if (deleteAction) { + try { + await AddonModData.deleteEntryOnline(entryId, siteId); + entryResult.deleted = true; + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + + // Delete the offline data. + result.updated = true; + + await AddonModDataOffline.deleteAllEntryActions(deleteAction.dataid, deleteAction.entryid, siteId); + + return entryResult; + } + + if (editAction) { + try { + await Promise.all(editAction.fields.map(async (field) => { + // Upload Files if asked. + const value = CoreTextUtils.parseJSON(field.value || ''); + if (value.online || value.offline) { + let files: (CoreWSExternalFile | FileEntry)[] = value.online || []; + + const offlineFiles = value.offline + ? await AddonModDataHelper.getStoredFiles(editAction.dataid, entryId, field.fieldid) + : []; + + files = files.concat(offlineFiles); + + const filesResult = await AddonModDataHelper.uploadOrStoreFiles( + editAction.dataid, + 0, + entryId, + field.fieldid, + files, + false, + siteId, + ); + + field.value = JSON.stringify(filesResult); + } + })); + + if (editAction.action == AddonModDataAction.ADD) { + const result = await AddonModData.addEntryOnline( + editAction.dataid, + editAction.fields, + editAction.groupid, + siteId, + ); + entryId = result.newentryid; + entryResult.entryId = entryId; + } else { + await AddonModData.editEntryOnline(entryId, editAction.fields, siteId); + } + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + // Delete the offline data. + result.updated = true; + + await AddonModDataOffline.deleteEntry(editAction.dataid, editAction.entryid, editAction.action, siteId); + } + + if (approveAction) { + try { + await AddonModData.approveEntryOnline(entryId, approveAction.action == AddonModDataAction.APPROVE, siteId); + } catch (error) { + if (error && CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + // Delete the offline data. + result.updated = true; + + await AddonModDataOffline.deleteEntry(approveAction.dataid, approveAction.entryid, approveAction.action, siteId); + } + + return entryResult; + } + + /** + * Synchronize offline ratings. + * + * @param cmId Course module to be synced. If not defined, sync all databases. + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncRatings(cmId?: number, force?: boolean, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const results = await CoreRatingSync.syncRatings('mod_data', 'entry', ContextLevel.MODULE, cmId, 0, force, siteId); + let updated = false; + const warnings = []; + + const promises = results.map((result) => + AddonModData.getDatabase(result.itemSet.courseId, result.itemSet.instanceId, { siteId }) + .then((database) => { + const subPromises: Promise[] = []; + + if (result.updated.length) { + updated = true; + + // Invalidate entry of updated ratings. + result.updated.forEach((itemId) => { + subPromises.push(AddonModData.invalidateEntryData(database.id, itemId, siteId)); + }); + } + + if (result.warnings.length) { + result.warnings.forEach((warning) => { + this.addOfflineDataDeletedWarning(warnings, database.name, warning); + }); + } + + return CoreUtils.allPromises(subPromises); + })); + + await Promise.all(promises); + + return ({ updated, warnings }); + } + +} +export const AddonModDataSync = makeSingleton(AddonModDataSyncProvider); + +/** + * Data returned by a database sync. + */ +export type AddonModDataSyncEntryResult = { + discardError?: string; + offlineId?: number; + entryId: number; + deleted: boolean; +}; + +/** + * Data returned by a database sync. + */ +export type AddonModDataSyncResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether some data was sent to the server or offline data was updated. +}; + +export type AddonModDataAutoSyncData = { + dataId: number; + warnings: string[]; + entryId?: number; + offlineEntryId?: number; + deleted?: boolean; +}; diff --git a/src/addons/mod/data/services/data.ts b/src/addons/mod/data/services/data.ts new file mode 100644 index 000000000..4b3af40c1 --- /dev/null +++ b/src/addons/mod/data/services/data.ts @@ -0,0 +1,1460 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreRatingInfo } from '@features/rating/services/rating'; +import { CoreTagItem } from '@features/tag/services/tag'; +import { FileEntry } from '@ionic-native/file'; +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModDataFieldsDelegate } from './data-fields-delegate'; +import { AddonModDataOffline } from './data-offline'; +import { AddonModDataAutoSyncData, AddonModDataSyncProvider } from './data-sync'; + +const ROOT_CACHE_KEY = 'mmaModData:'; + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [AddonModDataSyncProvider.AUTO_SYNCED]: AddonModDataAutoSyncData; + [AddonModDataProvider.ENTRY_CHANGED]: AddonModDataEntryChangedEventData; + } +} + +export enum AddonModDataAction { + ADD = 'add', + EDIT = 'edit', + DELETE = 'delete', + APPROVE = 'approve', + DISAPPROVE = 'disapprove', + USER = 'user', + USERPICTURE = 'userpicture', + MORE = 'more', + MOREURL = 'moreurl', + COMMENTS = 'comments', + TIMEADDED = 'timeadded', + TIMEMODIFIED = 'timemodified', + TAGS = 'tags', + APPROVALSTATUS = 'approvalstatus', + DELCHECK = 'delcheck', // Unused. + EXPORT = 'export', // Unused. +} + +export enum AddonModDataTemplateType { + LIST_HEADER = 'listtemplateheader', + LIST = 'listtemplate', + LIST_FOOTER = 'listtemplatefooter', + ADD = 'addtemplate', + SEARCH = 'asearchtemplate', + SINGLE = 'singletemplate', +} + +export enum AddonModDataTemplateMode { + LIST = 'list', + EDIT = 'edit', + SHOW = 'show', + SEARCH = 'search', +} + +/** + * Service that provides some features for databases. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataProvider { + + static readonly COMPONENT = 'mmaModData'; + static readonly PER_PAGE = 25; + static readonly ENTRY_CHANGED = 'addon_mod_data_entry_changed'; + + /** + * Adds a new entry to a database. + * + * @param dataId Data instance ID. + * @param entryId EntryId or provisional entry ID when offline. + * @param courseId Course ID. + * @param contents The fields data to be created. + * @param groupId Group id, 0 means that the function will determine the user group. + * @param fields The fields that define the contents. + * @param siteId Site ID. If not defined, current site. + * @param forceOffline Force editing entry in offline. + * @return Promise resolved when the action is done. + */ + async addEntry( + dataId: number, + entryId: number, + courseId: number, + contents: AddonModDataEntryWSField[], + groupId: number = 0, + fields: AddonModDataField[], + siteId?: string, + forceOffline: boolean = false, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a data to be synchronized later. + const storeOffline = async (): Promise => { + const entry = await AddonModDataOffline.saveEntry( + dataId, + entryId, + AddonModDataAction.ADD, + courseId, + groupId, + contents, + undefined, + siteId, + ); + + return { + // Return provissional entry Id. + newentryid: entry.entryid, + sent: false, + }; + }; + + // Checks to store offline. + if (!CoreApp.isOnline() || forceOffline) { + const notifications = this.checkFields(fields, contents); + if (notifications.length > 0) { + return { fieldnotifications: notifications }; + } + } + + // Remove unnecessary not synced actions. + await this.deleteEntryOfflineAction(dataId, entryId, AddonModDataAction.ADD, siteId); + + // App is offline, store the action. + if (!CoreApp.isOnline() || forceOffline) { + return storeOffline(); + } + + try { + const result: AddonModDataAddEntryResult = await this.addEntryOnline(dataId, contents, groupId, siteId); + result.sent = true; + + return result; + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + } + } + + /** + * Adds a new entry to a database. It does not cache calls. It will fail if offline or cannot connect. + * + * @param dataId Database ID. + * @param data The fields data to be created. + * @param groupId Group id, 0 means that the function will determine the user group. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async addEntryOnline( + dataId: number, + data: AddonModDataEntryWSField[], + groupId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + const params: AddonModDataAddEntryWSParams = { + databaseid: dataId, + data, + }; + + if (typeof groupId !== 'undefined') { + params.groupid = groupId; + } + + return site.write('mod_data_add_entry', params); + } + + /** + * Approves or unapproves an entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param approve Whether to approve (true) or unapprove the entry. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async approveEntry( + dataId: number, + entryId: number, + approve: boolean, + courseId: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a data to be synchronized later. + const storeOffline = async (): Promise => { + const action = approve ? AddonModDataAction.APPROVE : AddonModDataAction.DISAPPROVE; + + await AddonModDataOffline.saveEntry(dataId, entryId, action, courseId, undefined, undefined, undefined, siteId); + + return { + sent: false, + }; + }; + + // Get if the opposite action is not synced. + const oppositeAction = approve ? AddonModDataAction.DISAPPROVE : AddonModDataAction.APPROVE; + + const found = await this.deleteEntryOfflineAction(dataId, entryId, oppositeAction, siteId); + if (found) { + // Offline action has been found and deleted. Stop here. + return; + } + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + await this.approveEntryOnline(entryId, approve, siteId); + + return { + sent: true, + }; + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + } + } + + /** + * Approves or unapproves an entry. It does not cache calls. It will fail if offline or cannot connect. + * + * @param entryId Entry ID. + * @param approve Whether to approve (true) or unapprove the entry. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async approveEntryOnline(entryId: number, approve: boolean, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const params: AddonModDataApproveEntryWSParams = { + entryid: entryId, + approve, + }; + + await site.write('mod_data_approve_entry', params); + } + + /** + * Convenience function to check fields requeriments here named "notifications". + * + * @param fields The fields that define the contents. + * @param contents The contents data of the fields. + * @return Array of notifications if any or false. + */ + protected checkFields(fields: AddonModDataField[], contents: AddonModDataSubfieldData[]): AddonModDataFieldNotification[] { + const notifications: AddonModDataFieldNotification[] = []; + const contentsIndexed = CoreUtils.arrayToObjectMultiple(contents, 'fieldid'); + + // App is offline, check required fields. + fields.forEach((field) => { + const notification = AddonModDataFieldsDelegate.getFieldsNotifications(field, contentsIndexed[field.id]); + + if (notification) { + notifications.push({ + fieldname: field.name, + notification, + }); + } + }); + + return notifications; + } + + /** + * Deletes an entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async deleteEntry(dataId: number, entryId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a data to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModDataOffline.saveEntry( + dataId, + entryId, + AddonModDataAction.DELETE, + courseId, + undefined, + undefined, + undefined, + siteId, + ); + }; + + // Check if the opposite action is not synced and just delete it. + const addedOffline = await this.deleteEntryOfflineAction(dataId, entryId, AddonModDataAction.ADD, siteId); + if (addedOffline) { + // Offline add action found and deleted. Stop here. + return; + } + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + await this.deleteEntryOnline(entryId, siteId); + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + } + } + + /** + * Deletes an entry. It does not cache calls. It will fail if offline or cannot connect. + * + * @param entryId Entry ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async deleteEntryOnline(entryId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const params: AddonModDataDeleteEntryWSParams = { + entryid: entryId, + }; + + await site.write('mod_data_delete_entry', params); + } + + /** + * Delete entry offline action. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param action Action name to delete. + * @param siteId Site ID. + * @return Resolved with true if the action has been found and deleted. + */ + protected async deleteEntryOfflineAction( + dataId: number, + entryId: number, + action: AddonModDataAction, + siteId: string, + ): Promise { + try { + // Get other not not synced actions. + await AddonModDataOffline.getEntry(dataId, entryId, action, siteId); + await AddonModDataOffline.deleteEntry(dataId, entryId, action, siteId); + + return true; + } catch { + // Not found. + return false; + } + } + + /** + * Updates an existing entry. + * + * @param dataId Database ID. + * @param entryId Entry ID. + * @param courseId Course ID. + * @param contents The contents data to be updated. + * @param fields The fields that define the contents. + * @param siteId Site ID. If not defined, current site. + * @param forceOffline Force editing entry in offline. + * @return Promise resolved when the action is done. + */ + async editEntry( + dataId: number, + entryId: number, + courseId: number, + contents: AddonModDataEntryWSField[], + fields: AddonModDataField[], + siteId?: string, + forceOffline: boolean = false, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a data to be synchronized later. + const storeOffline = async (): Promise => { + await AddonModDataOffline.saveEntry( + dataId, + entryId, + AddonModDataAction.EDIT, + courseId, + undefined, + contents, + undefined, + siteId, + ); + + return { + updated: true, + sent: false, + }; + }; + + if (!CoreApp.isOnline() || forceOffline) { + const notifications = this.checkFields(fields, contents); + if (notifications.length > 0) { + return { fieldnotifications: notifications }; + } + } + + // Remove unnecessary not synced actions. + await this.deleteEntryOfflineAction(dataId, entryId, AddonModDataAction.EDIT, siteId); + + if (!CoreApp.isOnline() || forceOffline) { + // App is offline, store the action. + return storeOffline(); + } + + try { + const result: AddonModDataEditEntryResult = await this.editEntryOnline(entryId, contents, siteId); + result.sent = true; + + return result; + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + } + } + + /** + * Updates an existing entry. It does not cache calls. It will fail if offline or cannot connect. + * + * @param entryId Entry ID. + * @param data The fields data to be updated. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the action is done. + */ + async editEntryOnline( + entryId: number, + data: AddonModDataEntryWSField[], + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + const params: AddonModDataUpdateEntryWSParams = { + entryid: entryId, + data, + }; + + return site.write('mod_data_update_entry', params); + } + + /** + * Performs the whole fetch of the entries in the database. + * + * @param dataId Data ID. + * @param options Other options. + * @return Promise resolved when done. + */ + fetchAllEntries(dataId: number, options: AddonModDataGetEntriesOptions = {}): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + options = Object.assign({ + page: 0, + perPage: AddonModDataProvider.PER_PAGE, + }, options); + + return this.fetchEntriesRecursive(dataId, [], options); + } + + /** + * Recursive call on fetch all entries. + * + * @param dataId Data ID. + * @param entries Entries already fetch (just to concatenate them). + * @param options Other options. + * @return Promise resolved when done. + */ + protected async fetchEntriesRecursive( + dataId: number, + entries: AddonModDataEntry[], + options: AddonModDataGetEntriesOptions, + ): Promise { + const result = await this.getEntries(dataId, options); + entries = entries.concat(result.entries); + + const canLoadMore = options.perPage! > 0 && ((options.page! + 1) * options.perPage!) < result.totalcount; + if (canLoadMore) { + options.page!++; + + return this.fetchEntriesRecursive(dataId, entries, options); + } + + return entries; + } + + /** + * Get cache key for data data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getDatabaseDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'data:' + courseId; + } + + /** + * Get prefix cache key for all database activity data WS calls. + * + * @param dataId Data ID. + * @return Cache key. + */ + protected getDatabaseDataPrefixCacheKey(dataId: number): string { + return ROOT_CACHE_KEY + dataId; + } + + /** + * Get a database data. If more than one is found, only the first will be returned. + * + * @param courseId Course ID. + * @param key Name of the property to check. + * @param value Value to search. + * @param options Other options. + * @return Promise resolved when the data is retrieved. + */ + protected async getDatabaseByKey( + courseId: number, + key: string, + value: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModDataGetDatabasesByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDatabaseDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModDataProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + const response = + await site.read('mod_data_get_databases_by_courses', params, preSets); + + const currentData = response.databases.find((data) => data[key] == value); + if (currentData) { + return currentData; + } + + throw new CoreError('Activity not found'); + } + + /** + * Get a data by course module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the data is retrieved. + */ + getDatabase(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getDatabaseByKey(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a data by ID. + * + * @param courseId Course ID. + * @param id Data ID. + * @param options Other options. + * @return Promise resolved when the data is retrieved. + */ + getDatabaseById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getDatabaseByKey(courseId, 'id', id, options); + } + + /** + * Get prefix cache key for all database access information data WS calls. + * + * @param dataId Data ID. + * @return Cache key. + */ + protected getDatabaseAccessInformationDataPrefixCacheKey(dataId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':access:'; + } + + /** + * Get cache key for database access information data WS calls. + * + * @param dataId Data ID. + * @param groupId Group ID. + * @return Cache key. + */ + protected getDatabaseAccessInformationDataCacheKey(dataId: number, groupId: number = 0): string { + return this.getDatabaseAccessInformationDataPrefixCacheKey(dataId) + groupId; + } + + /** + * Get access information for a given database. + * + * @param dataId Data ID. + * @param options Other options. + * @return Promise resolved when the database is retrieved. + */ + async getDatabaseAccessInformation( + dataId: number, + options: AddonModDataAccessInfoOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + options.groupId = options.groupId || 0; + + const params: AddonModDataGetDataAccessInformationWSParams = { + databaseid: dataId, + groupid: options.groupId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDatabaseAccessInformationDataCacheKey(dataId, options.groupId), + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_data_get_data_access_information', params, preSets); + } + + /** + * Get entries for a specific database and group. + * + * @param dataId Data ID. + * @param options Other options. + * @return Promise resolved when the database is retrieved. + */ + async getEntries(dataId: number, options: AddonModDataGetEntriesOptions = {}): Promise { + options = Object.assign({ + groupId: 0, + sort: 0, + order: 'DESC', + page: 0, + perPage: AddonModDataProvider.PER_PAGE, + }, options); + + const site = await CoreSites.getSite(options.siteId); + // Always use sort and order params to improve cache usage (entries are identified by params). + const params: AddonModDataGetEntriesWSParams = { + databaseid: dataId, + returncontents: true, + page: options.page, + perpage: options.perPage, + groupid: options.groupId, + sort: options.sort, + order: options.order, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getEntriesCacheKey(dataId, options.groupId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_data_get_entries', params, preSets); + + const entriesFormatted = response.entries.map((entry) => this.formatEntryContents(entry)); + + return Object.assign(response, { + entries: entriesFormatted, + }); + } + + /** + * Get cache key for database entries data WS calls. + * + * @param dataId Data ID. + * @param groupId Group ID. + * @return Cache key. + */ + protected getEntriesCacheKey(dataId: number, groupId: number = 0): string { + return this.getEntriesPrefixCacheKey(dataId) + groupId; + } + + /** + * Get prefix cache key for database all entries data WS calls. + * + * @param dataId Data ID. + * @return Cache key. + */ + protected getEntriesPrefixCacheKey(dataId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':entries:'; + } + + /** + * Get an entry of the database activity. + * + * @param dataId Data ID for caching purposes. + * @param entryId Entry ID. + * @param options Other options. + * @return Promise resolved when the entry is retrieved. + */ + async getEntry( + dataId: number, + entryId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModDataGetEntryWSParams = { + entryid: entryId, + returncontents: true, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getEntryCacheKey(dataId, entryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_data_get_entry', params, preSets); + + return Object.assign(response, { + entry: this.formatEntryContents(response.entry), + }); + } + + /** + * Formats the contents of an entry. + * + * @param entry Original WS entry. + * @returns Entry with contents formatted. + */ + protected formatEntryContents(entry: AddonModDataEntryWS): AddonModDataEntry { + return Object.assign(entry, { + contents: CoreUtils.arrayToObject(entry.contents, 'fieldid'), + }); + } + + /** + * Get cache key for database entry data WS calls. + * + * @param dataId Data ID for caching purposes. + * @param entryId Entry ID. + * @return Cache key. + */ + protected getEntryCacheKey(dataId: number, entryId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':entry:' + entryId; + } + + /** + * Get the list of configured fields for the given database. + * + * @param dataId Data ID. + * @param options Other options. + * @return Promise resolved when the fields are retrieved. + */ + async getFields(dataId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModDataGetFieldsWSParams = { + databaseid: dataId, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getFieldsCacheKey(dataId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_data_get_fields', params, preSets); + if (response.fields) { + return response.fields; + } + + throw new CoreError('No fields were returned.'); + } + + /** + * Get cache key for database fields data WS calls. + * + * @param dataId Data ID. + * @return Cache key. + */ + protected getFieldsCacheKey(dataId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':fields'; + } + + /** + * Invalidate the prefetched content. + * To invalidate files, use AddonModDataProvider#invalidateFiles. + * + * @param moduleId The module ID. + * @param courseId Course ID of the module. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const promises: Promise[] = []; + + promises.push(this.getDatabase(courseId, moduleId).then(async (database) => { + const ps: Promise[] = []; + + // Do not invalidate module data before getting module info, we need it! + ps.push(this.invalidateDatabaseData(courseId, siteId)); + ps.push(this.invalidateDatabaseWSData(database.id, siteId)); + ps.push(this.invalidateFieldsData(database.id, siteId)); + + await Promise.all(ps); + + return; + })); + + promises.push(this.invalidateFiles(moduleId, siteId)); + + await CoreUtils.allPromises(promises); + } + + /** + * Invalidates database access information data. + * + * @param dataId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDatabaseAccessInformationData(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getDatabaseAccessInformationDataPrefixCacheKey(dataId)); + } + + /** + * Invalidates database entries data. + * + * @param dataId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateEntriesData(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getEntriesPrefixCacheKey(dataId)); + } + + /** + * Invalidates database fields data. + * + * @param dataId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateFieldsData(dataId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getFieldsCacheKey(dataId)); + } + + /** + * Invalidate the prefetched files. + * + * @param moduleId The module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the files are invalidated. + */ + async invalidateFiles(moduleId: number, siteId?: string): Promise { + await CoreFilepool.invalidateFilesByComponent(siteId, AddonModDataProvider.COMPONENT, moduleId); + } + + /** + * Invalidates database data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDatabaseData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getDatabaseDataCacheKey(courseId)); + } + + /** + * Invalidates database data except files and module info. + * + * @param databaseId Data ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateDatabaseWSData(databaseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getDatabaseDataPrefixCacheKey(databaseId)); + } + + /** + * Invalidates database entry data. + * + * @param dataId Data ID for caching purposes. + * @param entryId Entry ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateEntryData(dataId: number, entryId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getEntryCacheKey(dataId, entryId)); + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the database WS are available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + * @since 3.3 + */ + async isPluginEnabled(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.wsAvailable('mod_data_get_data_access_information'); + } + + /** + * Report the database as being viewed. + * + * @param id Module ID. + * @param name Name of the data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logView(id: number, name?: string, siteId?: string): Promise { + const params: AddonModDataViewDatabaseWSParams = { + databaseid: id, + }; + + await CoreCourseLogHelper.logSingle( + 'mod_data_view_database', + params, + AddonModDataProvider.COMPONENT, + id, + name, + 'data', + {}, + siteId, + ); + } + + /** + * Performs search over a database. + * + * @param dataId The data instance id. + * @param options Other options. + * @return Promise resolved when the action is done. + */ + async searchEntries(dataId: number, options: AddonModDataSearchEntriesOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + options.groupId = options.groupId || 0; + options.sort = options.sort || 0; + options.order || options.order || 'DESC'; + options.page = options.page || 0; + options.perPage = options.perPage || AddonModDataProvider.PER_PAGE; + options.readingStrategy = options.readingStrategy || CoreSitesReadingStrategy.PreferNetwork; + + const params: AddonModDataSearchEntriesWSParams = { + databaseid: dataId, + groupid: options.groupId, + returncontents: true, + page: options.page, + perpage: options.perPage, + }; + const preSets: CoreSiteWSPreSets = { + component: AddonModDataProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + if (typeof options.sort != 'undefined') { + params.sort = options.sort; + } + if (typeof options.order !== 'undefined') { + params.order = options.order; + } + if (typeof options.search !== 'undefined') { + params.search = options.search; + } + if (typeof options.advSearch !== 'undefined') { + params.advsearch = options.advSearch; + } + const response = await site.read('mod_data_search_entries', params, preSets); + + const entriesFormatted = response.entries.map((entry) => this.formatEntryContents(entry)); + + return Object.assign(response, { + entries: entriesFormatted, + }); + } + +} +export const AddonModData = makeSingleton(AddonModDataProvider); + +/** + * Params of mod_data_view_database WS. + */ +type AddonModDataViewDatabaseWSParams = { + databaseid: number; // Data instance id. +}; + +/** + * Params of mod_data_search_entries WS. + */ +type AddonModDataSearchEntriesWSParams = { + databaseid: number; // Data instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + returncontents?: boolean; // Whether to return contents or not. + search?: string; // Search string (empty when using advanced). + advsearch?: AddonModDataSearchEntriesAdvancedField[]; + sort?: number; // Sort the records by this field id, reserved ids are: + // 0: timeadded + // -1: firstname + // -2: lastname + // -3: approved + // -4: timemodified. + // Empty for using the default database setting. + order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Empty for using the default database setting. + page?: number; // The page of records to return. + perpage?: number; // The number of records to return per page. +}; + +/** + * Data returned by mod_data_search_entries WS. + */ +export type AddonModDataSearchEntriesWSResponse = { + entries: AddonModDataEntryWS[]; + totalcount: number; // Total count of records returned by the search. + maxcount?: number; // Total count of records that the user could see in the database (if all the search criterias were removed). + listviewcontents?: string; // The list view contents as is rendered in the site. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Options to pass to get access info. + */ +export type AddonModDataAccessInfoOptions = CoreCourseCommonModWSOptions & { + groupId?: number; // Group Id. +}; + +/** + * Options to pass to get entries. + */ +export type AddonModDataGetEntriesOptions = CoreCourseCommonModWSOptions & { + groupId?: number; // Group Id. + sort?: number; // Sort the records by this field id, defaults to 0. Reserved ids are: + // 0: timeadded + // -1: firstname + // -2: lastname + // -3: approved + // -4: timemodified + order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Defaults to 'DESC'. + page?: number; // Page of records to return. Defaults to 0. + perPage?: number; // Records per page to return. Defaults to AddonModDataProvider.PER_PAGE. +}; + +/** + * Options to pass to search entries. + */ +export type AddonModDataSearchEntriesOptions = AddonModDataGetEntriesOptions & { + search?: string; // Search text. It will be used if advSearch is not defined. + advSearch?: AddonModDataSearchEntriesAdvancedField[]; +}; + +/** + * Database entry (online or offline). + */ +export type AddonModDataEntry = Omit & { + contents: AddonModDataEntryFields; // The record contents. + tags?: CoreTagItem[]; // Tags. + // Calculated data. + deleted?: boolean; // Entry is deleted offline. + hasOffline?: boolean; // Entry has offline actions. +}; + +/** + * Database entry data from WS. + */ +export type AddonModDataEntryWS = { + id: number; // Record id. + userid: number; // The id of the user who created the record. + groupid: number; // The group id this record belongs to (0 for no groups). + dataid: number; // The database id this record belongs to. + timecreated: number; // Time the record was created. + timemodified: number; // Last time the record was modified. + approved: boolean; // Whether the entry has been approved (if the database is configured in that way). + canmanageentry: boolean; // Whether the current user can manage this entry. + fullname?: string; // The user who created the entry fullname. + contents?: AddonModDataEntryField[]; + tags?: CoreTagItem[]; // Tags. +}; + +/** + * Entry field content. + */ +export type AddonModDataEntryField = { + id: number; // Content id. + fieldid: number; // The field type of the content. + recordid: number; // The record this content belongs to. + content: string; // Contents. + content1: string; // Contents. + content2: string; // Contents. + content3: string; // Contents. + content4: string; // Contents. + files: (CoreWSExternalFile | FileEntry)[]; +}; + +/** + * Entry contents indexed by field id. + */ +export type AddonModDataEntryFields = { + [fieldid: number]: AddonModDataEntryField; +}; + +/** + * List of entries returned by web service and helper functions. + */ +export type AddonModDataEntries = { + entries: AddonModDataEntry[]; // Online entries. + totalcount: number; // Total count of online entries or found entries. + maxcount?: number; // Total count of online entries. Only returned when searching. + offlineEntries?: AddonModDataEntry[]; // Offline entries. + hasOfflineActions?: boolean; // Whether the database has offline data. + hasOfflineRatings?: boolean; // Whether the database has offline ratings. +}; + +/** + * Subfield form data. + */ +export type AddonModDataSubfieldData = { + fieldid: number; + subfield?: string; + value?: unknown; // Value encoded in JSON. + files?: (CoreWSExternalFile | FileEntry)[]; +}; + +/** + * Params of mod_data_get_data_access_information WS. + */ +type AddonModDataGetDataAccessInformationWSParams = { + databaseid: number; // Database instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. +}; + +/** + * Data returned by mod_data_get_data_access_information WS. + */ +export type AddonModDataGetDataAccessInformationWSResponse = { + groupid: number; // User current group id (calculated). + canaddentry: boolean; // Whether the user can add entries or not. + canmanageentries: boolean; // Whether the user can manage entries or not. + canapprove: boolean; // Whether the user can approve entries or not. + timeavailable: boolean; // Whether the database is available or not by time restrictions. + inreadonlyperiod: boolean; // Whether the database is in read mode only. + numentries: number; // The number of entries the current user added. + entrieslefttoadd: number; // The number of entries left to complete the activity. + entrieslefttoview: number; // The number of entries left to view other users entries. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_data_get_databases_by_courses WS. + */ +type AddonModDataGetDatabasesByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_data_get_databases_by_courses WS. + */ +type AddonModDataGetDatabasesByCoursesWSResponse = { + databases: AddonModDataData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Database data returned by mod_assign_get_assignments. + */ +export type AddonModDataData = { + id: number; // Database id. + course: number; // Course id. + name: string; // Database name. + intro: string; // The Database intro. + introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + comments: boolean; // Comments enabled. + timeavailablefrom: number; // Timeavailablefrom field. + timeavailableto: number; // Timeavailableto field. + timeviewfrom: number; // Timeviewfrom field. + timeviewto: number; // Timeviewto field. + requiredentries: number; // Requiredentries field. + requiredentriestoview: number; // Requiredentriestoview field. + maxentries: number; // Maxentries field. + rssarticles: number; // Rssarticles field. + singletemplate: string; // Singletemplate field. + listtemplate: string; // Listtemplate field. + listtemplateheader: string; // Listtemplateheader field. + listtemplatefooter: string; // Listtemplatefooter field. + addtemplate: string; // Addtemplate field. + rsstemplate: string; // Rsstemplate field. + rsstitletemplate: string; // Rsstitletemplate field. + csstemplate: string; // Csstemplate field. + jstemplate: string; // Jstemplate field. + asearchtemplate: string; // Asearchtemplate field. + approval: boolean; // Approval field. + manageapproved: boolean; // Manageapproved field. + scale?: number; // Scale field. + assessed?: number; // Assessed field. + assesstimestart?: number; // Assesstimestart field. + assesstimefinish?: number; // Assesstimefinish field. + defaultsort: number; // Defaultsort field. + defaultsortdir: number; // Defaultsortdir field. + editany?: boolean; // Editany field (not used any more). + notification?: number; // Notification field (not used any more). + timemodified?: number; // Time modified. + coursemodule: number; // Coursemodule. + introfiles?: CoreWSExternalFile[]; +}; + +/** + * Params of mod_data_add_entry WS. + */ +type AddonModDataAddEntryWSParams = { + databaseid: number; // Data instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + data: AddonModDataEntryWSField[]; // The fields data to be created. +}; + +/** + * Data returned by mod_data_add_entry WS. + */ +export type AddonModDataAddEntryWSResponse = { + newentryid: number; // True new created entry id. 0 if the entry was not created. + generalnotifications: string[]; + fieldnotifications: AddonModDataFieldNotification[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_data_approve_entry WS. + */ +type AddonModDataApproveEntryWSParams = { + entryid: number; // Record entry id. + approve?: boolean; // Whether to approve (true) or unapprove the entry. +}; + +/** + * Params of mod_data_delete_entry WS. + */ +type AddonModDataDeleteEntryWSParams = { + entryid: number; // Record entry id. +}; + +/** + * Params of mod_data_update_entry WS. + */ +type AddonModDataUpdateEntryWSParams = { + entryid: number; // The entry record id. + data: AddonModDataEntryWSField[]; // The fields data to be updated. +}; + +/** + * Data returned by mod_data_update_entry WS. + */ +export type AddonModDataUpdateEntryWSResponse = { + updated: boolean; // True if the entry was successfully updated, false other wise. + generalnotifications: string[]; + fieldnotifications: AddonModDataFieldNotification[]; + warnings?: CoreWSExternalWarning[]; +}; + +// The fields data to be created or updated. +export type AddonModDataEntryWSField = { + fieldid: number; // The field id. AddonModDataSubfieldData + subfield?: string; // The subfield name (if required). + value: string; // The contents for the field always JSON encoded. +}; + +/** + * Params of mod_data_get_entries WS. + */ +type AddonModDataGetEntriesWSParams = { + databaseid: number; // Data instance id. + groupid?: number; // Group id, 0 means that the function will determine the user group. + returncontents?: boolean; // Whether to return contents or not. This will return each entry raw contents and the complete list + // view(using the template). + sort?: number; // Sort the records by this field id, reserved ids are: + // 0: timeadded + // -1: firstname + // -2: lastname + // -3: approved + // -4: timemodified. + // Empty for using the default database setting. + order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Empty for using the default database setting. + page?: number; // The page of records to return. + perpage?: number; // The number of records to return per page. +}; + +/** + * Data returned by mod_data_get_entries WS. + */ +export type AddonModDataGetEntriesWSResponse = { + entries: AddonModDataEntryWS[]; + totalcount: number; // Total count of records. + totalfilesize: number; // Total size (bytes) of the files included in the records. + listviewcontents?: string; // The list view contents as is rendered in the site. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_data_get_entry WS. + */ +type AddonModDataGetEntryWSParams = { + entryid: number; // Record entry id. + returncontents?: boolean; // Whether to return contents or not. +}; + +/** + * Data returned by mod_data_get_entry WS. + */ +type AddonModDataGetEntryWSResponse = { + entry: AddonModDataEntryWS; + entryviewcontents?: string; // The entry as is rendered in the site. + ratinginfo?: CoreRatingInfo; // Rating information. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data returned by mod_data_get_entry WS. + */ +export type AddonModDataGetEntryFormatted = { + entry: AddonModDataEntry; + entryviewcontents?: string; // The entry as is rendered in the site. + ratinginfo?: CoreRatingInfo; // Rating information. + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonModDataFieldNotification = { + fieldname: string; // The field name. + notification: string; // The notification for the field. +}; + +/** + * Params of mod_data_get_fields WS. + */ +type AddonModDataGetFieldsWSParams = { + databaseid: number; // Database instance id. +}; + +/** + * Data returned by mod_data_get_fields WS. + */ +type AddonModDataGetFieldsWSResponse = { + fields: AddonModDataField[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Field data returned by mod_data_get_fields WS. + */ +export type AddonModDataField = { + id: number; // Field id. + dataid: number; // The field type of the content. + type: string; // The field type. + name: string; // The field name. + description: string; // The field description. + required: boolean; // Whether is a field required or not. + param1: string; // Field parameters. + param2: string; // Field parameters. + param3: string; // Field parameters. + param4: string; // Field parameters. + param5: string; // Field parameters. + param6: string; // Field parameters. + param7: string; // Field parameters. + param8: string; // Field parameters. + param9: string; // Field parameters. + param10: string; // Field parameters. +}; + +export type AddonModDataEntryChangedEventData = { + dataId: number; + entryId?: number; + deleted?: boolean; +}; + +/** + * Advanced search field. + */ +export type AddonModDataSearchEntriesAdvancedField = { + name: string; // Field key for search. Use fn or ln for first or last name. + value: string; // JSON encoded value for search. +}; + +/** + * Advanced search field. + */ +export type AddonModDataSearchEntriesAdvancedFieldFormatted = { + name: string; // Field key for search. Use fn or ln for first or last name. + value: unknown; // JSON encoded value for search. +}; + +export type AddonModDataAddEntryResult = Partial & { + sent?: boolean; // True if sent, false if stored offline. +}; + +export type AddonModDataApproveEntryResult = { + sent?: boolean; // True if sent, false if stored offline. +}; + +export type AddonModDataEditEntryResult = Partial & { + sent?: boolean; // True if sent, false if stored offline. +}; diff --git a/src/addons/mod/data/services/database/data.ts b/src/addons/mod/data/services/database/data.ts new file mode 100644 index 000000000..203274e78 --- /dev/null +++ b/src/addons/mod/data/services/database/data.ts @@ -0,0 +1,83 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreSiteSchema } from '@services/sites'; +import { AddonModDataAction } from '../data'; + +/** + * Database variables for AddonModDataOfflineProvider. + */ +export const DATA_ENTRY_TABLE = 'addon_mod_data_entry_1'; +export const ADDON_MOD_DATA_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModDataOfflineProvider', + version: 1, + tables: [ + { + name: DATA_ENTRY_TABLE, + columns: [ + { + name: 'dataid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'groupid', + type: 'INTEGER', + }, + { + name: 'action', + type: 'TEXT', + }, + { + name: 'entryid', + type: 'INTEGER', + }, + { + name: 'fields', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + primaryKeys: ['dataid', 'entryid', 'action'], + }, + ], + async migrate(db: SQLiteDB, oldVersion: number): Promise { + if (oldVersion > 0) { + return; + } + + // Move the records from the old table. + await db.migrateTable('addon_mod_data_entry', DATA_ENTRY_TABLE); + }, +}; + +/** + * Data about data entries to sync. + */ +export type AddonModDataEntryDBRecord = { + dataid: number; // Primary key. + entryid: number; // Primary key. Negative for offline entries. + action: AddonModDataAction; // Primary key. + courseid: number; + groupid: number; + fields: string; + timemodified: number; +}; diff --git a/src/addons/mod/data/services/handlers/approve-link.ts b/src/addons/mod/data/services/handlers/approve-link.ts new file mode 100644 index 000000000..7dfdc3173 --- /dev/null +++ b/src/addons/mod/data/services/handlers/approve-link.ts @@ -0,0 +1,63 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; +import { AddonModDataHelper } from '../data-helper'; + +/** + * Content links handler for database approve/disapprove entry. + * Match mod/data/view.php?d=6&approve=5 with a valid data id and entryid. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataApproveLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModDataApproveLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModData'; + pattern = /\/mod\/data\/view\.php.*([?&](d|approve|disapprove)=\d+)/; + priority = 50; // Higher priority than the default link handler for view.php. + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Params, courseId?: number): CoreContentLinksAction[] { + return [{ + action: (siteId): void => { + const dataId = parseInt(params.d, 10); + const entryId = parseInt(params.approve, 10) || parseInt(params.disapprove, 10); + const approve = parseInt(params.approve, 10) ? true : false; + + AddonModDataHelper.approveOrDisapproveEntry(dataId, entryId, approve, courseId, siteId); + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + if (typeof params.d == 'undefined' || (typeof params.approve == 'undefined' && typeof params.disapprove == 'undefined')) { + // Required fields not defined. Cannot treat the URL. + return false; + } + + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataApproveLinkHandler = makeSingleton(AddonModDataApproveLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/default-field.ts b/src/addons/mod/data/services/handlers/default-field.ts new file mode 100644 index 000000000..3eec28e1a --- /dev/null +++ b/src/addons/mod/data/services/handlers/default-field.ts @@ -0,0 +1,78 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { Injectable } from '@angular/core'; +import { FileEntry } from '@ionic-native/file'; +import { CoreWSExternalFile } from '@services/ws'; +import { AddonModDataEntryField, AddonModDataSearchEntriesAdvancedFieldFormatted, AddonModDataSubfieldData } from '../data'; +import { AddonModDataFieldHandler } from '../data-fields-delegate'; + +/** + * Default handler used when a field plugin doesn't have a specific implementation. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataDefaultFieldHandler implements AddonModDataFieldHandler { + + name = 'AddonModDataDefaultFieldHandler'; + type = 'default'; + + /** + * @inheritdoc + */ + getFieldSearchData(): AddonModDataSearchEntriesAdvancedFieldFormatted[] { + return []; + } + + /** + * @inheritdoc + */ + getFieldEditData(): AddonModDataSubfieldData[] { + return []; + } + + /** + * @inheritdoc + */ + hasFieldDataChanged(): boolean { + return false; + } + + /** + * @inheritdoc + */ + getFieldEditFiles(): (CoreWSExternalFile | FileEntry)[] { + return []; + } + + /** + * @inheritdoc + */ + getFieldsNotifications(): undefined { + return; + } + + /** + * @inheritdoc + */ + overrideData(originalContent: AddonModDataEntryField): AddonModDataEntryField { + return originalContent; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + +} diff --git a/src/addons/mod/data/services/handlers/delete-link.ts b/src/addons/mod/data/services/handlers/delete-link.ts new file mode 100644 index 000000000..6fd66bc4b --- /dev/null +++ b/src/addons/mod/data/services/handlers/delete-link.ts @@ -0,0 +1,61 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; +import { AddonModDataHelper } from '../data-helper'; + +/** + * Content links handler for database delete entry. + * Match mod/data/view.php?d=6&delete=5 with a valid data id and entryid. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataDeleteLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModDataDeleteLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModData'; + pattern = /\/mod\/data\/view\.php.*([?&](d|delete)=\d+)/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Params, courseId?: number): CoreContentLinksAction[] { + return [{ + action: (siteId): void => { + const dataId = parseInt(params.d, 10); + const entryId = parseInt(params.delete, 10); + + AddonModDataHelper.showDeleteEntryModal(dataId, entryId, courseId, siteId); + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + if (typeof params.d == 'undefined' || typeof params.delete == 'undefined') { + // Required fields not defined. Cannot treat the URL. + return false; + } + + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataDeleteLinkHandler = makeSingleton(AddonModDataDeleteLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/edit-link.ts b/src/addons/mod/data/services/handlers/edit-link.ts new file mode 100644 index 000000000..36acd0cfa --- /dev/null +++ b/src/addons/mod/data/services/handlers/edit-link.ts @@ -0,0 +1,79 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; +import { AddonModDataModuleHandlerService } from './module'; + +/** + * Content links handler for database add or edit entry. + * Match mod/data/edit.php?d=6&rid=6 with a valid data and optional record id. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataEditLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModDataEditLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModData'; + pattern = /\/mod\/data\/edit\.php.*([?&](d|rid)=\d+)/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] { + return [{ + action: async (siteId): Promise => { + const modal = await CoreDomUtils.showModalLoading(); + const dataId = parseInt(params.d, 10); + const rId = params.rid || ''; + + try { + const module = await CoreCourse.getModuleBasicInfoByInstance(dataId, 'data', siteId); + const pageParams: Params = { + module, + courseId: module.course, + }; + + CoreNavigator.navigateToSitePath( + `${AddonModDataModuleHandlerService.PAGE_NAME}/${module.course}/${module.id}/edit/${rId}`, + { siteId, params: pageParams }, + ); + } finally { + // Just in case. In fact we need to dismiss the modal before showing a toast or error message. + modal.dismiss(); + } + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + if (typeof params.d == 'undefined') { + // Id not defined. Cannot treat the URL. + return false; + } + + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataEditLinkHandler = makeSingleton(AddonModDataEditLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/index-link.ts b/src/addons/mod/data/services/handlers/index-link.ts new file mode 100644 index 000000000..a831add07 --- /dev/null +++ b/src/addons/mod/data/services/handlers/index-link.ts @@ -0,0 +1,40 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; + +/** + * Handler to treat links to data. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModDataLinkHandler'; + + constructor() { + super('AddonModData', 'data', 'd'); + } + + /** + * @inheritdoc + */ + isEnabled(siteId: string): Promise { + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataIndexLinkHandler = makeSingleton(AddonModDataIndexLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/list-link.ts b/src/addons/mod/data/services/handlers/list-link.ts new file mode 100644 index 000000000..1bbd94082 --- /dev/null +++ b/src/addons/mod/data/services/handlers/list-link.ts @@ -0,0 +1,40 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; + +/** + * Handler to treat links to data list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModDataListLinkHandler'; + + constructor() { + super('AddonModData', 'data'); + } + + /** + * @inheritdoc + */ + isEnabled(siteId?: string): Promise { + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataListLinkHandler = makeSingleton(AddonModDataListLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/module.ts b/src/addons/mod/data/services/handlers/module.ts new file mode 100644 index 000000000..c48f8bb97 --- /dev/null +++ b/src/addons/mod/data/services/handlers/module.ts @@ -0,0 +1,85 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreConstants } from '@/core/constants'; +import { Injectable, Type } from '@angular/core'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonModDataIndexComponent } from '../../components/index'; +import { AddonModData } from '../data'; + +/** + * Handler to support data modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_data'; + + name = 'AddonModData'; + modName = 'data'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + [CoreConstants.FEATURE_RATE]: true, + [CoreConstants.FEATURE_COMMENT]: true, + }; + + /** + * @inheritdoc + */ + isEnabled(): Promise { + return AddonModData.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_data-handler', + showDownloadButton: true, + action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + const routeParams = '/' + courseId + '/' + module.id; + + CoreNavigator.navigateToSitePath(AddonModDataModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * @inheritdoc + */ + async getMainComponent(): Promise> { + return AddonModDataIndexComponent; + } + +} +export const AddonModDataModuleHandler = makeSingleton(AddonModDataModuleHandlerService); diff --git a/src/addons/mod/data/services/handlers/prefetch.ts b/src/addons/mod/data/services/handlers/prefetch.ts new file mode 100644 index 000000000..7a8106596 --- /dev/null +++ b/src/addons/mod/data/services/handlers/prefetch.ts @@ -0,0 +1,300 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreComments } from '@features/comments/services/comments'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourseCommonModWSOptions, CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreFilepool } from '@services/filepool'; +import { CoreGroup, CoreGroups } from '@services/groups'; +import { CoreSitesCommonWSOptions, CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModDataProvider, AddonModDataEntry, AddonModData, AddonModDataData } from '../data'; +import { AddonModDataSync, AddonModDataSyncResult } from '../data-sync'; + +/** + * Handler to prefetch databases. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModData'; + modName = 'data'; + component = AddonModDataProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^entries$|^gradeitems$|^outcomes$|^comments$|^ratings/; + + /** + * Retrieves all the entries for all the groups and then returns only unique entries. + * + * @param dataId Database Id. + * @param groups Array of groups in the activity. + * @param options Other options. + * @return All unique entries. + */ + protected async getAllUniqueEntries( + dataId: number, + groups: CoreGroup[], + options: CoreSitesCommonWSOptions = {}, + ): Promise { + + const promises = groups.map((group) => AddonModData.fetchAllEntries(dataId, { + groupId: group.id, + ...options, // Include all options. + })); + + const responses = await Promise.all(promises); + + const uniqueEntries: Record = {}; + responses.forEach((groupEntries) => { + groupEntries.forEach((entry) => { + uniqueEntries[entry.id] = entry; + }); + }); + + return CoreUtils.objectToArray(uniqueEntries); + } + + /** + * Helper function to get all database info just once. + * + * @param module Module to get the files. + * @param courseId Course ID the module belongs to. + * @param omitFail True to always return even if fails. Default false. + * @param options Other options. + * @return Promise resolved with the info fetched. + */ + protected async getDatabaseInfoHelper( + module: CoreCourseAnyModuleData, + courseId: number, + omitFail: boolean, + options: CoreCourseCommonModWSOptions = {}, + ): Promise<{ database: AddonModDataData; groups: CoreGroup[]; entries: AddonModDataEntry[]; files: CoreWSExternalFile[]}> { + let groups: CoreGroup[] = []; + let entries: AddonModDataEntry[] = []; + let files: CoreWSExternalFile[] = []; + + options.cmId = options.cmId || module.id; + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + const database = await AddonModData.getDatabase(courseId, module.id, options); + + try { + files = this.getIntroFilesFromInstance(module, database); + + const groupInfo = await CoreGroups.getActivityGroupInfo(module.id, false, undefined, options.siteId); + if (!groupInfo.groups || groupInfo.groups.length == 0) { + groupInfo.groups = [{ id: 0, name: '' }]; + } + groups = groupInfo.groups || []; + + entries = await this.getAllUniqueEntries(database.id, groups, options); + files = files.concat(this.getEntriesFiles(entries)); + + return { + database, + groups, + entries, + files, + }; + } catch (error) { + if (omitFail) { + // Any error, return the info we have. + return { + database, + groups, + entries, + files, + }; + } + + throw error; + } + } + + /** + * Returns the file contained in the entries. + * + * @param entries List of entries to get files from. + * @return List of files. + */ + protected getEntriesFiles(entries: AddonModDataEntry[]): CoreWSExternalFile[] { + let files: CoreWSExternalFile[] = []; + + entries.forEach((entry) => { + CoreUtils.objectToArray(entry.contents).forEach((content) => { + files = files.concat(content.files); + }); + }); + + return files; + } + + /** + * @inheritdoc + */ + async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + return this.getDatabaseInfoHelper(module, courseId, true).then((info) => info.files); + } + + /** + * @inheritdoc + */ + async getIntroFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + const data = await CoreUtils.ignoreErrors(AddonModData.getDatabase(courseId, module.id)); + + return this.getIntroFilesFromInstance(module, data); + } + + /** + * @inheritdoc + */ + async invalidateContent(moduleId: number, courseId: number): Promise { + await AddonModData.invalidateContent(moduleId, courseId); + } + + /** + * @inheritdoc + */ + async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + const promises: Promise[] = []; + promises.push(AddonModData.invalidateDatabaseData(courseId)); + promises.push(AddonModData.invalidateDatabaseAccessInformationData(module.instance!)); + + await Promise.all(promises); + } + + /** + * @inheritdoc + */ + async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise { + const database = await AddonModData.getDatabase(courseId, module.id, { + readingStrategy: CoreSitesReadingStrategy.PreferCache, + }); + + const accessData = await AddonModData.getDatabaseAccessInformation(database.id, { cmId: module.id }); + // Check if database is restricted by time. + if (!accessData.timeavailable) { + const time = CoreTimeUtils.timestamp(); + + // It is restricted, checking times. + if (database.timeavailablefrom && time < database.timeavailablefrom) { + return false; + } + if (database.timeavailableto && time > database.timeavailableto) { + return false; + } + } + + return true; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return AddonModData.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise { + return this.prefetchPackage(module, courseId, this.prefetchDatabase.bind(this, module, courseId)); + } + + /** + * Prefetch a database. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + protected async prefetchDatabase(module: CoreCourseAnyModuleData, courseId?: number): Promise { + const siteId = CoreSites.getCurrentSiteId(); + courseId = courseId || module.course || CoreSites.getCurrentSiteHomeId(); + + const options = { + cmId: module.id, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + const info = await this.getDatabaseInfoHelper(module, courseId, false, options); + + // Prefetch the database data. + const database = info.database; + + const commentsEnabled = !CoreComments.areCommentsDisabledInSite(); + + const promises: Promise[] = []; + + promises.push(AddonModData.getFields(database.id, options)); + promises.push(CoreFilepool.addFilesToQueue(siteId, info.files, this.component, module.id)); + + info.groups.forEach((group) => { + promises.push(AddonModData.getDatabaseAccessInformation(database.id, { + groupId: group.id, + ...options, // Include all options. + })); + }); + + info.entries.forEach((entry) => { + promises.push(AddonModData.getEntry(database.id, entry.id, options)); + + if (commentsEnabled && database.comments) { + promises.push(CoreComments.getComments( + 'module', + database.coursemodule, + 'mod_data', + entry.id, + 'database_entry', + 0, + siteId, + )); + } + }); + + // Add Basic Info to manage links. + promises.push(CoreCourse.getModuleBasicInfoByInstance(database.id, 'data', siteId)); + + await Promise.all(promises); + } + + /** + * Sync a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + const promises = [ + AddonModDataSync.syncDatabase(module.instance!, siteId), + AddonModDataSync.syncRatings(module.id, true, siteId), + ]; + + const results = await Promise.all(promises); + + return results.reduce((a, b) => ({ + updated: a.updated || b.updated, + warnings: (a.warnings || []).concat(b.warnings || []), + }), { updated: false , warnings: [] }); + } + +} +export const AddonModDataPrefetchHandler = makeSingleton(AddonModDataPrefetchHandlerService); diff --git a/src/addons/mod/data/services/handlers/show-link.ts b/src/addons/mod/data/services/handlers/show-link.ts new file mode 100644 index 000000000..63beb6200 --- /dev/null +++ b/src/addons/mod/data/services/handlers/show-link.ts @@ -0,0 +1,94 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; +import { AddonModDataModuleHandlerService } from './module'; + +/** + * Content links handler for database show entry. + * Match mod/data/view.php?d=6&rid=5 with a valid data id and entryid. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataShowLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModDataShowLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModData'; + pattern = /\/mod\/data\/view\.php.*([?&](d|rid|page|group|mode)=\d+)/; + priority = 50; // Higher priority than the default link handler for view.php. + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] { + return [{ + action: async (siteId): Promise => { + const modal = await CoreDomUtils.showModalLoading(); + const dataId = parseInt(params.d, 10); + const rId = params.rid || ''; + const group = parseInt(params.group, 10) || false; + const page = parseInt(params.page, 10) || false; + + try { + const module = await CoreCourse.getModuleBasicInfoByInstance(dataId, 'data', siteId); + const pageParams: Params = { + module: module, + courseId: module.course, + }; + + if (group) { + pageParams.group = group; + } + + if (params.mode && params.mode == 'single') { + pageParams.offset = page || 0; + } + + CoreNavigator.navigateToSitePath( + `${AddonModDataModuleHandlerService.PAGE_NAME}/${module.course}/${module.id}/${rId}`, + { siteId, params: pageParams }, + ); + } finally { + // Just in case. In fact we need to dismiss the modal before showing a toast or error message. + modal.dismiss(); + } + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Params): Promise { + if (typeof params.d == 'undefined') { + // Id not defined. Cannot treat the URL. + return false; + } + + if ((!params.mode || params.mode != 'single') && typeof params.rid == 'undefined') { + return false; + } + + return AddonModData.isPluginEnabled(siteId); + } + +} +export const AddonModDataShowLinkHandler = makeSingleton(AddonModDataShowLinkHandlerService); diff --git a/src/addons/mod/data/services/handlers/sync-cron.ts b/src/addons/mod/data/services/handlers/sync-cron.ts new file mode 100644 index 000000000..daab609e2 --- /dev/null +++ b/src/addons/mod/data/services/handlers/sync-cron.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModDataSync } from '../data-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModDataSyncCronHandler'; + + /** + * @inheritdoc + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModDataSync.syncAllDatabases(siteId, force); + } + + /** + * @inheritdoc + */ + getInterval(): number { + return AddonModDataSync.syncInterval; + } + +} +export const AddonModDataSyncCronHandler = makeSingleton(AddonModDataSyncCronHandlerService); diff --git a/src/addons/mod/data/services/handlers/tag-area.ts b/src/addons/mod/data/services/handlers/tag-area.ts new file mode 100644 index 000000000..31341389e --- /dev/null +++ b/src/addons/mod/data/services/handlers/tag-area.ts @@ -0,0 +1,53 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Type } from '@angular/core'; +import { CoreTagFeedComponent } from '@features/tag/components/feed/feed'; +import { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate'; +import { CoreTagFeedElement, CoreTagHelper } from '@features/tag/services/tag-helper'; +import { makeSingleton } from '@singletons'; +import { AddonModData } from '../data'; + +/** + * Handler to support tags. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModDataTagAreaHandlerService implements CoreTagAreaHandler { + + name = 'AddonModDataTagAreaHandler'; + type = 'mod_data/data_records'; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return AddonModData.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + parseContent(content: string): CoreTagFeedElement[] { + return CoreTagHelper.parseFeedContent(content); + } + + /** + * @inheritdoc + */ + getComponent(): Type { + return CoreTagFeedComponent; + } + +} +export const AddonModDataTagAreaHandler = makeSingleton(AddonModDataTagAreaHandlerService); diff --git a/src/addons/mod/forum/components/edit-post/edit-post.ts b/src/addons/mod/forum/components/edit-post/edit-post.ts index 958db0662..1070e5c4b 100644 --- a/src/addons/mod/forum/components/edit-post/edit-post.ts +++ b/src/addons/mod/forum/components/edit-post/edit-post.ts @@ -20,6 +20,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { ModalController, Translate } from '@singletons'; import { AddonModForumData, AddonModForumPost, AddonModForumReply } from '@addons/mod/forum/services/forum'; import { AddonModForumHelper } from '@addons/mod/forum/services/forum-helper'; +import { CoreForms } from '@singletons/form'; /** * Page that displays a form to edit discussion post. @@ -93,9 +94,9 @@ export class AddonModForumEditPostComponent implements OnInit { } if (data) { - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); } else { - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); } ModalController.dismiss(data); diff --git a/src/addons/mod/forum/components/post/post.ts b/src/addons/mod/forum/components/post/post.ts index 3e244193b..e86cfd393 100644 --- a/src/addons/mod/forum/components/post/post.ts +++ b/src/addons/mod/forum/components/post/post.ts @@ -53,6 +53,7 @@ import { CoreUtils } from '@services/utils/utils'; import { AddonModForumPostOptionsMenuComponent } from '../post-options-menu/post-options-menu'; import { AddonModForumEditPostComponent } from '../edit-post/edit-post'; import { CoreRatingInfo } from '@features/rating/services/rating'; +import { CoreForms } from '@singletons/form'; /** * Components that shows a discussion post, its attachments and the action buttons allowed (reply, etc.). @@ -129,7 +130,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges ngOnChanges(changes: {[name: string]: SimpleChange}): void { if (changes.leavingPage && this.leavingPage) { // Download all courses is enabled now, initialize it. - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); } } @@ -498,7 +499,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges this.onPostChange.emit(); - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.getCurrentSiteId()); if (this.syncId) { CoreSync.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); @@ -520,7 +521,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges // Reset data. this.setReplyFormData(); - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); if (this.syncId) { CoreSync.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId); diff --git a/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts b/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts index 8ff69c794..390672083 100644 --- a/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts +++ b/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts @@ -39,6 +39,7 @@ import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; import { CoreTextUtils } from '@services/utils/text'; import { CanLeave } from '@guards/can-leave'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreForms } from '@singletons/form'; type NewDiscussionData = { subject: string; @@ -519,7 +520,7 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea CoreDomUtils.showErrorModalDefault(null, 'addon.mod_forum.errorposttoallgroups', true); } - CoreDomUtils.triggerFormSubmittedEvent( + CoreForms.triggerFormSubmittedEvent( this.formElement, !!discussionIds, CoreSites.getCurrentSiteId(), @@ -551,7 +552,7 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea await Promise.all(promises); - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); this.returnToDiscussions(); } catch (error) { @@ -585,7 +586,7 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea CoreFileUploader.clearTmpFiles(this.newDiscussion.files); if (this.formElement) { - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); } return true; diff --git a/src/addons/mod/forum/services/forum-sync.ts b/src/addons/mod/forum/services/forum-sync.ts index 2cec0080d..fae896b0c 100644 --- a/src/addons/mod/forum/services/forum-sync.ts +++ b/src/addons/mod/forum/services/forum-sync.ts @@ -330,12 +330,12 @@ export class AddonModForumSyncProvider extends CoreCourseActivitySyncBaseProvide updated = true; // Invalidate discussions of updated ratings. - promises.push(AddonModForum.invalidateDiscussionPosts(result.itemSet!.itemSetId, undefined, siteId)); + promises.push(AddonModForum.invalidateDiscussionPosts(result.itemSet.itemSetId, undefined, siteId)); } if (result.warnings.length) { // Fetch forum to construct the warning message. - promises.push(AddonModForum.getForum(result.itemSet!.courseId!, result.itemSet!.instanceId, { siteId }) + promises.push(AddonModForum.getForum(result.itemSet.courseId, result.itemSet.instanceId, { siteId }) .then((forum) => { result.warnings.forEach((warning) => { this.addOfflineDataDeletedWarning(warnings, forum.name, warning); diff --git a/src/addons/mod/lesson/components/index/index.ts b/src/addons/mod/lesson/components/index/index.ts index 125d6f009..25c9c6400 100644 --- a/src/addons/mod/lesson/components/index/index.ts +++ b/src/addons/mod/lesson/components/index/index.ts @@ -24,6 +24,7 @@ import { IonContent, IonInput } from '@ionic/angular'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreForms } from '@singletons/form'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; @@ -645,7 +646,7 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo this.refreshIcon = CoreConstants.ICON_REFRESH; this.syncIcon = CoreConstants.ICON_SYNC; - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, true, this.siteId); + CoreForms.triggerFormSubmittedEvent(this.formElement, true, this.siteId); } } diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.ts b/src/addons/mod/lesson/components/password-modal/password-modal.ts index 871316793..a163b454e 100644 --- a/src/addons/mod/lesson/components/password-modal/password-modal.ts +++ b/src/addons/mod/lesson/components/password-modal/password-modal.ts @@ -16,7 +16,7 @@ import { Component, ViewChild, ElementRef } from '@angular/core'; import { IonInput } from '@ionic/angular'; import { CoreSites } from '@services/sites'; -import { CoreDomUtils } from '@services/utils/dom'; +import { CoreForms } from '@singletons/form'; import { ModalController } from '@singletons'; /** @@ -40,7 +40,7 @@ export class AddonModLessonPasswordModalComponent { e.preventDefault(); e.stopPropagation(); - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); ModalController.dismiss(password.value); } @@ -49,7 +49,7 @@ export class AddonModLessonPasswordModalComponent { * Close modal. */ closeModal(): void { - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); ModalController.dismiss(); } diff --git a/src/addons/mod/lesson/pages/player/player.page.ts b/src/addons/mod/lesson/pages/player/player.page.ts index e6d39e5d8..f030eb7f3 100644 --- a/src/addons/mod/lesson/pages/player/player.page.ts +++ b/src/addons/mod/lesson/pages/player/player.page.ts @@ -53,6 +53,7 @@ import { } from '../../services/lesson-helper'; import { AddonModLessonOffline } from '../../services/lesson-offline'; import { AddonModLessonSync } from '../../services/lesson-sync'; +import { CoreFormFields, CoreForms } from '@singletons/form'; /** * Page that allows attempting and reviewing a lesson. @@ -90,7 +91,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave { processDataButtons: ProcessDataButton[] = []; // Buttons to display after processing a page. loaded?: boolean; // Whether data has been loaded. displayMenu?: boolean; // Whether the lesson menu should be displayed. - originalData?: Record; // Original question data. It is used to check if data has changed. + originalData?: CoreFormFields; // Original question data. It is used to check if data has changed. reviewPageId?: number; // Page to open if the user wants to review the attempt. courseId!: number; // The course ID the lesson belongs to. lessonPages?: AddonModLessonPageWSData[]; // Lesson pages (for the lesson menu). @@ -164,7 +165,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave { } } - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); return true; } @@ -605,7 +606,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave { * @param formSubmitted Whether a form was submitted. * @return Promise resolved when done. */ - protected async processPage(data: Record, formSubmitted?: boolean): Promise { + protected async processPage(data: CoreFormFields, formSubmitted?: boolean): Promise { this.loaded = false; const options: AddonModLessonProcessPageOptions = { @@ -630,7 +631,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave { ); if (formSubmitted) { - CoreDomUtils.triggerFormSubmittedEvent( + CoreForms.triggerFormSubmittedEvent( this.formElement, result.sent, CoreSites.getCurrentSiteId(), diff --git a/src/addons/mod/lesson/services/lesson-helper.ts b/src/addons/mod/lesson/services/lesson-helper.ts index f7035f99d..cebbb9f88 100644 --- a/src/addons/mod/lesson/services/lesson-helper.ts +++ b/src/addons/mod/lesson/services/lesson-helper.ts @@ -16,6 +16,7 @@ import { Injectable } from '@angular/core'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreFormFields } from '@singletons/form'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; import { makeSingleton, Translate } from '@singletons'; @@ -550,7 +551,7 @@ export class AddonModLessonHelperProvider { * @param data Data to prepare. * @return Data to send. */ - prepareQuestionData(question: AddonModLessonQuestion, data: Record): Record { + prepareQuestionData(question: AddonModLessonQuestion, data: CoreFormFields): CoreFormFields { if (question.template == 'essay') { const textarea = ( question).textarea; diff --git a/src/addons/mod/lesson/services/lesson-offline.ts b/src/addons/mod/lesson/services/lesson-offline.ts index 41b7f836a..4a4297372 100644 --- a/src/addons/mod/lesson/services/lesson-offline.ts +++ b/src/addons/mod/lesson/services/lesson-offline.ts @@ -14,6 +14,7 @@ import { Injectable } from '@angular/core'; import { CoreSites } from '@services/sites'; +import { CoreFormFields } from '@singletons/form'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; @@ -484,7 +485,7 @@ export class AddonModLessonOfflineProvider { courseId: number, retake: number, page: AddonModLessonPageWSData, - data: Record, + data: CoreFormFields, newPageId: number, answerId?: number, correct?: boolean, @@ -552,7 +553,7 @@ export const AddonModLessonOffline = makeSingleton(AddonModLessonOfflineProvider * Attempt DB record with parsed data. */ export type AddonModLessonPageAttemptRecord = Omit & { - data: Record | null; + data: CoreFormFields | null; useranswer: unknown | null; }; diff --git a/src/addons/mod/lesson/services/lesson.ts b/src/addons/mod/lesson/services/lesson.ts index 5bff0aceb..4fa222b27 100644 --- a/src/addons/mod/lesson/services/lesson.ts +++ b/src/addons/mod/lesson/services/lesson.ts @@ -2165,7 +2165,7 @@ export class AddonModLessonProvider { const site = await CoreSites.getSite(options.siteId); const userId = options.userId || site.getUserId(); - const params: ModLessonGetUserTimersWSParams = { + const params: AddonModLessonGetUserTimersWSParams = { lessonid: lessonId, userid: userId, }; @@ -2176,7 +2176,7 @@ export class AddonModLessonProvider { ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. }; - const response = await site.read('mod_lesson_get_user_timers', params, preSets); + const response = await site.read('mod_lesson_get_user_timers', params, preSets); return response.timers; } @@ -4027,7 +4027,7 @@ export type AddonModLessonAttemptsOverviewsAttemptWSData = { /** * Params of mod_lesson_get_user_timers WS. */ -export type ModLessonGetUserTimersWSParams = { +export type AddonModLessonGetUserTimersWSParams = { lessonid: number; // Lesson instance id. userid?: number; // The user id (empty for current user). }; @@ -4035,7 +4035,7 @@ export type ModLessonGetUserTimersWSParams = { /** * Data returned by mod_lesson_get_user_timers WS. */ -export type ModLessonGetUserTimersWSResponse = { +export type AddonModLessonGetUserTimersWSResponse = { timers: AddonModLessonUserTimerWSData[]; warnings?: CoreWSExternalWarning[]; }; diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index f16e25ea1..a3c6ba745 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { AddonModAssignModule } from './assign/assign.module'; import { AddonModBookModule } from './book/book.module'; +import { AddonModDataModule } from './data/data.module'; import { AddonModFolderModule } from './folder/folder.module'; import { AddonModForumModule } from './forum/forum.module'; import { AddonModLabelModule } from './label/label.module'; @@ -32,10 +33,10 @@ import { AddonModScormModule } from './scorm/scorm.module'; import { AddonModChoiceModule } from './choice/choice.module'; @NgModule({ - declarations: [], imports: [ AddonModAssignModule, AddonModBookModule, + AddonModDataModule, AddonModForumModule, AddonModLessonModule, AddonModPageModule, @@ -51,7 +52,5 @@ import { AddonModChoiceModule } from './choice/choice.module'; AddonModScormModule, AddonModChoiceModule, ], - providers: [], - exports: [], }) export class AddonModModule { } diff --git a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts index 5a9dd4c12..2bc91c409 100644 --- a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts +++ b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts @@ -18,6 +18,7 @@ import { IonContent } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreForms } from '@singletons/form'; import { ModalController, Translate } from '@singletons'; import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../../services/quiz'; @@ -126,7 +127,7 @@ export class AddonModQuizPreflightModalComponent implements OnInit { CoreDomUtils.showErrorModal('core.errorinvalidform', true); } } else { - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, false, this.siteId); + CoreForms.triggerFormSubmittedEvent(this.formElement, false, this.siteId); ModalController.dismiss(this.preflightForm.value); } @@ -136,7 +137,7 @@ export class AddonModQuizPreflightModalComponent implements OnInit { * Close modal. */ closeModal(): void { - CoreDomUtils.triggerFormCancelledEvent(this.formElement, this.siteId); + CoreForms.triggerFormCancelledEvent(this.formElement, this.siteId); ModalController.dismiss(); } diff --git a/src/addons/mod/quiz/pages/player/player.page.ts b/src/addons/mod/quiz/pages/player/player.page.ts index d956922f9..f6925f43f 100644 --- a/src/addons/mod/quiz/pages/player/player.page.ts +++ b/src/addons/mod/quiz/pages/player/player.page.ts @@ -44,6 +44,7 @@ import { import { AddonModQuizAttempt, AddonModQuizHelper } from '../../services/quiz-helper'; import { AddonModQuizSync } from '../../services/quiz-sync'; import { CanLeave } from '@guards/can-leave'; +import { CoreForms } from '@singletons/form'; /** * Page that allows attempting a quiz. @@ -160,7 +161,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave { await CoreDomUtils.showConfirm(Translate.instant('addon.mod_quiz.confirmleavequizonerror')); - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); } finally { modal.dismiss(); } @@ -672,7 +673,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave { this.autoSave.hideAutoSaveError(); if (this.formElement) { - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, !this.offline, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, !this.offline, CoreSites.getCurrentSiteId()); } return CoreQuestionHelper.clearTmpData(this.questions, this.component, this.quiz!.coursemodule); diff --git a/src/addons/mod/survey/components/index/addon-mod-survey-index.html b/src/addons/mod/survey/components/index/addon-mod-survey-index.html index 89f5cad45..0879ce799 100644 --- a/src/addons/mod/survey/components/index/addon-mod-survey-index.html +++ b/src/addons/mod/survey/components/index/addon-mod-survey-index.html @@ -55,7 +55,8 @@
- +

{{ question.text }}

diff --git a/src/addons/mod/survey/pages/index/index.html b/src/addons/mod/survey/pages/index/index.html index 3ccf0efe9..7aee62174 100644 --- a/src/addons/mod/survey/pages/index/index.html +++ b/src/addons/mod/survey/pages/index/index.html @@ -3,7 +3,9 @@ - + + + diff --git a/src/addons/notes/components/add/add-modal.ts b/src/addons/notes/components/add/add-modal.ts index 6ab234f07..d784b1605 100644 --- a/src/addons/notes/components/add/add-modal.ts +++ b/src/addons/notes/components/add/add-modal.ts @@ -17,6 +17,7 @@ import { Component, ViewChild, ElementRef, Input } from '@angular/core'; import { CoreApp } from '@services/app'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreForms } from '@singletons/form'; import { ModalController } from '@singletons'; /** @@ -53,7 +54,7 @@ export class AddonNotesAddComponent { this.userId = this.userId || CoreSites.getCurrentSiteUserId(); const sent = await AddonNotes.addNote(this.userId, this.courseId, this.type, this.text); - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, sent, CoreSites.getCurrentSiteId()); ModalController.dismiss({ type: this.type, sent: true }).finally(() => { CoreDomUtils.showToast(sent ? 'addon.notes.eventnotecreated' : 'core.datastoredoffline', true, 3000); @@ -70,7 +71,7 @@ export class AddonNotesAddComponent { * Close modal. */ closeModal(): void { - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); ModalController.dismiss({ type: this.type }); } diff --git a/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts b/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts index 3611f612b..887770465 100644 --- a/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts +++ b/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts @@ -17,6 +17,7 @@ import { Injectable, Type } from '@angular/core'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; import { CoreUserProfileField } from '@features/user/services/user'; import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { CoreFormFields } from '@singletons/form'; import { makeSingleton } from '@singletons'; import { AddonUserProfileFieldCheckboxComponent } from '../../component/checkbox'; @@ -51,7 +52,7 @@ export class AddonUserProfileFieldCheckboxHandlerService implements CoreUserProf field: AuthEmailSignupProfileField | CoreUserProfileField, signup: boolean, registerAuth: string, - formValues: Record, + formValues: CoreFormFields, ): Promise { const name = 'profile_field_' + field.shortname; diff --git a/src/addons/userprofilefield/datetime/services/handlers/datetime.ts b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts index ba0bd7d67..413ad1ccd 100644 --- a/src/addons/userprofilefield/datetime/services/handlers/datetime.ts +++ b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts @@ -17,6 +17,7 @@ import { Injectable, Type } from '@angular/core'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; import { CoreUserProfileField } from '@features/user/services/user'; import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { CoreFormFields } from '@singletons/form'; import { CoreTimeUtils } from '@services/utils/time'; import { makeSingleton } from '@singletons'; import { AddonUserProfileFieldDatetimeComponent } from '../../component/datetime'; @@ -52,7 +53,7 @@ export class AddonUserProfileFieldDatetimeHandlerService implements CoreUserProf field: AuthEmailSignupProfileField | CoreUserProfileField, signup: boolean, registerAuth: string, - formValues: Record, + formValues: CoreFormFields, ): Promise { const name = 'profile_field_' + field.shortname; diff --git a/src/addons/userprofilefield/menu/services/handlers/menu.ts b/src/addons/userprofilefield/menu/services/handlers/menu.ts index 9379b1b2f..ad111bd7e 100644 --- a/src/addons/userprofilefield/menu/services/handlers/menu.ts +++ b/src/addons/userprofilefield/menu/services/handlers/menu.ts @@ -17,6 +17,7 @@ import { Injectable, Type } from '@angular/core'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; import { CoreUserProfileField } from '@features/user/services/user'; import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { CoreFormFields } from '@singletons/form'; import { makeSingleton } from '@singletons'; import { AddonUserProfileFieldMenuComponent } from '../../component/menu'; @@ -51,7 +52,7 @@ export class AddonUserProfileFieldMenuHandlerService implements CoreUserProfileF field: AuthEmailSignupProfileField | CoreUserProfileField, signup: boolean, registerAuth: string, - formValues: Record, + formValues: CoreFormFields, ): Promise { const name = 'profile_field_' + field.shortname; diff --git a/src/addons/userprofilefield/text/services/handlers/text.ts b/src/addons/userprofilefield/text/services/handlers/text.ts index 28335b0d2..ac1edda13 100644 --- a/src/addons/userprofilefield/text/services/handlers/text.ts +++ b/src/addons/userprofilefield/text/services/handlers/text.ts @@ -20,6 +20,7 @@ import { CoreTextUtils } from '@services/utils/text'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; import { CoreUserProfileField } from '@features/user/services/user'; import { makeSingleton } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; /** * Text user profile field handlers. @@ -52,7 +53,7 @@ export class AddonUserProfileFieldTextHandlerService implements CoreUserProfileF field: AuthEmailSignupProfileField | CoreUserProfileField, signup: boolean, registerAuth: string, - formValues: Record, + formValues: CoreFormFields, ): Promise { const name = 'profile_field_' + field.shortname; diff --git a/src/addons/userprofilefield/textarea/services/handlers/textarea.ts b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts index a3aff7885..3a6823027 100644 --- a/src/addons/userprofilefield/textarea/services/handlers/textarea.ts +++ b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts @@ -20,6 +20,7 @@ import { CoreTextUtils } from '@services/utils/text'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; import { CoreUserProfileField } from '@features/user/services/user'; import { makeSingleton } from '@singletons'; +import { CoreFormFields } from '@singletons/form'; /** * Textarea user profile field handlers. @@ -52,7 +53,7 @@ export class AddonUserProfileFieldTextareaHandlerService implements CoreUserProf field: AuthEmailSignupProfileField | CoreUserProfileField, signup: boolean, registerAuth: string, - formValues: Record, + formValues: CoreFormFields, ): Promise { const name = 'profile_field_' + field.shortname; diff --git a/src/core/classes/sqlitedb.ts b/src/core/classes/sqlitedb.ts index 4aa39378c..4c1deb91b 100644 --- a/src/core/classes/sqlitedb.ts +++ b/src/core/classes/sqlitedb.ts @@ -856,6 +856,49 @@ export class SQLiteDB { await this.execute(`INSERT INTO ${table} SELECT ${fields} FROM ${source} ${select}`, params); } + /** + * Helper migration function for tables. + * It will check if old table exists and drop it when finished. + * + * @param oldTable Old table name. + * @param newTable New table name. + * @param mapCallback Mapping callback to migrate each record. + * @return Resolved when done. + */ + async migrateTable( + oldTable: string, + newTable: string, + mapCallback?: (record: SQLiteDBRecordValues) => SQLiteDBRecordValues, + ): Promise { + try { + await this.tableExists(oldTable); + } catch (error) { + // Old table does not exist, ignore. + return; + } + + // Move the records from the old table. + if (mapCallback) { + const records = await this.getAllRecords(oldTable); + const promises = records.map((record) => { + record = mapCallback(record); + + return this.insertRecord(newTable, record); + }); + + await Promise.all(promises); + } else { + // No changes needed. + await this.insertRecordsFrom(newTable, oldTable); + } + + try { + await this.dropTable(oldTable); + } catch (error) { + // Error deleting old table, ignore. + } + } + /** * Ensures that limit params are numeric and positive integers, to be passed to the database. * We explicitly treat null, '' and -1 as 0 in order to provide compatibility with how limit diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index e6f72957f..33596bb6b 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -54,6 +54,7 @@ import { CoreLocalFileComponent } from './local-file/local-file'; import { CoreBSTooltipComponent } from './bs-tooltip/bs-tooltip'; import { CoreSitePickerComponent } from './site-picker/site-picker'; import { CoreChartComponent } from './chart/chart'; +import { CoreStyleComponent } from './style/style'; @NgModule({ declarations: [ @@ -69,6 +70,7 @@ import { CoreChartComponent } from './chart/chart'; CoreRecaptchaModalComponent, CoreShowPasswordComponent, CoreSplitViewComponent, + CoreStyleComponent, CoreEmptyBoxComponent, CoreTabsComponent, CoreTabComponent, @@ -112,6 +114,7 @@ import { CoreChartComponent } from './chart/chart'; CoreRecaptchaModalComponent, CoreShowPasswordComponent, CoreSplitViewComponent, + CoreStyleComponent, CoreEmptyBoxComponent, CoreTabsComponent, CoreTabComponent, diff --git a/src/core/components/download-refresh/core-download-refresh.html b/src/core/components/download-refresh/core-download-refresh.html index c70f87a0e..4c9c00db2 100644 --- a/src/core/components/download-refresh/core-download-refresh.html +++ b/src/core/components/download-refresh/core-download-refresh.html @@ -8,7 +8,7 @@ + [attr.aria-label]="(statusTranslatable || 'core.refresh') | translate"> diff --git a/src/core/components/local-file/local-file.ts b/src/core/components/local-file/local-file.ts index a8a15c27f..9cab8887d 100644 --- a/src/core/components/local-file/local-file.ts +++ b/src/core/components/local-file/local-file.ts @@ -24,6 +24,7 @@ import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; +import { CoreForms } from '@singletons/form'; /** * Component to handle a local file. Only files inside the app folder can be managed. @@ -149,7 +150,7 @@ export class CoreLocalFileComponent implements OnInit { if (newName == this.file!.name) { // Name hasn't changed, stop. this.editMode = false; - CoreDomUtils.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); return; } @@ -169,7 +170,7 @@ export class CoreLocalFileComponent implements OnInit { // File doesn't exist, move it. const fileEntry = await CoreFile.moveFile(this.relativePath!, newPath); - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); this.editMode = false; this.file = fileEntry; diff --git a/src/core/components/send-message-form/send-message-form.ts b/src/core/components/send-message-form/send-message-form.ts index 728643f46..aaa69d383 100644 --- a/src/core/components/send-message-form/send-message-form.ts +++ b/src/core/components/send-message-form/send-message-form.ts @@ -19,8 +19,8 @@ import { CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreTextUtils } from '@services/utils/text'; -import { CoreDomUtils } from '@services/utils/dom'; import { CoreConstants } from '@/core/constants'; +import { CoreForms } from '@singletons/form'; /** * Component to display a "send message form". @@ -90,7 +90,7 @@ export class CoreSendMessageFormComponent implements OnInit { this.message = ''; // Reset the form. - CoreDomUtils.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId()); value = CoreTextUtils.replaceNewLines(value, '
'); this.onSubmit.emit(value); diff --git a/src/core/components/style/style.ts b/src/core/components/style/style.ts new file mode 100644 index 000000000..da497ffdd --- /dev/null +++ b/src/core/components/style/style.ts @@ -0,0 +1,77 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ElementRef, Input, OnChanges } from '@angular/core'; + +/** + * Component to add a