// (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 { 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 { AddonModDataModuleHandlerService } from '../../services/handlers/module'; import { AddonModDataPrefetchHandler } from '../../services/handlers/prefetch'; import { AddonModDataComponentsCompileModule } from '../components-compile.module'; import { AddonModDataSearchComponent } from '../search/search'; const contentToken = ''; /** * 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 headerAndFooter = AddonModDataHelper.getTemplate( this.database!, AddonModDataTemplateType.LIST_HEADER, this.fieldsArray, ); headerAndFooter += contentToken; headerAndFooter += AddonModDataHelper.getTemplate( this.database!, AddonModDataTemplateType.LIST_FOOTER, this.fieldsArray, ); headerAndFooter = CoreDomUtils.fixHtml(headerAndFooter); // 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); let entriesHTML = ''; 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, ); }); this.entriesRendered = headerAndFooter.replace(contentToken, 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 modalData = await CoreDomUtils.openModal({ component: AddonModDataSearchComponent, componentProps: { search: this.search, fields: this.fields, database: this.database, }, }); // Add data to search object. if (modalData) { this.search = modalData; 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.navigateToSitePath( `${AddonModDataModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/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. if (!this.search.searching) { const pageXOffset = this.entries.findIndex((entry) => entry.id == entryId); if (pageXOffset >= 0) { params.offset = this.search.page * AddonModDataProvider.PER_PAGE + pageXOffset; } } CoreNavigator.navigateToSitePath( `${AddonModDataModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/${entryId}`, { params }, ); } /** * Performs the sync of the activity. * * @return Promise resolved when done. */ protected sync(): Promise { return 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[]; };