From c626bee40772e056f140dde504ee7f0e837a6b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 16 May 2018 16:12:54 +0200 Subject: [PATCH] MOBILE-2338 data: Entry page --- .../mod/data/components/index/index.scss | 28 -- src/addon/mod/data/components/index/index.ts | 10 +- src/addon/mod/data/data.scss | 26 ++ src/addon/mod/data/pages/entry/entry.html | 54 +++ .../mod/data/pages/entry/entry.module.ts | 39 +++ src/addon/mod/data/pages/entry/entry.ts | 307 ++++++++++++++++++ src/addon/mod/data/providers/data.ts | 53 +++ src/addon/mod/data/providers/helper.ts | 127 +++++++- 8 files changed, 609 insertions(+), 35 deletions(-) delete mode 100644 src/addon/mod/data/components/index/index.scss create mode 100644 src/addon/mod/data/data.scss create mode 100644 src/addon/mod/data/pages/entry/entry.html create mode 100644 src/addon/mod/data/pages/entry/entry.module.ts create mode 100644 src/addon/mod/data/pages/entry/entry.ts diff --git a/src/addon/mod/data/components/index/index.scss b/src/addon/mod/data/components/index/index.scss deleted file mode 100644 index 106a72942..000000000 --- a/src/addon/mod/data/components/index/index.scss +++ /dev/null @@ -1,28 +0,0 @@ -addon-mod-data-index { - .addon-data-contents { - overflow: visible; - white-space: normal; - word-break: break-word; - padding: $content-padding; - background-color: white; - border-top-width: 1px; - border-bottom-width: 1px; - border-right-width: 0; - border-left-width: 0; - border-style: solid; - border-color: $list-border-color; - - table, tbody { - display: block; - } - - tr { - @extend .row; - padding: 0; - } - - td, th { - @extend .col; - } - } -} \ No newline at end of file diff --git a/src/addon/mod/data/components/index/index.ts b/src/addon/mod/data/components/index/index.ts index fcfae95e3..e1d85cda6 100644 --- a/src/addon/mod/data/components/index/index.ts +++ b/src/addon/mod/data/components/index/index.ts @@ -75,11 +75,11 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp protected fieldsArray: any; constructor(injector: Injector, private dataProvider: AddonModDataProvider, private dataHelper: AddonModDataHelperProvider, - private dataOffline: AddonModDataOfflineProvider, @Optional() @Optional() content: Content, + private dataOffline: AddonModDataOfflineProvider, @Optional() content: Content, private dataSync: AddonModDataSyncProvider, private timeUtils: CoreTimeUtilsProvider, private groupsProvider: CoreGroupsProvider, private commentsProvider: CoreCommentsProvider, private modalCtrl: ModalController, private utils: CoreUtilsProvider, protected navCtrl: NavController) { - super(injector); + super(injector, content); // Refresh entries on change. this.entryChangedObserver = this.eventsProvider.on(AddonModDataProvider.ENTRY_CHANGED, (eventData) => { @@ -424,9 +424,9 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp gotoEntry(entryId: number): void { const stateParams = { module: this.module, - moduleid: this.module.id, - courseid: this.courseId, - entryid: entryId, + moduleId: this.module.id, + courseId: this.courseId, + entryId: entryId, group: this.selectedGroup }; diff --git a/src/addon/mod/data/data.scss b/src/addon/mod/data/data.scss new file mode 100644 index 000000000..fcc24ecde --- /dev/null +++ b/src/addon/mod/data/data.scss @@ -0,0 +1,26 @@ +.addon-data-contents { + overflow: visible; + white-space: normal; + word-break: break-word; + padding: $content-padding; + background-color: white; + border-top-width: 1px; + border-bottom-width: 1px; + border-right-width: 0; + border-left-width: 0; + border-style: solid; + border-color: $list-border-color; + + table, tbody { + display: block; + } + + tr { + @extend .row; + padding: 0; + } + + td, th { + @extend .col; + } +} diff --git a/src/addon/mod/data/pages/entry/entry.html b/src/addon/mod/data/pages/entry/entry.html new file mode 100644 index 000000000..55db45b17 --- /dev/null +++ b/src/addon/mod/data/pages/entry/entry.html @@ -0,0 +1,54 @@ + + + + + + + + + + + +
+ + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} +
+ + + {{ 'core.groupsseparate' | translate }} + {{ 'core.groupsvisible' | translate }} + + {{groupOpt.name}} + + + +
+ + + +
+ + + + + + + + + + + + + + + +
+
diff --git a/src/addon/mod/data/pages/entry/entry.module.ts b/src/addon/mod/data/pages/entry/entry.module.ts new file mode 100644 index 000000000..cebf202e2 --- /dev/null +++ b/src/addon/mod/data/pages/entry/entry.module.ts @@ -0,0 +1,39 @@ +// (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 { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreCommentsComponentsModule } from '@core/comments/components/components.module'; +import { CoreCompileHtmlComponentModule } from '@core/compile/components/compile-html/compile-html.module'; +import { AddonModDataComponentsModule } from '../../components/components.module'; +import { AddonModDataEntryPage } from './entry'; + +@NgModule({ + declarations: [ + AddonModDataEntryPage, + ], + imports: [ + CoreDirectivesModule, + CoreComponentsModule, + AddonModDataComponentsModule, + CoreCompileHtmlComponentModule, + CoreCommentsComponentsModule, + IonicPageModule.forChild(AddonModDataEntryPage), + TranslateModule.forChild() + ], +}) +export class AddonModDataEntryPageModule {} diff --git a/src/addon/mod/data/pages/entry/entry.ts b/src/addon/mod/data/pages/entry/entry.ts new file mode 100644 index 000000000..2dce9294e --- /dev/null +++ b/src/addon/mod/data/pages/entry/entry.ts @@ -0,0 +1,307 @@ +// (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 { Content, IonicPage, NavParams, NavController } from 'ionic-angular'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { AddonModDataProvider } from '../../providers/data'; +import { AddonModDataHelperProvider } from '../../providers/helper'; +import { AddonModDataOfflineProvider } from '../../providers/offline'; +import { AddonModDataSyncProvider } from '../../providers/sync'; +import { AddonModDataFieldsDelegate } from '../../providers/fields-delegate'; +import { AddonModDataComponentsModule } from '../../components/components.module'; + +/** + * Page that displays the view entry page. + */ +@IonicPage({ segment: 'addon-mod-data-entry' }) +@Component({ + selector: 'page-addon-mod-data-entry', + templateUrl: 'entry.html', +}) +export class AddonModDataEntryPage { + @ViewChild(Content) content: Content; + + protected module: any; + protected entryId: number; + protected courseId: number; + protected page: number; + protected syncObserver: any; // It will observe the sync auto event. + protected entryChangedObserver: any; // It will observe the changed entry event. + protected fields = {}; + + title = ''; + moduleName = 'data'; + component = AddonModDataProvider.COMPONENT; + entryLoaded = false; + selectedGroup = 0; + entry: any; + offlineActions = []; + hasOffline = false; + cssTemplate = ''; + previousId: number; + nextId: number; + access: any; + data: any; + groupInfo: any; + showComments: any; + entryRendered = ''; + siteId: string; + cssClass = ''; + extraImports = [AddonModDataComponentsModule]; + jsData; + + constructor(params: NavParams, protected utils: CoreUtilsProvider, protected groupsProvider: CoreGroupsProvider, + protected domUtils: CoreDomUtilsProvider, protected fieldsDelegate: AddonModDataFieldsDelegate, + protected courseProvider: CoreCourseProvider, protected dataProvider: AddonModDataProvider, + protected dataOffline: AddonModDataOfflineProvider, protected dataHelper: AddonModDataHelperProvider, + sitesProvider: CoreSitesProvider, protected navCtrl: NavController, + protected eventsProvider: CoreEventsProvider) { + this.module = params.get('module') || {}; + this.entryId = params.get('entryId') || null; + this.courseId = params.get('courseId'); + this.selectedGroup = params.get('group') || 0; + this.page = params.get('page') || null; + + this.siteId = sitesProvider.getCurrentSiteId(); + + this.title = this.module.name; + this.moduleName = this.courseProvider.translateModuleName('data'); + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchEntryData(); + + // Refresh data if this discussion is synchronized automatically. + this.syncObserver = this.eventsProvider.on(AddonModDataSyncProvider.AUTO_SYNCED, (data) => { + if ((data.entryId == this.entryId || data.offlineEntryId == this.entryId) && this.data.id == data.dataId) { + if (data.deleted) { + // If deleted, go back. + this.navCtrl.pop(); + } else { + this.entryId = data.entryid; + this.entryLoaded = false; + this.fetchEntryData(true); + } + } + }, this.siteId); + + // Refresh entry on change. + this.entryChangedObserver = this.eventsProvider.on(AddonModDataProvider.ENTRY_CHANGED, (data) => { + if (data.entryId == this.entryId && data.id == data.dataId) { + if (data.deleted) { + // If deleted, go back. + this.navCtrl.pop(); + } else { + this.entryLoaded = false; + this.fetchEntryData(true); + } + } + }, this.siteId); + } + + /** + * Fetch the entry data. + * + * @param {boolean} refresh If refresh the current data or not. + * @return {Promise} Resolved when done. + */ + protected fetchEntryData(refresh?: boolean): Promise { + return this.dataProvider.getDatabase(this.courseId, this.module.id).then((data) => { + this.title = data.name || this.title; + this.data = data; + this.cssClass = 'addon-data-entries-' + data.id; + + return this.setEntryIdFromPage(data.id, this.page, this.selectedGroup).then(() => { + return this.dataProvider.getDatabaseAccessInformation(data.id); + }); + }).then((accessData) => { + this.access = accessData; + + return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule, accessData.canmanageentries) + .then((groupInfo) => { + this.groupInfo = groupInfo; + + // Check selected group is accessible. + if (groupInfo && groupInfo.groups && groupInfo.groups.length > 0) { + if (!groupInfo.groups.some((group) => this.selectedGroup == group.id)) { + this.selectedGroup = groupInfo.groups[0].id; + } + } + + return this.dataOffline.getEntryActions(this.data.id, this.entryId); + }); + }).then((actions) => { + this.offlineActions = actions; + this.hasOffline = !!actions.length; + + return this.dataProvider.getFields(this.data.id).then((fieldsData) => { + this.fields = {}; + fieldsData.forEach((field) => { + this.fields[field.id] = field; + }); + + return this.dataHelper.getEntry(this.data, this.entryId, this.offlineActions); + }); + }).then((entry) => { + entry = entry.entry; + this.cssTemplate = this.dataHelper.prefixCSS(this.data.csstemplate, '.' + this.cssClass); + + // Index contents by fieldid. + const contents = {}; + entry.contents.forEach((field) => { + contents[field.fieldid] = field; + }); + entry.contents = contents; + + const fieldsArray = this.utils.objectToArray(this.fields); + + return this.dataHelper.applyOfflineActions(entry, this.offlineActions, fieldsArray); + }).then((entryData) => { + this.entry = entryData; + + const actions = this.dataHelper.getActions(this.data, this.access, this.entry), + fieldsArray = this.utils.objectToArray(this.fields); + + this.entryRendered = this.dataHelper.displayShowFields(this.data.singletemplate, fieldsArray, + this.entry, 'show', actions); + this.showComments = actions.comments; + + const entries = {}; + entries[this.entryId] = this.entry; + + // Pass the input data to the component. + this.jsData = { + fields: this.fields, + entries: entries, + data: this.data + }; + + return this.dataHelper.getPageInfoByEntry(this.data.id, this.entryId, this.selectedGroup).then((result) => { + this.previousId = result.previousId; + this.nextId = result.nextId; + }); + }).catch((message) => { + if (!refresh) { + // Some call failed, retry without using cache since it might be a new activity. + return this.refreshAllData(); + } + + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + + return Promise.reject(null); + }).finally(() => { + this.content && this.content.scrollToTop(); + this.entryLoaded = true; + }); + } + + /** + * Go to selected entry without changing state. + * + * @param {number} entry Entry Id where to go. + * @return {Promise} Resolved when done. + */ + gotoEntry(entry: number): Promise { + this.entryId = entry; + this.page = null; + this.entryLoaded = false; + + return this.fetchEntryData(); + } + + /** + * Refresh all the data. + * + * @return {Promise} Promise resolved when done. + */ + protected refreshAllData(): Promise { + const promises = []; + + promises.push(this.dataProvider.invalidateDatabaseData(this.courseId)); + if (this.data) { + promises.push(this.dataProvider.invalidateEntryData(this.data.id, this.entryId)); + promises.push(this.groupsProvider.invalidateActivityGroupInfo(this.data.coursemodule)); + promises.push(this.dataProvider.invalidateEntriesData(this.data.id)); + } + + return Promise.all(promises).finally(() => { + return this.fetchEntryData(true); + }); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @return {Promise} Promise resolved when done. + */ + refreshDatabase(refresher?: any): Promise { + if (this.entryLoaded) { + return this.refreshAllData().finally(() => { + refresher && refresher.complete(); + }); + } + } + + /** + * Set group to see the database. + * + * @param {number} groupId Group identifier to set. + * @return {Promise} Resolved when done. + */ + setGroup(groupId: number): Promise { + this.selectedGroup = groupId; + this.entryLoaded = false; + + return this.setEntryIdFromPage(this.data.id, 0, this.selectedGroup).then(() => { + return this.fetchEntryData(); + }); + } + + /** + * Convenience function to translate page number to entry identifier. + * + * @param {number} dataId Data Id. + * @param {number} [pageNumber] Page number where to go + * @param {number} group Group Id to get the entry. + * @return {Promise} Resolved when done. + */ + protected setEntryIdFromPage(dataId: number, pageNumber?: number, group?: number): Promise { + if (typeof pageNumber == 'number') { + return this.dataHelper.getPageInfoByPage(dataId, pageNumber, group).then((result) => { + this.entryId = result.entryId; + this.page = null; + }); + } + + return Promise.resolve(); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.syncObserver && this.syncObserver.off(); + this.entryChangedObserver && this.entryChangedObserver.off(); + } +} diff --git a/src/addon/mod/data/providers/data.ts b/src/addon/mod/data/providers/data.ts index b4beec32b..f38951efa 100644 --- a/src/addon/mod/data/providers/data.ts +++ b/src/addon/mod/data/providers/data.ts @@ -218,6 +218,59 @@ export class AddonModDataProvider { }); } + /** + * Performs the whole fetch of the entries in the database. + * + * @param {number} dataId Data ID. + * @param {number} [groupId] Group ID. + * @param {string} [sort] Sort the records by this field id. See AddonModDataProvider#getEntries for more info. + * @param {string} [order] The direction of the sorting. See AddonModDataProvider#getEntries for more info. + * @param {number} [perPage] Records per page to fetch. It has to match with the prefetch. + * Default on AddonModDataProvider.PER_PAGE. + * @param {boolean} [forceCache] True to always get the value from cache, false otherwise. Default false. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + fetchAllEntries(dataId: number, groupId: number = 0, sort: string = '0', order: string = 'DESC', + perPage: number = AddonModDataProvider.PER_PAGE, forceCache: boolean = false, ignoreCache: boolean = false, + siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.fetchEntriesRecursive(dataId, groupId, sort, order, perPage, forceCache, ignoreCache, [], 0, siteId); + } + + /** + * Recursive call on fetch all entries. + * + * @param {number} dataId Data ID. + * @param {number} groupId Group ID. + * @param {string} sort Sort the records by this field id. See AddonModDataProvider#getEntries for more info. + * @param {string} order The direction of the sorting. See AddonModDataProvider#getEntries for more info. + * @param {number} perPage Records per page to fetch. It has to match with the prefetch. + * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {boolean} ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param {any} entries Entries already fetch (just to concatenate them). + * @param {number} page Page of records to return. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when done. + */ + protected fetchEntriesRecursive(dataId: number, groupId: number, sort: string, order: string, perPage: number, + forceCache: boolean, ignoreCache: boolean, entries: any, page: number, siteId: string): Promise { + return this.getEntries(dataId, groupId, sort, order, page, perPage, forceCache, ignoreCache, siteId) + .then((result) => { + entries = entries.concat(result.entries); + + const canLoadMore = perPage > 0 && ((page + 1) * perPage) < result.totalcount; + if (canLoadMore) { + return this.fetchEntriesRecursive(dataId, groupId, sort, order, perPage, forceCache, ignoreCache, entries, page + 1, + siteId); + } + + return entries; + }); + } + /** * Get cache key for data data WS calls. * diff --git a/src/addon/mod/data/providers/helper.ts b/src/addon/mod/data/providers/helper.ts index 6bba1cea2..91834da47 100644 --- a/src/addon/mod/data/providers/helper.ts +++ b/src/addon/mod/data/providers/helper.ts @@ -15,7 +15,6 @@ import { Injectable } from '@angular/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { TranslateService } from '@ngx-translate/core'; import { AddonModDataFieldsDelegate } from './fields-delegate'; import { AddonModDataOfflineProvider } from './offline'; @@ -27,7 +26,7 @@ import { AddonModDataProvider } from './data'; @Injectable() export class AddonModDataHelperProvider { - constructor(private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, + constructor(private sitesProvider: CoreSitesProvider, protected dataProvider: AddonModDataProvider, private translate: TranslateService, private fieldsDelegate: AddonModDataFieldsDelegate, private dataOffline: AddonModDataOfflineProvider, private fileUploaderProvider: CoreFileUploaderProvider) { } @@ -175,6 +174,130 @@ export class AddonModDataHelperProvider { }; } + /** + * Fetch all entries and return it's Id + * + * @param {number} dataId Data ID. + * @param {number} groupId Group ID. + * @param {boolean} [forceCache] True to always get the value from cache, false otherwise. Default false. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. Current if not defined. + * @return {Promise} Resolved with an array of entry ID. + */ + getAllEntriesIds(dataId: number, groupId: number, forceCache: boolean = false, ignoreCache: boolean = false, siteId?: string): + Promise { + return this.dataProvider.fetchAllEntries(dataId, groupId, undefined, undefined, undefined, forceCache, ignoreCache, siteId) + .then((entries) => { + return entries.map((entry) => { + return entry.id; + }); + }); + } + + /** + * Get an online or offline entry. + * + * @param {any} data Database. + * @param {number} entryId Entry ID. + * @param {any} [offlineActions] Offline data with the actions done. Required for offline entries. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the entry. + */ + getEntry(data: any, entryId: number, offlineActions?: any, siteId?: string): Promise { + if (entryId > 0) { + // It's an online entry, get it from WS. + return this.dataProvider.getEntry(data.id, entryId, siteId); + } + + // It's an offline entry, search it in the offline actions. + return this.sitesProvider.getSite(siteId).then((site) => { + const offlineEntry = offlineActions.find((offlineAction) => { + return offlineAction.action == 'add'; + }); + + if (offlineEntry) { + const siteInfo = site.getInfo(); + + return {entry: { + id: offlineEntry.entryid, + canmanageentry: true, + approved: !data.approval || data.manageapproved, + dataid: offlineEntry.dataid, + groupid: offlineEntry.groupid, + timecreated: -offlineEntry.entryid, + timemodified: -offlineEntry.entryid, + userid: siteInfo.userid, + fullname: siteInfo.fullname, + contents: {} + } + }; + } + }); + } + + /** + * Get page info related to an entry. + * + * @param {number} dataId Data ID. + * @param {number} entryId Entry ID. + * @param {number} groupId Group ID. + * @param {boolean} [forceCache] True to always get the value from cache, false otherwise. Default false. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. Current if not defined. + * @return {Promise} Containing page number, if has next and have following page. + */ + getPageInfoByEntry(dataId: number, entryId: number, groupId: number, forceCache: boolean = false, + ignoreCache: boolean = false, siteId?: string): Promise { + return this.getAllEntriesIds(dataId, groupId, forceCache, ignoreCache, siteId).then((entries) => { + const index = entries.findIndex((entry) => { + return entry == entryId; + }); + + if (index >= 0) { + return { + previousId: entries[index - 1] || false, + nextId: entries[index + 1] || false, + entryId: entryId, + page: index + 1, // Parsed to natural language. + numEntries: entries.length + }; + } + + return false; + }); + } + + /** + * Get page info related to an entry by page number. + * + * @param {number} dataId Data ID. + * @param {number} page Page number. + * @param {number} groupId Group ID. + * @param {boolean} [forceCache] True to always get the value from cache, false otherwise. Default false. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. Current if not defined. + * @return {Promise} Containing page number, if has next and have following page. + */ + getPageInfoByPage(dataId: number, page: number, groupId: number, forceCache: boolean = false, + ignoreCache: boolean = false, siteId?: string): Promise { + return this.getAllEntriesIds(dataId, groupId, forceCache, ignoreCache, siteId).then((entries) => { + const index = page - 1, + entryId = entries[index]; + + if (entryId) { + return { + previousId: entries[index - 1] || null, + nextId: entries[index + 1] || null, + entryId: entryId, + page: page, // Parsed to natural language. + numEntries: entries.length + }; + } + + return false; + }); + } + /** * Get a list of stored attachment files for a new entry. See $mmaModDataHelper#storeFiles. *