MOBILE-3640 database: Add actions and pages
parent
8febbe3ea7
commit
1363951920
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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>
|
|
@ -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[];
|
||||||
|
};
|
|
@ -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>
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in New Issue