diff --git a/scripts/langindex.json b/scripts/langindex.json index 369488b84..679e4d559 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1800,11 +1800,15 @@ "core.submit": "moodle", "core.success": "moodle", "core.tablet": "local_moodlemobileapp", + "core.tag.errorareanotsupported": "local_moodlemobileapp", + "core.tag.itemstaggedwith": "moodle", + "core.tag.tag": "moodle", "core.tag.tagarea_course": "moodle", "core.tag.tagarea_course_modules": "moodle", "core.tag.tagarea_post": "moodle", "core.tag.tagarea_user": "moodle", "core.tag.tags": "moodle", + "core.tag.warningareasnotsupported": "local_moodlemobileapp", "core.teachers": "moodle", "core.thereisdatatosync": "local_moodlemobileapp", "core.thisdirection": "langconfig", diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 6fc2be13d..69730eab9 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1800,11 +1800,15 @@ "core.submit": "Submit", "core.success": "Success", "core.tablet": "Tablet", + "core.tag.errorareanotsupported": "This tag area is not supported by the app.", + "core.tag.itemstaggedwith": "{{$a.tagarea}} tagged with \"{{$a.tag}}\"", + "core.tag.tag": "Tag", "core.tag.tagarea_course": "Courses", "core.tag.tagarea_course_modules": "Activities and resources", "core.tag.tagarea_post": "Blog posts", "core.tag.tagarea_user": "User interests", "core.tag.tags": "Tags", + "core.tag.warningareasnotsupported": "Some of the tag areas are not displayed because they are not supported by the app.", "core.teachers": "Teachers", "core.thereisdatatosync": "There are offline {{$a}} to be synchronised.", "core.thisdirection": "ltr", diff --git a/src/core/tag/lang/en.json b/src/core/tag/lang/en.json index 02e288849..b7f8b8794 100644 --- a/src/core/tag/lang/en.json +++ b/src/core/tag/lang/en.json @@ -1,7 +1,11 @@ { + "errorareanotsupported": "This tag area is not supported by the app.", + "itemstaggedwith": "{{$a.tagarea}} tagged with \"{{$a.tag}}\"", + "tag": "Tag", "tagarea_course": "Courses", "tagarea_course_modules": "Activities and resources", "tagarea_post": "Blog posts", "tagarea_user": "User interests", - "tags": "Tags" + "tags": "Tags", + "warningareasnotsupported": "Some of the tag areas are not displayed because they are not supported by the app." } diff --git a/src/core/tag/pages/index-area/index-area.html b/src/core/tag/pages/index-area/index-area.html new file mode 100644 index 000000000..8a43d2d51 --- /dev/null +++ b/src/core/tag/pages/index-area/index-area.html @@ -0,0 +1,16 @@ + + + {{ 'core.tag.itemstaggedwith' | translate: { $a: {tagarea: areaNameKey | translate, tag: tagName} } }} + + + + + + + + + + + + + diff --git a/src/core/tag/pages/index-area/index-area.module.ts b/src/core/tag/pages/index-area/index-area.module.ts new file mode 100644 index 000000000..87a49cd7f --- /dev/null +++ b/src/core/tag/pages/index-area/index-area.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreTagIndexAreaPage } from './index-area'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + CoreTagIndexAreaPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreTagIndexAreaPage), + TranslateModule.forChild() + ], +}) +export class CoreTagIndexAreaPageModule {} diff --git a/src/core/tag/pages/index-area/index-area.ts b/src/core/tag/pages/index-area/index-area.ts new file mode 100644 index 000000000..9f71f0532 --- /dev/null +++ b/src/core/tag/pages/index-area/index-area.ts @@ -0,0 +1,150 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTagProvider } from '@core/tag/providers/tag'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; + +/** + * Page that displays the tag index area. + */ +@IonicPage({ segment: 'core-tag-index-area' }) +@Component({ + selector: 'page-core-tag-index-area', + templateUrl: 'index-area.html', +}) +export class CoreTagIndexAreaPage { + tagId: number; + tagName: string; + collectionId: number; + areaId: number; + fromContextId: number; + contextId: number; + recursive: boolean; + areaNameKey: string; + loaded = false; + componentName: string; + itemType: string; + items = []; + nextPage = 0; + canLoadMore = false; + areaComponent: any; + loadMoreError = false; + + constructor(navParams: NavParams, private injector: Injector, private translate: TranslateService, + private tagProvider: CoreTagProvider, private domUtils: CoreDomUtilsProvider, + private tagAreaDelegate: CoreTagAreaDelegate) { + this.tagId = navParams.get('tagId'); + this.tagName = navParams.get('tagName'); + this.collectionId = navParams.get('collectionId'); + this.areaId = navParams.get('areaId'); + this.fromContextId = navParams.get('fromContextId'); + this.contextId = navParams.get('contextId'); + this.recursive = navParams.get('recursive'); + this.areaNameKey = navParams.get('areaNameKey'); + + // Pass the the following parameters to avoid fetching the first page. + this.componentName = navParams.get('componentName'); + this.itemType = navParams.get('itemType'); + this.items = navParams.get('items') || []; + this.nextPage = navParams.get('nextPage') || 0; + this.canLoadMore = !!navParams.get('canLoadMore'); + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + let promise: Promise; + if (!this.componentName || !this.itemType || !this.items.length || this.nextPage == 0) { + promise = this.fetchData(true); + } else { + promise = Promise.resolve(); + } + + promise.then(() => { + return this.tagAreaDelegate.getComponent(this.componentName, this.itemType, this.injector).then((component) => { + this.areaComponent = component; + }); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Fetch next page of the tag index area. + * + * @param {boolean} [refresh=false] Whether to refresh the data or fetch a new page. + * @return {Promise} Resolved when done. + */ + fetchData(refresh: boolean = false): Promise { + this.loadMoreError = false; + const page = refresh ? 0 : this.nextPage; + + return this.tagProvider.getTagIndexPerArea(this.tagId, this.tagName, this.collectionId, this.areaId, this.fromContextId, + this.contextId, this.recursive, page).then((areas) => { + const area = areas[0]; + + return this.tagAreaDelegate.parseContent(area.component, area.itemtype, area.content).then((items) => { + if (!items || !items.length) { + // Tag area not supported. + return Promise.reject(this.translate.instant('core.tag.errorareanotsupported')); + } + + if (page == 0) { + this.items = items; + } else { + this.items.push(...items); + } + this.componentName = area.component; + this.itemType = area.itemtype; + this.areaNameKey = this.tagAreaDelegate.getDisplayNameKey(area.component, area.itemtype); + this.canLoadMore = !!area.nextpageurl; + this.nextPage = page + 1; + }); + }).catch((error) => { + this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + this.domUtils.showErrorModalDefault(error, 'Error loading tag index'); + }); + } + + /** + * Load more items. + * + * @param {any} infiniteComplete Infinite scroll complete function. + * @return {Promise} Resolved when done. + */ + loadMore(infiniteComplete: any): Promise { + return this.fetchData().finally(() => { + infiniteComplete(); + }); + } + + /** + * Refresh data. + * + * @param {any} refresher Refresher. + */ + refreshData(refresher: any): void { + this.tagProvider.invalidateTagIndexPerArea(this.tagId, this.tagName, this.collectionId, this.areaId, this.fromContextId, + this.contextId, this.recursive).finally(() => { + this.fetchData(true).finally(() => { + refresher.complete(); + }); + }); + } +} diff --git a/src/core/tag/pages/index/index.html b/src/core/tag/pages/index/index.html new file mode 100644 index 000000000..5174fcd7c --- /dev/null +++ b/src/core/tag/pages/index/index.html @@ -0,0 +1,24 @@ + + + {{ 'core.tag.tag' | translate }}: {{ tagName }} + + + + + + + + + + + + {{ 'core.tag.warningareasnotsupported' | translate }} + + +

{{ area.nameKey | translate }}

+ {{ area.badge }} +
+
+
+
+
diff --git a/src/core/tag/pages/index/index.module.ts b/src/core/tag/pages/index/index.module.ts new file mode 100644 index 000000000..bb3cd138d --- /dev/null +++ b/src/core/tag/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreTagIndexPage } from './index'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + CoreTagIndexPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreTagIndexPage), + TranslateModule.forChild() + ], +}) +export class CoreTagIndexPageModule {} diff --git a/src/core/tag/pages/index/index.ts b/src/core/tag/pages/index/index.ts new file mode 100644 index 000000000..9185bae0f --- /dev/null +++ b/src/core/tag/pages/index/index.ts @@ -0,0 +1,154 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreTagProvider } from '@core/tag/providers/tag'; +import { CoreTagAreaDelegate } from '@core/tag/providers/area-delegate'; + +/** + * Page that displays the tag index. + */ +@IonicPage({ segment: 'core-tag-index' }) +@Component({ + selector: 'page-core-tag-index', + templateUrl: 'index.html', +}) +export class CoreTagIndexPage { + @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + + tagId: number; + tagName: string; + collectionId: number; + areaId: number; + fromContextId: number; + contextId: number; + recursive: boolean; + loaded = false; + areas: Array<{ + id: number, + componentName: string, + itemType: string, + nameKey: string, + items: any[], + canLoadMore: boolean, + badge: string + }>; + selectedAreaId: number; + hasUnsupportedAreas = false; + + constructor(navParams: NavParams, private tagProvider: CoreTagProvider, private domUtils: CoreDomUtilsProvider, + private tagAreaDelegate: CoreTagAreaDelegate) { + this.tagId = navParams.get('tagId') || 0; + this.tagName = navParams.get('tagName') || ''; + this.collectionId = navParams.get('collectionId'); + this.areaId = navParams.get('areaId') || 0; + this.fromContextId = navParams.get('fromContextId') || 0; + this.contextId = navParams.get('contextId') || 0; + this.recursive = navParams.get('recursive') || true; + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchData().then(() => { + if (this.splitviewCtrl.isOn() && this.areas && this.areas.length > 0) { + const area = this.areas.find((area) => area.id == this.areaId); + this.openArea(area || this.areas[0]); + } + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Fetch first page of tag index per area. + * + * @return {Promise} Resolved when done. + */ + fetchData(): Promise { + return this.tagProvider.getTagIndexPerArea(this.tagId, this.tagName, this.collectionId, this.areaId, this.fromContextId, + this.contextId, this.recursive, 0).then((areas) => { + this.areas = []; + this.hasUnsupportedAreas = false; + + return Promise.all(areas.map((area) => { + return this.tagAreaDelegate.parseContent(area.component, area.itemtype, area.content).then((items) => { + if (!items || !items.length) { + // Tag area not supported, skip. + this.hasUnsupportedAreas = true; + + return null; + } + + return { + id: area.ta, + componentName: area.component, + itemType: area.itemtype, + nameKey: this.tagAreaDelegate.getDisplayNameKey(area.component, area.itemtype), + items, + canLoadMore: !!area.nextpageurl, + badge: items && items.length ? items.length + (area.nextpageurl ? '+' : '') : '', + }; + }); + })).then((areas) => { + this.areas = areas.filter((area) => area != null); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading tag index'); + }); + } + + /** + * Refresh data. + * + * @param {any} refresher Refresher. + */ + refreshData(refresher: any): void { + this.tagProvider.invalidateTagIndexPerArea(this.tagId, this.tagName, this.collectionId, this.areaId, this.fromContextId, + this.contextId, this.recursive).finally(() => { + this.fetchData().finally(() => { + refresher.complete(); + }); + }); + } + + /** + * Navigate to an index area. + * + * @param {any} area Area. + */ + openArea(area: any): void { + this.selectedAreaId = area.id; + const params = { + tagId: this.tagId, + tagName: this.tagName, + collectionId: this.collectionId, + areaId: area.id, + fromContextId: this.fromContextId, + contextId: this.contextId, + recursive: this.recursive, + areaNameKey: area.nameKey, + componentName: area.component, + itemType: area.itemType, + items: area.items.slice(), + canLoadMore: area.canLoadMore, + nextPage: 1 + }; + this.splitviewCtrl.push('CoreTagIndexAreaPage', params); + } +} diff --git a/src/core/tag/providers/tag.ts b/src/core/tag/providers/tag.ts index fdcdaf189..88b739f04 100644 --- a/src/core/tag/providers/tag.ts +++ b/src/core/tag/providers/tag.ts @@ -13,8 +13,27 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; import { CoreSitesProvider } from '@providers/sites'; -import { CoreSite } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; + +/** + * Structure of a tag index returned by WS. + */ +export interface CoreTagIndex { + tagid: number; + ta: number; + component: string; + itemtype: string; + nextpageurl: string; + prevpageurl: string; + exclusiveurl: string; + exclusivetext: string; + title: string; + content: string; + hascontent: number; + anchor: string; +} /** * Structure of a tag item returned by WS. @@ -38,7 +57,9 @@ export interface CoreTagItem { @Injectable() export class CoreTagProvider { - constructor(private sitesProvider: CoreSitesProvider) {} + protected ROOT_CACHE_KEY = 'CoreTag:'; + + constructor(private sitesProvider: CoreSitesProvider, private translate: TranslateService) {} /** * Check whether tags are available in a certain site. @@ -67,4 +88,96 @@ export class CoreTagProvider { site.wsAvailable('core_tag_get_tag_collections') && !site.isFeatureDisabled('NoDelegate_CoreTag'); } + + /** + * Fetch the tag index. + * + * @param {number} [id=0] Tag ID. + * @param {string} [name=''] Tag name. + * @param {number} [collectionId=0] Tag collection ID. + * @param {number} [areaId=0] Tag area ID. + * @param {number} [fromContextId=0] Context ID where the link was displayed. + * @param {number} [contextId=0] Context ID where to search for items. + * @param {boolean} [recursive=true] Search in the context and its children. + * @param {number} [page=0] Page number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the tag index per area. + * @since 3.7 + */ + getTagIndexPerArea(id: number, name: string = '', collectionId: number = 0, areaId: number = 0, fromContextId: number = 0, + contextId: number = 0, recursive: boolean = true, page: number = 0, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + tagindex: { + id: id, + tag: name, + tc: collectionId, + ta: areaId, + excl: true, + from: fromContextId, + ctx: contextId, + rec: recursive, + page: page + }, + }; + const preSets: CoreSiteWSPreSets = { + updateFrequency: CoreSite.FREQUENCY_OFTEN, + cacheKey: this.getTagIndexPerAreaKey(id, name, collectionId, areaId, fromContextId, contextId, recursive) + }; + + return site.read('core_tag_get_tagindex_per_area', params, preSets).catch((error) => { + // Workaround for WS not passing parameter to error string. + if (error && error.errorcode == 'notagsfound') { + error.message = this.translate.instant('core.tag.notagsfound', {$a: name || id || ''}); + } + + return Promise.reject(error); + }).then((response) => { + if (!response || !response.length) { + return Promise.reject(null); + } + + return response; + }); + }); + } + + /** + * Invalidate tag index. + * + * @param {number} [id=0] Tag ID. + * @param {string} [name=''] Tag name. + * @param {number} [collectionId=0] Tag collection ID. + * @param {number} [areaId=0] Tag area ID. + * @param {number} [fromContextId=0] Context ID where the link was displayed. + * @param {number} [contextId=0] Context ID where to search for items. + * @param {boolean} [recursive=true] Search in the context and its children. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateTagIndexPerArea(id: number, name: string = '', collectionId: number = 0, areaId: number = 0, + fromContextId: number = 0, contextId: number = 0, recursive: boolean = true, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getTagIndexPerAreaKey(id, name, collectionId, areaId, fromContextId, contextId, recursive); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Get cache key for tag index. + * + * @param {number} id Tag ID. + * @param {string} name Tag name. + * @param {number} collectionId Tag collection ID. + * @param {number} areaId Tag area ID. + * @param {number} fromContextId Context ID where the link was displayed. + * @param {number} contextId Context ID where to search for items. + * @param {boolean} [recursive=true] Search in the context and its children. + * @return {string} Cache key. + */ + protected getTagIndexPerAreaKey(id: number, name: string, collectionId: number, areaId: number, fromContextId: number, + contextId: number, recursive: boolean): string { + return this.ROOT_CACHE_KEY + 'index:' + id + ':' + name + ':' + collectionId + ':' + areaId + ':' + fromContextId + ':' + + contextId + ':' + (recursive ? 1 : 0); + } }