MOBILE-3640 database: Add actions and pages

main
Pau Ferrer Ocaña 2021-03-31 09:25:57 +02:00
parent 8febbe3ea7
commit 1363951920
21 changed files with 2647 additions and 3 deletions

View File

@ -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<void> {
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<void> {
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);
}
}

View File

@ -0,0 +1,42 @@
<ion-button *ngIf="action == 'more'" fill="clear" (click)="viewEntry()" [title]="'addon.mod_data.more' | translate">
<ion-icon name="fas-search" slot="icon-only"></ion-icon>
</ion-button>
<ion-button *ngIf="action == 'edit'" fill="clear" (click)="editEntry()" [title]="'core.edit' | translate">
<ion-icon name="fas-cog" slot="icon-only"></ion-icon>
</ion-button>
<ion-button *ngIf="action == 'delete' && !entry.deleted" fill="clear" (click)="deleteEntry()" [title]="'core.delete' | translate">
<ion-icon name="fas-trash" slot="icon-only"></ion-icon>
</ion-button>
<ion-button *ngIf="action == 'delete' && entry.deleted" fill="clear" (click)="undoDelete()" [title]="'core.restore' | translate">
<ion-icon name="fas-undo-alt" slot="icon-only"></ion-icon>
</ion-button>
<ion-button *ngIf="action == 'approve'" fill="clear" (click)="approveEntry()" [title]="'addon.mod_data.approve' | translate">
<ion-icon name="fas-thumbs-up" slot="icon-only"></ion-icon>
</ion-button>
<ion-button *ngIf="action == 'disapprove'" fill="clear" (click)="disapproveEntry()"
[title]="'addon.mod_data.disapprove' | translate">
<ion-icon name="far-thumbs-down" slot="icon-only"></ion-icon>
</ion-button>
<core-comments *ngIf="action == 'comments' && mode == 'list'" contextLevel="module" [instanceId]="database.coursemodule"
component="mod_data" [itemId]="entry.id" area="database_entry" [courseId]="database.course">
</core-comments>
<span *ngIf="action == 'timeadded'">{{ entry.timecreated * 1000 | coreFormatDate }}</span>
<span *ngIf="action == 'timemodified'">{{ entry.timemodified * 1000 | coreFormatDate }}</span>
<a *ngIf="action == 'userpicture'" core-user-link [courseId]="database.course" [userId]="entry.userid" [title]="entry.fullname">
<img class="avatar-round" [src]="userPicture" [alt]="'core.pictureof' | translate:{$a: entry.fullname}" core-external-content
onError="this.src='assets/img/user-avatar.png'" role="presentation">
</a>
<a *ngIf="action == 'user' && entry" core-user-link [courseId]="database.course" [userId]="entry.userid" [title]="entry.fullname">
{{entry.fullname}}
</a>
<core-tag-list *ngIf="tagsEnabled && action == 'tags' && entry" [tags]="entry.tags"></core-tag-list>

View File

@ -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 {}

View File

@ -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 {}

View File

@ -0,0 +1,154 @@
<!-- Buttons to add to the header. -->
<core-navbar-buttons slot="end">
<ion-button *ngIf="canSearch" (click)="showSearch()" [attr.aria-label]="'addon.mod_data.search' | translate">
<ion-icon name="fas-search" slot="icon-only"></ion-icon>
</ion-button>
<core-context-menu>
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate"
[href]="externalUrl" iconAction="fas-external-link-alt">
</core-context-menu-item>
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate"
(action)="expandDescription()" iconAction="fas-arrow-right">
</core-context-menu-item>
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}"
iconAction="far-newspaper" (action)="gotoBlog()">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && !(hasOffline || hasOfflineRatings) && isOnline" [priority]="700"
[content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon"
[closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="loaded && (hasOffline || hasOfflineRatings) && isOnline" [priority]="600"
[content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon"
[closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item [priority]="500" *ngIf="canAdd" [content]="'addon.mod_data.addentries' | translate"
iconAction="fas-plus" (action)="gotoAddEntries()">
</core-context-menu-item>
<core-context-menu-item [priority]="400" *ngIf="firstEntry" [content]="'addon.mod_data.single' | translate"
iconAction="fas-file" (action)="gotoEntry(firstEntry)">
</core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="300" [content]="prefetchText" (action)="prefetch($event)"
[iconAction]="prefetchStatusIcon" [closeOnClick]="false">
</core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="200" [content]="'core.clearstoreddata' | translate:{$a: size}"
iconDescription="fas-archive" (action)="removeFiles($event)" iconAction="fas-trash" [closeOnClick]="false">
</core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
<!-- Content. -->
<ion-content>
<core-loading [hideUntil]="loaded" class="core-loading-center">
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>
<!-- Data done in offline but not synchronized -->
<ion-card class="core-warning-card" *ngIf="hasOffline || hasOfflineRatings">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
<ion-label>{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}</ion-label>
</ion-item>
</ion-card>
<ion-item class="ion-text-wrap" *ngIf="groupInfo && (groupInfo.separateGroups || groupInfo.visibleGroups)">
<ion-label id="addon-data-groupslabel">
<ng-container *ngIf="groupInfo.separateGroups">{{'core.groupsseparate' | translate }}</ng-container>
<ng-container *ngIf="groupInfo.visibleGroups">{{'core.groupsvisible' | translate }}</ng-container>
</ion-label>
<ion-select [(ngModel)]="selectedGroup" (ionChange)="setGroup(selectedGroup)" aria-labelledby="addon-data-groupslabel"
interface="action-sheet">
<ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id">
{{groupOpt.name}}
</ion-select-option>
</ion-select>
</ion-item>
<ion-card class="core-info-card" *ngIf="!access?.timeavailable && timeAvailableFrom">
<ion-item>
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
<ion-label>{{ 'addon.mod_data.notopenyet' | translate:{$a: timeAvailableFromReadable} }}</ion-label>
</ion-item>
</ion-card>
<ion-card class="core-info-card" *ngIf="!access?.timeavailable && timeAvailableTo">
<ion-item>
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
<ion-label>{{ 'addon.mod_data.expired' | translate:{$a: timeAvailableToReadable} }}</ion-label>
</ion-item>
</ion-card>
<ion-card class="core-info-card" *ngIf="access && access.entrieslefttoview">>
<ion-item>
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
<ion-label>
{{ 'addon.mod_data.entrieslefttoaddtoview' | translate:{$a: {entrieslefttoview: access.entrieslefttoview} } }}
</ion-label>
</ion-item>
</ion-card>
<ion-card class="core-info-card" *ngIf="access && access.entrieslefttoadd">>
<ion-item>
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
<ion-label>
{{ 'addon.mod_data.entrieslefttoadd' | translate:{$a: {entriesleft: access.entrieslefttoadd} } }}
</ion-label>
</ion-item>
</ion-card>
<!-- Reset search. -->
<ng-container *ngIf="search.searching && !isEmpty">
<ion-item *ngIf="!foundRecordsTranslationData">
<ion-label>
<a (click)="searchReset()">{{ 'addon.mod_data.resetsettings' | translate}}</a>
</ion-label>
</ion-item>
<ion-card class="core-success-card" *ngIf="foundRecordsTranslationData" (click)="searchReset()">
<ion-item><ion-label>
<p [innerHTML]="'addon.mod_data.foundrecords' | translate:{$a: foundRecordsTranslationData}"></p>
</ion-label></ion-item>
</ion-card>
</ng-container>
<div class="addon-data-contents addon-data-entries-{{database.id}} ion-padding-horizontal" *ngIf="!isEmpty && database">
<core-style [css]="database.csstemplate" prefix=".addon-data-entries-{{database.id}}"></core-style>
<core-compile-html [text]="entriesRendered" [jsData]="jsData" [extraImports]="extraImports"></core-compile-html>
</div>
<ion-grid *ngIf="search.page > 0 || hasNextPage">
<ion-row class="ion-align-items-center">
<ion-col *ngIf="search.page > 0">
<ion-button expand="block" fill="outline" (click)="searchEntries(search.page - 1)">
<ion-icon name="fas-chevron-left" slot="start"></ion-icon>
{{ 'core.previous' | translate }}
</ion-button>
</ion-col>
<ion-col *ngIf="hasNextPage">
<ion-button expand="block" (click)="searchEntries(search.page + 1)">
{{ 'core.next' | translate }}
<ion-icon name="fas-chevron-right" slot="end"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
<core-empty-box *ngIf="isEmpty && !search.searching" icon="fas-database" [message]="'addon.mod_data.norecords' | translate">
</core-empty-box>
<core-empty-box *ngIf="isEmpty && search.searching" icon="fas-database" [message]="'addon.mod_data.nomatch' | translate"
class="core-empty-box-clickable">
<a (click)="searchReset()">{{ 'addon.mod_data.resetsettings' | translate}}</a>
</core-empty-box>
</core-loading>
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="canAdd">
<ion-fab-button (click)="gotoAddEntries()" [attr.aria-label]="'addon.mod_data.addentries' | translate">
<ion-icon name="fas-plus"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>

View File

@ -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<number, AddonModDataField> = {};
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<unknown>[] = [AddonModDataComponentsCompileModule];
jsData? : {
fields: Record<number, AddonModDataField>;
entries: Record<number, AddonModDataEntry>;
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<void> {
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<void> {
const promises: Promise<void>[] = [];
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<void> {
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<void> {
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<number, AddonModDataEntry> = {};
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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[];
};

View File

@ -0,0 +1,68 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'addon.mod_data.search' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-times" slot="icon-only"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
<ion-label>{{ 'addon.mod_data.advancedsearch' | translate }}</ion-label>
<ion-toggle [(ngModel)]="search.searchingAdvanced"></ion-toggle>
</ion-item>
<form (ngSubmit)="searchEntries($event)" [formGroup]="searchForm" #searchFormEl>
<ion-list class="ion-no-margin">
<ion-item [hidden]="search.searchingAdvanced">
<ion-label></ion-label>
<ion-input type="text" placeholder="{{ 'addon.mod_data.search' | translate}}"
[(ngModel)]="search.text" name="text" formControlName="text">
</ion-input>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label position="stacked">{{ 'core.sortby' | translate }}</ion-label>
<ion-select interface="action-sheet" name="sortBy" formControlName="sortBy"
[placeholder]="'core.sortby' | translate">
<optgroup *ngIf="fieldsArray.length" label="{{ 'addon.mod_data.fields' | translate }}">
<ion-select-option *ngFor="let field of fieldsArray" [value]="field.id">{{field.name}}</ion-select-option>
</optgroup>
<optgroup label="{{ 'addon.mod_data.other' | translate }}">
<ion-select-option value="0">{{ 'addon.mod_data.timeadded' | translate }}</ion-select-option>
<ion-select-option value="-4">{{ 'addon.mod_data.timemodified' | translate }}</ion-select-option>
<ion-select-option value="-1">{{ 'addon.mod_data.authorfirstname' | translate }}</ion-select-option>
<ion-select-option value="-2">{{ 'addon.mod_data.authorlastname' | translate }}</ion-select-option>
<ion-select-option value="-3" *ngIf="database.approval">
{{ 'addon.mod_data.approved' | translate }}
</ion-select-option>
</optgroup>
</ion-select>
</ion-item>
<ion-list >
<ion-radio-group [(ngModel)]="search.sortDirection" name="sortDirection" formControlName="sortDirection">
<ion-item>
<ion-label>{{ 'addon.mod_data.ascending' | translate }}</ion-label>
<ion-radio slot="start" value="ASC"></ion-radio>
</ion-item>
<ion-item>
<ion-label>{{ 'addon.mod_data.descending' | translate }}</ion-label>
<ion-radio slot="start" value="DESC"></ion-radio>
</ion-item>
</ion-radio-group>
</ion-list>
<div class="ion-padding addon-data-advanced-search" [hidden]="!advancedSearch || !search.searchingAdvanced">
<core-compile-html [text]="advancedSearch" [jsData]="jsData" [extraImports]="extraImports"></core-compile-html>
</div>
</ion-list>
<div class="ion-padding">
<ion-button expand="block" type="submit">
<ion-icon name="fas-search" slot="start"></ion-icon>
{{ 'addon.mod_data.search' | translate }}
</ion-button>
</div>
</form>
</ion-content>

View File

@ -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<number, AddonModDataField>;
@Input() database!: AddonModDataData;
advancedSearch = '';
advancedIndexed: CoreFormFields = {};
extraImports: Type<unknown>[] = [AddonModDataComponentsCompileModule];
searchForm: FormGroup;
jsData? : {
fields: Record<number, AddonModDataField>;
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 = '<addon-mod-data-field-plugin mode="search" [field]="fields[' + field.id +
']" [form]="form" [searchFields]="search"></addon-mod-data-field-plugin>';
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 = '<span [formGroup]="form"><ion-input type="text" name="firstname" \
[placeholder]="\'addon.mod_data.authorfirstname\' | translate" formControlName="firstname"></ion-input></span>';
template = template.replace(replaceRegex, render);
// Replace lastname field by the text input.
replaceRegex = new RegExp('##lastname##', 'gi');
render = '<span [formGroup]="form"><ion-input type="text" name="lastname" \
[placeholder]="\'addon.mod_data.authorlastname\' | translate" formControlName="lastname"></ion-input></span>';
template = template.replace(replaceRegex, render);
// Searching by tags is not supported.
replaceRegex = new RegExp('##tags##', 'gi');
const message = CoreTag.areTagsAvailableInSite() ?
'<p class="item-dimmed">{{ \'addon.mod_data.searchbytagsnotsupported\' | translate }}</p>'
: '';
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);
}
}

View File

@ -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);
}
}
}
}
}

View File

@ -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 {}

View File

@ -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<unknown>[] = [
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 {}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,40 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end">
<ion-button *ngIf="entry" fill="clear" (click)="save($event)" [attr.aria-label]="'core.save' | translate">
{{ 'core.save' | translate }}
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<ion-item class="ion-text-wrap" *ngIf="groupInfo && (groupInfo.separateGroups || groupInfo.visibleGroups)">
<ion-label id="addon-data-groupslabel">
<ng-container *ngIf="groupInfo.separateGroups">{{ 'core.groupsvisible' | translate }}</ng-container>
<ng-container *ngIf="groupInfo.visibleGroups">{{ 'core.groupsseparate' | translate }}</ng-container>
</ion-label>
<ion-select [(ngModel)]="selectedGroup" (ionChange)="setGroup(selectedGroup)" aria-labelledby="addon-data-groupslabel"
interface="action-sheet">
<ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id">
{{groupOpt.name}}
</ion-select-option>
</ion-select>
</ion-item>
<div class="addon-data-contents {{cssClass}}" *ngIf="database">
<core-style [css]="database.csstemplate" prefix=".{{cssClass}}"></core-style>
<form (ngSubmit)="save($event)" [formGroup]="editForm" #editFormEl>
<core-compile-html [text]="editFormRender" [jsData]="jsData" [extraImports]="extraImports"></core-compile-html>
</form>
</div>
</core-loading>
</ion-content>

View File

@ -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<number, AddonModDataField> = {};
courseId!: number;
module!: CoreCourseModule;
database?: AddonModDataData;
title = '';
component = AddonModDataProvider.COMPONENT;
loaded = false;
selectedGroup = 0;
cssClass = '';
groupInfo?: CoreGroupInfo;
editFormRender = '';
editForm: FormGroup;
extraImports: Type<unknown>[] = [AddonModDataComponentsCompileModule];
jsData? : {
fields: Record<number, AddonModDataField>;
database?: AddonModDataData;
contents: AddonModDataEntryFields;
errors?: Record<number, string>;
form: FormGroup;
};
errors: Record<number, string> = {};
constructor() {
this.siteId = CoreSites.getCurrentSiteId();
this.editForm = new FormGroup({});
}
/**
* @inheritdoc
*/
ngOnInit(): void {
this.module = CoreNavigator.getRouteParam<CoreCourseModule>('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<boolean> {
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<void> {
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<number, boolean> = {};
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<void> {
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<void>[] = [];
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<void> {
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 = '<addon-mod-data-field-plugin [class.has-errors]="!!errors[' + field.id + ']" mode="edit" \
[field]="fields[' + field.id + ']" [value]="contents[' + field.id + ']" [form]="form" [database]="database" \
[error]="errors[' + field.id + ']"></addon-mod-data-field-plugin>';
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()
? '<p class="item-dimmed">{{ \'addon.mod_data.edittagsnotsupported\' | translate }}</p>'
: '';
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<void> {
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();
}
}
}

View File

@ -0,0 +1,82 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-format-text>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed"
[disabled]="!entryLoaded || !(isPullingToRefresh || !renderingEntry && !loadingRating && !loadingComments)"
(ionRefresh)="refreshDatabase($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="entryLoaded && (isPullingToRefresh || !renderingEntry && !loadingRating && !loadingComments)">
<!-- Database entries found to be synchronized -->
<ion-card class="core-warning-card" *ngIf="entry && entry.hasOffline">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
<ion-label>{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}</ion-label>
</ion-item>
</ion-card>
<ion-item class="ion-text-wrap" *ngIf="groupInfo && (groupInfo.separateGroups || groupInfo.visibleGroups)">
<ion-label id="addon-data-groupslabel">
<ng-container *ngIf="groupInfo.separateGroups">{{ 'core.groupsvisible' | translate }}</ng-container>
<ng-container *ngIf="groupInfo.visibleGroups">{{ 'core.groupsseparate' | translate }}</ng-container>
</ion-label>
<ion-select [(ngModel)]="selectedGroup" (ionChange)="setGroup(selectedGroup)" aria-labelledby="addon-data-groupslabel"
interface="action-sheet">
<ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id">
{{groupOpt.name}}
</ion-select-option>
</ion-select>
</ion-item>
<div class="addon-data-contents addon-data-entries-{{database.id}}" *ngIf="database && entry">
<core-style [css]="database.csstemplate" prefix=".addon-data-entries-{{database.id}}"></core-style>
<core-compile-html [text]="entryHtml" [jsData]="jsData" [extraImports]="extraImports"
(compiling)="setRenderingEntry($event)"></core-compile-html>
</div>
<core-rating-rate *ngIf="database && entry && ratingInfo && (!database.approval || entry.approved)"
[ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="database.coursemodule" [itemId]="entry.id" [itemSetId]="0"
[courseId]="courseId" [aggregateMethod]="database.assessed" [scaleId]="database.scale" [userId]="entry.userid"
(onLoading)="setLoadingRating($event)" (onUpdate)="ratingUpdated()">
</core-rating-rate>
<core-rating-aggregate *ngIf="database && entry && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="database.coursemodule" [itemId]="entry.id" [courseId]="courseId" [aggregateMethod]="database.assessed"
[scaleId]="database.scale">
</core-rating-aggregate>
<ion-item *ngIf="database && database.comments && entry && entry.id > 0 && commentsEnabled">
<ion-label>
<core-comments contextLevel="module" [instanceId]="database.coursemodule" component="mod_data" [itemId]="entry.id"
area="database_entry" [displaySpinner]="false" [courseId]="courseId" (onLoading)="setLoadingComments($event)">
</core-comments>
</ion-label>
</ion-item>
<ion-grid *ngIf="hasPrevious || hasNext">
<ion-row class="ion-align-items-center">
<ion-col *ngIf="hasPrevious">
<ion-button expand="block" fill="outline" (click)="gotoEntry(offset! -1)">
<ion-icon name="fas-chevron-left" slot="start"></ion-icon>
{{ 'core.previous' | translate }}
</ion-button>
</ion-col>
<ion-col *ngIf="hasNext">
<ion-button expand="block" (click)="gotoEntry(offset! + 1)">
{{ 'core.next' | translate }}
<ion-icon name="fas-chevron-right" slot="end"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
</core-loading>
</ion-content>

View File

@ -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<number, AddonModDataField> = {};
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<unknown>[] = [AddonModDataComponentsCompileModule];
jsData? : {
fields: Record<number, AddonModDataField>;
entries: Record<number, AddonModDataEntry>;
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<void> {
this.module = CoreNavigator.getRouteParam<CoreCourseModule>('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<void> {
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<number, AddonModDataEntry> = {};
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<void> {
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<void> {
const promises: Promise<void>[] = [];
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<void> {
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<void> {
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<void> {
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();
}
}

View File

@ -0,0 +1,23 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end">
<!-- The buttons defined by the component will be added in here. -->
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!activityComponent?.loaded" (ionRefresh)="activityComponent?.doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<addon-mod-data-index [module]="module" [courseId]="courseId" [group]="group" (dataRetrieved)="updateData($event)">
</addon-mod-data-index>
</ion-content>

View File

@ -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<AddonModDataIndexComponent> implements OnInit {
@ViewChild(AddonModDataIndexComponent) activityComponent?: AddonModDataIndexComponent;
group = 0;
/**
* Component being initialized.
*/
ngOnInit(): void {
super.ngOnInit();
this.group = CoreNavigator.getRouteNumberParam('group') || 0;
}
}

View File

@ -579,7 +579,7 @@ export class AddonModDataHelperProvider {
entryFieldDataToSend.push({ entryFieldDataToSend.push({
fieldid: fieldSubdata.fieldid, fieldid: fieldSubdata.fieldid,
subfield: fieldSubdata.subfield || '', subfield: fieldSubdata.subfield || '',
value: fieldSubdata.value ? JSON.stringify(value) : '', value: value ? JSON.stringify(value) : '',
}); });
return; return;

View File

@ -4,6 +4,7 @@
--toobar-background: var(--white); --toobar-background: var(--white);
--button-color: var(--ion-text-color); --button-color: var(--ion-text-color);
--button-active-color: var(--gray); --button-active-color: var(--gray);
--background: var(--ion-item-background);
} }
:host-context(body.dark) { :host-context(body.dark) {
@ -39,7 +40,7 @@
border-top: 1px solid var(--ion-color-secondary); border-top: 1px solid var(--ion-color-secondary);
background: var(--background); background: var(--background);
flex-shrink: 1; flex-shrink: 1;
font-size: 1.4rem; font-size: 1.1rem;
.icon { .icon {
color: var(--ion-color-secondary); color: var(--ion-color-secondary);

View File

@ -56,6 +56,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
// Based on: https://github.com/judgewest2000/Ionic3RichText/ // Based on: https://github.com/judgewest2000/Ionic3RichText/
// @todo: Anchor button, fullscreen... // @todo: Anchor button, fullscreen...
// @todo: Textarea height is not being updated when editor is resized. Height is calculated if any css is changed. // @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() placeholder = ''; // Placeholder to set in textarea.
@Input() control?: FormControl; // Form control. @Input() control?: FormControl; // Form control.
@ -724,6 +725,8 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
* Hide the toolbar in phone mode. * Hide the toolbar in phone mode.
*/ */
hideToolbar(event: Event): void { hideToolbar(event: Event): void {
this.element.classList.remove('has-focus');
this.stopBubble(event); this.stopBubble(event);
if (this.isPhone) { if (this.isPhone) {
@ -735,6 +738,10 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
* Show the toolbar. * Show the toolbar.
*/ */
showToolbar(event: Event): void { 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.stopBubble(event);
this.editorElement?.focus(); this.editorElement?.focus();
@ -747,7 +754,9 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
* @param event Event. * @param event Event.
*/ */
stopBubble(event: Event): void { stopBubble(event: Event): void {
event.preventDefault(); if (event.type != 'mouseup') {
event.preventDefault();
}
event.stopPropagation(); event.stopPropagation();
} }
@ -904,6 +913,9 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
return; return;
} }
this.element.classList.add('ion-touched');
this.element.classList.remove('ion-untouched');
let draftText = entry.drafttext || ''; let draftText = entry.drafttext || '';
// Revert untouched editor contents to an empty string. // Revert untouched editor contents to an empty string.