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/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/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-helper.ts b/src/addons/mod/data/services/data-helper.ts index b2a1f4c3b..567f7d8bb 100644 --- a/src/addons/mod/data/services/data-helper.ts +++ b/src/addons/mod/data/services/data-helper.ts @@ -579,7 +579,7 @@ export class AddonModDataHelperProvider { entryFieldDataToSend.push({ fieldid: fieldSubdata.fieldid, subfield: fieldSubdata.subfield || '', - value: fieldSubdata.value ? JSON.stringify(value) : '', + value: value ? JSON.stringify(value) : '', }); return; diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss index 11299d9de..e8edb8955 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss @@ -4,6 +4,7 @@ --toobar-background: var(--white); --button-color: var(--ion-text-color); --button-active-color: var(--gray); + --background: var(--ion-item-background); } :host-context(body.dark) { @@ -39,7 +40,7 @@ border-top: 1px solid var(--ion-color-secondary); background: var(--background); flex-shrink: 1; - font-size: 1.4rem; + font-size: 1.1rem; .icon { color: var(--ion-color-secondary); diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts index 638d4ac38..65aacd83c 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -56,6 +56,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn // Based on: https://github.com/judgewest2000/Ionic3RichText/ // @todo: Anchor button, fullscreen... // @todo: Textarea height is not being updated when editor is resized. Height is calculated if any css is changed. + // @todo: Implement ControlValueAccessor https://angular.io/api/forms/ControlValueAccessor. @Input() placeholder = ''; // Placeholder to set in textarea. @Input() control?: FormControl; // Form control. @@ -724,6 +725,8 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn * Hide the toolbar in phone mode. */ hideToolbar(event: Event): void { + this.element.classList.remove('has-focus'); + this.stopBubble(event); if (this.isPhone) { @@ -735,6 +738,10 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn * Show the toolbar. */ showToolbar(event: Event): void { + this.element.classList.add('ion-touched'); + this.element.classList.remove('ion-untouched'); + this.element.classList.add('has-focus'); + this.stopBubble(event); this.editorElement?.focus(); @@ -747,7 +754,9 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn * @param event Event. */ stopBubble(event: Event): void { - event.preventDefault(); + if (event.type != 'mouseup') { + event.preventDefault(); + } event.stopPropagation(); } @@ -904,6 +913,9 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn return; } + this.element.classList.add('ion-touched'); + this.element.classList.remove('ion-untouched'); + let draftText = entry.drafttext || ''; // Revert untouched editor contents to an empty string.