From 9ecbdd22b8d7ca8e68e64838c19715c0e490013a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 12 Nov 2020 09:18:44 +0100 Subject: [PATCH 01/19] MOBILE-3592 user: Implement user services --- src/app/core/user/services/user.db.ts | 95 ++ src/app/core/user/services/user.helper.ts | 67 ++ src/app/core/user/services/user.offline.ts | 83 ++ src/app/core/user/services/user.ts | 1046 ++++++++++++++++++++ 4 files changed, 1291 insertions(+) create mode 100644 src/app/core/user/services/user.db.ts create mode 100644 src/app/core/user/services/user.helper.ts create mode 100644 src/app/core/user/services/user.offline.ts create mode 100644 src/app/core/user/services/user.ts diff --git a/src/app/core/user/services/user.db.ts b/src/app/core/user/services/user.db.ts new file mode 100644 index 000000000..8f19b47fd --- /dev/null +++ b/src/app/core/user/services/user.db.ts @@ -0,0 +1,95 @@ +// (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 { CoreSiteSchema, registerSiteSchema } from '@services/sites'; +import { CoreUserBasicData } from './user'; + +/** + * Database variables for CoreUser service. + */ +export const USERS_TABLE_NAME = 'users'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreUserProvider', + version: 1, + canBeCleared: [USERS_TABLE_NAME], + tables: [ + { + name: USERS_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'fullname', + type: 'TEXT', + }, + { + name: 'profileimageurl', + type: 'TEXT', + }, + ], + }, + ], +}; + +/** + * Database variables for CoreUserOffline service. + */ +export const PREFERENCES_TABLE_NAME = 'user_preferences'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreUserOfflineProvider', + version: 1, + tables: [ + { + name: PREFERENCES_TABLE_NAME, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true, + }, + { + name: 'value', + type: 'TEXT', + }, + { + name: 'onlinevalue', + type: 'TEXT', + }, + ], + }, + ], +}; + +/** + * Data stored in DB for users. + */ +export type CoreUserDBRecord = CoreUserBasicData; + +/** + * Structure of offline user preferences. + */ +export type CoreUserPreferenceDBRecord = { + name: string; + value: string; + onlinevalue: string; +}; + +export const initCoreUserDB = (): void => { + registerSiteSchema(SITE_SCHEMA); + registerSiteSchema(OFFLINE_SITE_SCHEMA); +}; diff --git a/src/app/core/user/services/user.helper.ts b/src/app/core/user/services/user.helper.ts new file mode 100644 index 000000000..d41a3e372 --- /dev/null +++ b/src/app/core/user/services/user.helper.ts @@ -0,0 +1,67 @@ +// (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 { Injectable } from '@angular/core'; + +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreUserRole } from './user'; + +/** + * Service that provides some features regarding users information. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreUserHelperProvider { + + /** + * Formats a user address, concatenating address, city and country. + * + * @param address Address. + * @param city City. + * @param country Country. + * @return Formatted address. + */ + formatAddress(address: string, city: string, country: string): string { + const separator = Translate.instance.instant('core.listsep'); + let values = [address, city, country]; + + values = values.filter((value) => value?.length > 0); + + return values.join(separator + ' '); + } + + /** + * Formats a user role list, translating and concatenating them. + * + * @param roles List of user roles. + * @return The formatted roles. + */ + formatRoleList(roles?: CoreUserRole[]): string { + if (!roles || roles.length <= 0) { + return ''; + } + + const separator = Translate.instance.instant('core.listsep'); + + return roles.map((value) => { + const translation = Translate.instance.instant('core.user.' + value.shortname); + + return translation.indexOf('core.user.') < 0 ? translation : value.shortname; + }).join(separator + ' '); + } + +} + +export class CoreUserHelper extends makeSingleton(CoreUserHelperProvider) {} diff --git a/src/app/core/user/services/user.offline.ts b/src/app/core/user/services/user.offline.ts new file mode 100644 index 000000000..fbc298cc6 --- /dev/null +++ b/src/app/core/user/services/user.offline.ts @@ -0,0 +1,83 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons/core.singletons'; +import { PREFERENCES_TABLE_NAME, CoreUserPreferenceDBRecord } from './user.db'; + +/** + * Service to handle offline user preferences. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreUserOfflineProvider { + + /** + * Get preferences that were changed offline. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with list of preferences. + */ + async getChangedPreferences(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecordsSelect(PREFERENCES_TABLE_NAME, 'value != onlineValue'); + } + + /** + * Get an offline preference. + * + * @param name Name of the preference. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the preference, rejected if not found. + */ + async getPreference(name: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(PREFERENCES_TABLE_NAME, { name }); + } + + /** + * Set an offline preference. + * + * @param name Name of the preference. + * @param value Value of the preference. + * @param onlineValue Online value of the preference. If undefined, preserve previously stored value. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async setPreference(name: string, value: string, onlineValue?: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + if (typeof onlineValue == 'undefined') { + const preference = await this.getPreference(name, site.id); + + onlineValue = preference.onlinevalue; + } + + const record: CoreUserPreferenceDBRecord = { + name, + value, + onlinevalue: onlineValue, + }; + + await site.getDb().insertRecord(PREFERENCES_TABLE_NAME, record); + } + +} + +export class CoreUserOffline extends makeSingleton(CoreUserOfflineProvider) {} diff --git a/src/app/core/user/services/user.ts b/src/app/core/user/services/user.ts new file mode 100644 index 000000000..7baae9152 --- /dev/null +++ b/src/app/core/user/services/user.ts @@ -0,0 +1,1046 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreUserOffline } from './user.offline'; +import { CoreLogger } from '@singletons/logger'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { makeSingleton } from '@singletons/core.singletons'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; +import { CoreError } from '@classes/errors/error'; +import { USERS_TABLE_NAME, CoreUserDBRecord } from './user.db'; + +const ROOT_CACHE_KEY = 'mmUser:'; + +/** + * Service to provide user functionalities. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreUserProvider { + + static readonly PARTICIPANTS_LIST_LIMIT = 50; // Max of participants to retrieve in each WS call. + static readonly PROFILE_REFRESHED = 'CoreUserProfileRefreshed'; + static readonly PROFILE_PICTURE_UPDATED = 'CoreUserProfilePictureUpdated'; + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreUserProvider'); + } + + /** + * Check if WS to search participants is available in site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's available. + * @since 3.8 + */ + async canSearchParticipants(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canSearchParticipantsInSite(site); + } + + /** + * Check if WS to search participants is available in site. + * + * @param site Site. If not defined, current site. + * @return Whether it's available. + * @since 3.8 + */ + canSearchParticipantsInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_enrol_search_users'); + } + + /** + * Change the given user profile picture. + * + * @param draftItemId New picture draft item id. + * @param userId User ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolve with the new profileimageurl + */ + async changeProfilePicture(draftItemId: number, userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreUserUpdatePictureWSParams = { + draftitemid: draftItemId, + delete: false, + userid: userId, + }; + + const result = await site.write('core_user_update_picture', params); + + if (!result.success) { + return Promise.reject(null); + } + + return result.profileimageurl!; + } + + /** + * Store user basic information in local DB to be retrieved if the WS call fails. + * + * @param userId User ID. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is deleted. + */ + async deleteStoredUser(userId: number, siteId?: string): Promise { + if (isNaN(userId)) { + throw new CoreError('Invalid user ID.'); + } + + const site = await CoreSites.instance.getSite(siteId); + + await Promise.all([ + this.invalidateUserCache(userId, site.getId()), + site.getDb().deleteRecords(USERS_TABLE_NAME, { id: userId }), + ]); + } + + /** + * Get participants for a certain course. + * + * @param courseId ID of the course. + * @param limitFrom Position of the first participant to get. + * @param limitNumber Number of participants to get. + * @param siteId Site Id. If not defined, use current site. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved when the participants are retrieved. + */ + async getParticipants( + courseId: number, + limitFrom: number = 0, + limitNumber: number = CoreUserProvider.PARTICIPANTS_LIST_LIMIT, + siteId?: string, + ignoreCache?: boolean, + ): Promise<{participants: CoreUserParticipant[]; canLoadMore: boolean}> { + + const site = await CoreSites.instance.getSite(siteId); + + this.logger.debug(`Get participants for course '${courseId}' starting at '${limitFrom}'`); + + const params: CoreEnrolGetEnrolledUsersWSParams = { + courseid: courseId, + options: [ + { + name: 'limitfrom', + value: String(limitFrom), + }, + { + name: 'limitnumber', + value: String(limitNumber), + }, + { + name: 'sortby', + value: 'siteorder', + }, + ], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getParticipantsListCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const users = await site.read('core_enrol_get_enrolled_users', params, preSets); + + const canLoadMore = users.length >= limitNumber; + this.storeUsers(users, siteId); + + return { participants: users, canLoadMore: canLoadMore }; + } + + /** + * Get cache key for participant list WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getParticipantsListCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'list:' + courseId; + } + + /** + * Get user profile. The type of profile retrieved depends on the params. + * + * @param userId User's ID. + * @param courseId Course ID to get course profile, undefined or 0 to get site profile. + * @param forceLocal True to retrieve the user data from local DB, false to retrieve it from WS. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolved with the user data. + */ + async getProfile( + userId: number, + courseId?: number, + forceLocal: boolean = false, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (forceLocal) { + try { + return await this.getUserFromLocalDb(userId, siteId); + } catch { + return this.getUserFromWS(userId, courseId, siteId); + } + } + + try { + return await this.getUserFromWS(userId, courseId, siteId); + } catch { + return this.getUserFromLocalDb(userId, siteId); + } + } + + /** + * Get cache key for a user WS call. + * + * @param userId User ID. + * @return Cache key. + */ + protected getUserCacheKey(userId: number): string { + return ROOT_CACHE_KEY + 'data:' + userId; + } + + /** + * Get user basic information from local DB. + * + * @param userId User ID. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is retrieved. + */ + protected async getUserFromLocalDb(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(USERS_TABLE_NAME, { id: userId }); + } + + /** + * Get user profile from WS. + * + * @param userId User ID. + * @param courseId Course ID to get course profile, undefined or 0 to get site profile. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is retrieved. + */ + protected async getUserFromWS( + userId: number, + courseId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserCacheKey(userId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + let users: CoreUserData[] | CoreUserCourseProfile[] | undefined; + + // Determine WS and data to use. + if (courseId && courseId != site.getSiteHomeId()) { + this.logger.debug(`Get participant with ID '${userId}' in course '${courseId}`); + + const params: CoreUserGetCourseUserProfilesWSParams = { + userlist: [ + { + userid: userId, + courseid: courseId, + }, + ], + }; + + users = await site.read('core_user_get_course_user_profiles', params, preSets); + } else { + this.logger.debug(`Get user with ID '${userId}'`); + + const params: CoreUserGetUsersByFieldWSParams = { + field: 'id', + values: [String(userId)], + }; + + users = await site.read('core_user_get_users_by_field', params, preSets); + } + + if (users.length == 0) { + // Shouldn't happen. + throw new CoreError('Cannot retrieve user info.'); + } + + const user = users[0]; + if (user.country) { + user.country = CoreUtils.instance.getCountryName(user.country); + } + this.storeUser(user.id, user.fullname, user.profileimageurl); + + return user; + } + + /** + * Get a user preference (online or offline). + * + * @param name Name of the preference. + * @param siteId Site Id. If not defined, use current site. + * @return Preference value or null if preference not set. + */ + async getUserPreference(name: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const preference = await CoreUtils.instance.ignoreErrors(CoreUserOffline.instance.getPreference(name, siteId)); + + if (preference && !CoreApp.instance.isOnline()) { + // Offline, return stored value. + return preference.value; + } + + const wsValue = await this.getUserPreferenceOnline(name, siteId); + + if (!wsValue) { + if (preference) { + // Return the local value. + return preference.value; + } + + throw new CoreError('Preference not found'); + } + + if (preference && preference.value != preference.onlinevalue && preference.onlinevalue == wsValue) { + // Sync is pending for this preference, return stored value. + return preference.value; + } + + await CoreUserOffline.instance.setPreference(name, wsValue, wsValue); + + return wsValue; + } + + /** + * Get cache key for a user preference WS call. + * + * @param name Preference name. + * @return Cache key. + */ + protected getUserPreferenceCacheKey(name: string): string { + return ROOT_CACHE_KEY + 'preference:' + name; + } + + /** + * Get a user preference online. + * + * @param name Name of the preference. + * @param siteId Site Id. If not defined, use current site. + * @return Preference value or null if preference not set. + */ + async getUserPreferenceOnline(name: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreUserGetUserPreferencesWSParams = { + name, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserPreferenceCacheKey(params.name!), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + }; + + const result = await site.read('core_user_get_user_preferences', params, preSets); + + return result.preferences[0] ? result.preferences[0].value : null; + } + + /** + * Invalidates user WS calls. + * + * @param userId User ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserCache(userId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserCacheKey(userId)); + } + + /** + * Invalidates participant list for a certain course. + * + * @param courseId Course ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the list is invalidated. + */ + async invalidateParticipantsList(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getParticipantsListCacheKey(courseId)); + } + + /** + * Invalidate user preference. + * + * @param name Name of the preference. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserPreference(name: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserPreferenceCacheKey(name)); + } + + /** + * Check if course participants is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isParticipantsDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isParticipantsDisabledInSite(site); + } + + /** + * Check if course participants is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isParticipantsDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.isFeatureDisabled('CoreCourseOptionsDelegate_CoreUserParticipants'); + } + + /** + * Returns whether or not participants is enabled for a certain course. + * + * @param courseId Course ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + isPluginEnabledForCourse(courseId: number, siteId?: string): Promise { + if (!courseId) { + throw new CoreError('Invalid course ID.'); + } + + // Retrieving one participant will fail if browsing users is disabled by capabilities. + return CoreUtils.instance.promiseWorks(this.getParticipants(courseId, 0, 1, siteId)); + } + + /** + * Check if update profile picture is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return True if disabled, false otherwise. + */ + isUpdatePictureDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.isFeatureDisabled('CoreUserDelegate_picture'); + } + + /** + * Log User Profile View in Moodle. + * + * @param userId User ID. + * @param courseId Course ID. + * @param name Name of the user. + * @return Promise resolved when done. + */ + async logView(userId: number, courseId?: number, name?: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreUserViewUserProfileWSParams = { + userid: userId, + }; + const wsName = 'core_user_view_user_profile'; + + if (courseId) { + params.courseid = courseId; + } + + // @todo this.pushNotificationsProvider.logViewEvent(userId, name, 'user', wsName, {courseid: courseId}); + + return site.write(wsName, params); + } + + /** + * Log Participants list view in Moodle. + * + * @param courseId Course ID. + * @return Promise resolved when done. + */ + async logParticipantsView(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreUserViewUserListWSParams = { + courseid: courseId, + }; + + // @todo this.pushNotificationsProvider.logViewListEvent('user', 'core_user_view_user_list', params); + + return site.write('core_user_view_user_list', params); + } + + /** + * Prefetch user profiles and their images from a certain course. It prevents duplicates. + * + * @param userIds List of user IDs. + * @param courseId Course the users belong to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when prefetched. + */ + async prefetchProfiles(userIds: number[], courseId?: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + return; + } + + const treated: Record = {}; + + await Promise.all(userIds.map(async (userId) => { + if (userId === null) { + return; + } + + userId = Number(userId); // Make sure it's a number. + + // Prevent repeats and errors. + if (isNaN(userId) || treated[userId] || userId <= 0) { + return; + } + + treated[userId] = true; + + try { + const profile = await this.getProfile(userId, courseId, false, siteId); + + if (profile.profileimageurl) { + await CoreFilepool.instance.addToQueueByUrl(siteId!, profile.profileimageurl); + } + } catch (error) { + this.logger.warn(`Ignore error when prefetching user ${userId}`, error); + } + })); + } + + /** + * Prefetch user avatars. It prevents duplicates. + * + * @param entries List of entries that have the images. + * @param propertyName The name of the property that contains the image. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when prefetched. + */ + async prefetchUserAvatars(entries: Record[], propertyName: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + return; + } + + const treated: Record = {}; + + const promises = entries.map(async (entry) => { + const imageUrl = entry[propertyName]; + + if (!imageUrl || treated[imageUrl]) { + // It doesn't have an image or it has already been treated. + return; + } + + treated[imageUrl] = true; + + try { + await CoreFilepool.instance.addToQueueByUrl(siteId!, imageUrl); + } catch (ex) { + this.logger.warn(`Ignore error when prefetching user avatar ${imageUrl}`, entry, ex); + } + }); + + await Promise.all(promises); + } + + /** + * Search participants in a certain course. + * + * @param courseId ID of the course. + * @param search The string to search. + * @param searchAnywhere Whether to find a match anywhere or only at the beginning. + * @param page Page to get. + * @param limitNumber Number of participants to get. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the participants are retrieved. + * @since 3.8 + */ + async searchParticipants( + courseId: number, + search: string, + searchAnywhere: boolean = true, + page: number = 0, + perPage: number = CoreUserProvider.PARTICIPANTS_LIST_LIMIT, + siteId?: string, + ): Promise<{participants: CoreUserData[]; canLoadMore: boolean}> { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreEnrolSearchUsersWSParams = { + courseid: courseId, + search: search, + searchanywhere: !!searchAnywhere, + page: page, + perpage: perPage, + }; + const preSets: CoreSiteWSPreSets = { + getFromCache: false, // Always try to get updated data. If it fails, it will get it from cache. + }; + + const users = await site.read('core_enrol_search_users', params, preSets); + + const canLoadMore = users.length >= perPage; + this.storeUsers(users, siteId); + + return { participants: users, canLoadMore: canLoadMore }; + } + + /** + * Store user basic information in local DB to be retrieved if the WS call fails. + * + * @param userId User ID. + * @param fullname User full name. + * @param avatar User avatar URL. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is stored. + */ + protected async storeUser(userId: number, fullname: string, avatar?: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const userRecord: CoreUserDBRecord = { + id: userId, + fullname: fullname, + profileimageurl: avatar, + }; + + await site.getDb().insertRecord(USERS_TABLE_NAME, userRecord); + } + + /** + * Store users basic information in local DB. + * + * @param users Users to store. + * @param siteId ID of the site. If not defined, use current site. + * @return Promise resolve when the user is stored. + */ + async storeUsers(users: CoreUserBasicData[], siteId?: string): Promise { + + await Promise.all(users.map((user) => { + if (!user.id || isNaN(Number(user.id))) { + return; + } + + return this.storeUser(Number(user.id), user.fullname, user.profileimageurl, siteId); + })); + } + + /** + * Set a user preference (online or offline). + * + * @param name Name of the preference. + * @param value Value of the preference. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved on success. + */ + async setUserPreference(name: string, value: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!CoreApp.instance.isOnline()) { + // Offline, just update the preference. + return CoreUserOffline.instance.setPreference(name, value); + } + + + try { + // Update the preference in the site. + const preferences = [ + { type: name, value }, + ]; + + await this.updateUserPreferences(preferences, undefined, undefined, siteId); + + // Update preference and invalidate data. + await Promise.all([ + CoreUserOffline.instance.setPreference(name, value, value), + CoreUtils.instance.ignoreErrors(this.invalidateUserPreference(name)), + ]); + } catch (error) { + // Preference not saved online. Update the offline one. + await CoreUserOffline.instance.setPreference(name, value); + } + } + + /** + * Update a preference for a user. + * + * @param name Preference name. + * @param value Preference new value. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success. + */ + updateUserPreference(name: string, value: string, userId?: number, siteId?: string): Promise { + const preferences = [ + { + type: name, + value: value, + }, + ]; + + return this.updateUserPreferences(preferences, undefined, userId, siteId); + } + + /** + * Update some preferences for a user. + * + * @param preferences List of preferences. + * @param disableNotifications Whether to disable all notifications. Undefined to not update this value. + * @param userId User ID. If not defined, site's current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success. + */ + async updateUserPreferences( + preferences: { type: string; value: string }[], + disableNotifications?: boolean, + userId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + userId = userId || site.getUserId(); + + const params: CoreUserUpdateUserPreferencesWSParams = { + userid: userId, + preferences: preferences, + }; + const preSets: CoreSiteWSPreSets = { + responseExpected: false, + }; + + if (typeof disableNotifications != 'undefined') { + params.emailstop = disableNotifications ? 1 : 0; + } + + await site.write('core_user_update_user_preferences', params, preSets); + } + +} + +export class CoreUser extends makeSingleton(CoreUserProvider) {} + +/** + * Basic data of a user. + */ +export type CoreUserBasicData = { + id: number; // ID of the user. + fullname: string; // The fullname of the user. + profileimageurl?: string; // User image profile URL - big version. +}; + +/** + * User preference. + */ +export type CoreUserPreference = { + name: string; // The name of the preference. + value: string; // The value of the preferenc. +}; + +/** + * User custom profile field. + */ +export type CoreUserProfileField = { + type: string; // The type of the custom field - text field, checkbox... + value: string; // The value of the custom field. + name: string; // The name of the custom field. + shortname: string; // The shortname of the custom field - to be able to build the field class in the code. +}; + +/** + * User group. + */ +export type CoreUserGroup = { + id: number; // Group id. + name: string; // Group name. + description: string; // Group description. + descriptionformat: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). +}; + +/** + * User role. + */ +export type CoreUserRole = { + roleid: number; // Role id. + name: string; // Role name. + shortname: string; // Role shortname. + sortorder: number; // Role sortorder. +}; + +/** + * Basic data of a course the user is enrolled in. + */ +export type CoreUserEnrolledCourse = { + id: number; // Id of the course. + fullname: string; // Fullname of the course. + shortname: string; // Shortname of the course. +}; + +/** + * Common data returned by user_description function. + */ +export type CoreUserData = { + id: number; // ID of the user. + username?: string; // The username. + firstname?: string; // The first name(s) of the user. + lastname?: string; // The family name of the user. + fullname: string; // The fullname of the user. + email?: string; // An email address - allow email as root@localhost. + address?: string; // Postal address. + phone1?: string; // Phone 1. + phone2?: string; // Phone 2. + icq?: string; // Icq number. + skype?: string; // Skype id. + yahoo?: string; // Yahoo id. + aim?: string; // Aim id. + msn?: string; // Msn number. + department?: string; // Department. + institution?: string; // Institution. + idnumber?: string; // An arbitrary ID code number perhaps from the institution. + interests?: string; // User interests (separated by commas). + firstaccess?: number; // First access to the site (0 if never). + lastaccess?: number; // Last access to the site (0 if never). + auth?: string; // Auth plugins include manual, ldap, etc. + suspended?: boolean; // Suspend user account, either false to enable user login or true to disable it. + confirmed?: boolean; // Active user: 1 if confirmed, 0 otherwise. + lang?: string; // Language code such as "en", must exist on server. + calendartype?: string; // Calendar type such as "gregorian", must exist on server. + theme?: string; // Theme name such as "standard", must exist on server. + timezone?: string; // Timezone code such as Australia/Perth, or 99 for default. + mailformat?: number; // Mail format code is 0 for plain text, 1 for HTML etc. + description?: string; // User profile description. + descriptionformat?: number; // Int format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + city?: string; // Home city of the user. + url?: string; // URL of the user. + country?: string; // Home country code of the user, such as AU or CZ. + profileimageurlsmall: string; // User image profile URL - small version. + profileimageurl: string; // User image profile URL - big version. + customfields?: CoreUserProfileField[]; // User custom fields (also known as user profile fields). + preferences?: CoreUserPreference[]; // Users preferences. +}; + +/** + * Data returned by user_summary_exporter. + */ +export type CoreUserSummary = { + id: number; // Id. + email: string; // Email. + idnumber: string; // Idnumber. + phone1: string; // Phone1. + phone2: string; // Phone2. + department: string; // Department. + institution: string; // Institution. + fullname: string; // Fullname. + identity: string; // Identity. + profileurl: string; // Profileurl. + profileimageurl: string; // Profileimageurl. + profileimageurlsmall: string; // Profileimageurlsmall. +}; + +/** + * User data returned by core_enrol_get_enrolled_users WS. + */ +export type CoreUserParticipant = CoreUserBasicData & { + username?: string; // Username policy is defined in Moodle security config. + firstname?: string; // The first name(s) of the user. + lastname?: string; // The family name of the user. + email?: string; // An email address - allow email as root@localhost. + address?: string; // Postal address. + phone1?: string; // Phone 1. + phone2?: string; // Phone 2. + icq?: string; // Icq number. + skype?: string; // Skype id. + yahoo?: string; // Yahoo id. + aim?: string; // Aim id. + msn?: string; // Msn number. + department?: string; // Department. + institution?: string; // Institution. + idnumber?: string; // An arbitrary ID code number perhaps from the institution. + interests?: string; // User interests (separated by commas). + firstaccess?: number; // First access to the site (0 if never). + lastaccess?: number; // Last access to the site (0 if never). + lastcourseaccess?: number; // Last access to the course (0 if never). + description?: string; // User profile description. + descriptionformat?: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + city?: string; // Home city of the user. + url?: string; // URL of the user. + country?: string; // Home country code of the user, such as AU or CZ. + profileimageurlsmall?: string; // User image profile URL - small version. + customfields?: CoreUserProfileField[]; // User custom fields (also known as user profil fields). + groups?: CoreUserGroup[]; // User groups. + roles?: CoreUserRole[]; // User roles. + preferences?: CoreUserPreference[]; // User preferences. + enrolledcourses?: CoreUserEnrolledCourse[]; // Courses where the user is enrolled. +}; + +/** + * User data returned by core_user_get_course_user_profiles WS. + */ +export type CoreUserCourseProfile = CoreUserData & { + groups?: CoreUserGroup[]; // User groups. + roles?: CoreUserRole[]; // User roles. + enrolledcourses?: CoreUserEnrolledCourse[]; // Courses where the user is enrolled. +}; + +/** + * Params of core_user_update_picture WS. + */ +type CoreUserUpdatePictureWSParams = { + draftitemid: number; // Id of the user draft file to use as image. + delete?: boolean; // If we should delete the user picture. + userid?: number; // Id of the user, 0 for current user. +}; + +/** + * Data returned by core_user_update_picture WS. + */ +type CoreUserUpdatePictureWSResponse = { + success: boolean; // True if the image was updated, false otherwise. + profileimageurl?: string; // New profile user image url. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_enrol_get_enrolled_users WS. + */ +type CoreEnrolGetEnrolledUsersWSParams = { + courseid: number; // Course id. + options?: { + name: string; // Option name. + value: string; // Option value. + }[]; +}; + +/** + * Data returned by core_enrol_get_enrolled_users WS. + */ +type CoreEnrolGetEnrolledUsersWSResponse = CoreUserParticipant[]; + +/** + * Params of core_user_get_course_user_profiles WS. + */ +type CoreUserGetCourseUserProfilesWSParams = { + userlist: { + userid: number; // Userid. + courseid: number; // Courseid. + }[]; +}; + +/** + * Data returned by core_user_get_course_user_profiles WS. + */ +type CoreUserGetCourseUserProfilesWSResponse = CoreUserCourseProfile[]; + +/** + * Params of core_user_get_users_by_field WS. + */ +type CoreUserGetUsersByFieldWSParams = { + field: string; // The search field can be 'id' or 'idnumber' or 'username' or 'email'. + values: string[]; +}; +/** + * Data returned by core_user_get_users_by_field WS. + */ +type CoreUserGetUsersByFieldWSResponse = CoreUserData[]; + +/** + * Params of core_user_get_user_preferences WS. + */ +type CoreUserGetUserPreferencesWSParams = { + name?: string; // Preference name, empty for all. + userid?: number; // Id of the user, default to current user. +}; + +/** + * Data returned by core_user_get_user_preferences WS. + */ +type CoreUserGetUserPreferencesWSResponse = { + preferences: { // User custom fields (also known as user profile fields). + name: string; // The name of the preference. + value: string; // The value of the preference. + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_user_view_user_list WS. + */ +type CoreUserViewUserListWSParams = { + courseid: number; // Id of the course, 0 for site. +}; + +/** + * Params of core_user_view_user_profile WS. + */ +type CoreUserViewUserProfileWSParams = { + userid: number; // Id of the user, 0 for current user. + courseid?: number; // Id of the course, default site course. +}; + +/** + * Params of core_user_update_user_preferences WS. + */ +type CoreUserUpdateUserPreferencesWSParams = { + userid?: number; // Id of the user, default to current user. + emailstop?: number; // Enable or disable notifications for this user. + preferences?: { // User preferences. + type: string; // The name of the preference. + value?: string; // The value of the preference, do not set this field if you want to remove (unset) the current value. + }[]; +}; + +/** + * Params of core_enrol_search_users WS. + */ +type CoreEnrolSearchUsersWSParams = { + courseid: number; // Course id. + search: string; // Query. + searchanywhere: boolean; // Find a match anywhere, or only at the beginning. + page: number; // Page number. + perpage: number; // Number per page. +}; + +/** + * Data returned by core_enrol_search_users WS. + */ +type CoreEnrolSearchUsersWSResponse = CoreUserData[]; + From d733488963ad7c57faf49202ec5608f18e2483b2 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 13 Nov 2020 09:08:58 +0100 Subject: [PATCH 02/19] MOBILE-3592 user: Implement user delegate --- src/app/core/user/services/user.delegate.ts | 283 ++++++++++++++++++++ src/app/core/user/services/user.helper.ts | 4 +- src/app/core/user/services/user.ts | 73 ++++- src/core/classes/site.ts | 4 +- src/core/singletons/events.ts | 28 +- 5 files changed, 375 insertions(+), 17 deletions(-) create mode 100644 src/app/core/user/services/user.delegate.ts diff --git a/src/app/core/user/services/user.delegate.ts b/src/app/core/user/services/user.delegate.ts new file mode 100644 index 000000000..411a375f3 --- /dev/null +++ b/src/app/core/user/services/user.delegate.ts @@ -0,0 +1,283 @@ +// (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 { Injectable } from '@angular/core'; +import { NavController } from '@ionic/angular'; +import { Subject, BehaviorSubject } from 'rxjs'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEvents } from '@singletons/events'; +import { CoreUserProfile } from './user'; + +/** + * Interface that all user profile handlers must implement. + */ +export interface CoreUserProfileHandler extends CoreDelegateHandler { + /** + * The highest priority is displayed first. + */ + priority: number; + + /** + * A type should be specified among these: + * - TYPE_COMMUNICATION: will be displayed under the user avatar. Should have icon. Spinner not used. + * - TYPE_NEW_PAGE: will be displayed as a list of items. Should have icon. Spinner not used. + * Default value if none is specified. + * - TYPE_ACTION: will be displayed as a button and should not redirect to any state. Spinner use is recommended. + */ + type: string; + + /** + * Whether or not the handler is enabled for a user. + * + * @param user User object. + * @param courseId Course ID where to show. + * @param navOptions Navigation options for the course. + * @param admOptions Admin options for the course. + * @return Whether or not the handler is enabled for a user. + */ + isEnabledForUser(user: CoreUserProfile, courseId: number, navOptions?: unknown, admOptions?: unknown): Promise; + + /** + * Returns the data needed to render the handler. + * + * @param user User object. + * @param courseId Course ID where to show. + * @return Data to be shown. + */ + getDisplayData(user: CoreUserProfile, courseId: number): CoreUserProfileHandlerData; +} + +/** + * Data needed to render a user profile handler. It's returned by the handler. + */ +export interface CoreUserProfileHandlerData { + /** + * Title to display. + */ + title: string; + + /** + * Name of the icon to display. Mandatory for TYPE_COMMUNICATION. + */ + icon?: string; + + /** + * Additional class to add to the HTML. + */ + class?: string; + + /** + * If enabled, element will be hidden. Only for TYPE_NEW_PAGE and TYPE_ACTION. + */ + hidden?: boolean; + + /** + * If enabled will show an spinner. Only for TYPE_ACTION. + */ + spinner?: boolean; + + /** + * Action to do when clicked. + * + * @param event Click event. + * @param Nav controller to use to navigate. + * @param user User object. + * @param courseId Course ID being viewed. If not defined, site context. + */ + action(event: Event, navCtrl: NavController, user: CoreUserProfile, courseId?: number): void; +} + +/** + * Data returned by the delegate for each handler. + */ +export interface CoreUserProfileHandlerToDisplay { + /** + * Name of the handler. + */ + name?: string; + + /** + * Data to display. + */ + data: CoreUserProfileHandlerData; + + /** + * The highest priority is displayed first. + */ + priority?: number; + + /** + * The type of the handler. See CoreUserProfileHandler. + */ + type: string; +} + +/** + * Service to interact with plugins to be shown in user profile. Provides functions to register a plugin + * and notify an update in the data. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreUserDelegate extends CoreDelegate { + + /** + * User profile handler type for communication. + */ + static readonly TYPE_COMMUNICATION = 'communication'; + /** + * User profile handler type for new page. + */ + static readonly TYPE_NEW_PAGE = 'newpage'; + /** + * User profile handler type for actions. + */ + static readonly TYPE_ACTION = 'action'; + + /** + * Update handler information event. + */ + static readonly UPDATE_HANDLER_EVENT = 'CoreUserDelegate_update_handler_event'; + + protected featurePrefix = 'CoreUserDelegate_'; + + // Hold the handlers and the observable to notify them for each user. + protected userHandlers: { + [userId: number]: { + loaded: boolean; // Whether the handlers are loaded. + handlers: CoreUserProfileHandlerToDisplay[]; // List of handlers. + observable: Subject; // Observale to notify the handlers. + }; + } = {}; + + constructor() { + super('CoreUserDelegate', true); + + CoreEvents.on(CoreUserDelegate.UPDATE_HANDLER_EVENT, (data) => { + if (!data || !data.handler || !this.userHandlers[data.userId]) { + return; + } + + // Search the handler. + const handler = this.userHandlers[data.userId].handlers.find((userHandler) => userHandler.name == data.handler); + + if (!handler) { + return; + } + + // Update the data and notify. + Object.assign(handler.data, data.data); + this.userHandlers[data.userId].observable.next(this.userHandlers[data.userId].handlers); + }); + } + + /** + * Check if handlers are loaded. + * + * @return True if handlers are loaded, false otherwise. + */ + areHandlersLoaded(userId: number): boolean { + return this.userHandlers[userId]?.loaded; + } + + /** + * Clear current user handlers. + * + * @param userId The user to clear. + */ + clearUserHandlers(userId: number): void { + const userData = this.userHandlers[userId]; + + if (userData) { + userData.handlers = []; + userData.observable.next([]); + userData.loaded = false; + } + } + + /** + * Get the profile handlers for a user. + * + * @param user The user object. + * @param courseId The course ID. + * @return Resolved with the handlers. + */ + getProfileHandlersFor(user: CoreUserProfile, courseId: number): Subject { + // Initialize the user handlers if it isn't initialized already. + if (!this.userHandlers[user.id]) { + this.userHandlers[user.id] = { + loaded: false, + handlers: [], + observable: new BehaviorSubject([]), + }; + } + + this.calculateUserHandlers(user, courseId); + + return this.userHandlers[user.id].observable; + } + + /** + * Get the profile handlers for a user. + * + * @param user The user object. + * @param courseId The course ID. + * @return Promise resolved when done. + */ + protected async calculateUserHandlers(user: CoreUserProfile, courseId: number): Promise { + // @todo: Get Course admin/nav options. + let navOptions; + let admOptions; + + const userData = this.userHandlers[user.id]; + userData.handlers = []; + + await CoreUtils.instance.allPromises(Object.keys(this.enabledHandlers).map(async (name) => { + // Checks if the handler is enabled for the user. + const handler = this.handlers[name]; + + try { + const enabled = await handler.isEnabledForUser(user, courseId, navOptions, admOptions); + + if (enabled) { + userData.handlers.push({ + name: name, + data: handler.getDisplayData(user, courseId), + priority: handler.priority || 0, + type: handler.type || CoreUserDelegate.TYPE_NEW_PAGE, + }); + } + } catch { + // Nothing to do here, it is not enabled for this user. + } + })); + + // Sort them by priority. + userData.handlers.sort((a, b) => (b.priority || 0) - (a.priority || 0)); + userData.loaded = true; + userData.observable.next(userData.handlers); + } + +} + +/** + * Data passed to UPDATE_HANDLER_EVENT event. + */ +export type CoreUserUpdateHandlerData = { + handler: string; // Name of the handler. + userId: number; // User affected. + data: Record; // Data to set to the handler. +}; diff --git a/src/app/core/user/services/user.helper.ts b/src/app/core/user/services/user.helper.ts index d41a3e372..8403939f8 100644 --- a/src/app/core/user/services/user.helper.ts +++ b/src/app/core/user/services/user.helper.ts @@ -33,11 +33,11 @@ export class CoreUserHelperProvider { * @param country Country. * @return Formatted address. */ - formatAddress(address: string, city: string, country: string): string { + formatAddress(address?: string, city?: string, country?: string): string { const separator = Translate.instance.instant('core.listsep'); let values = [address, city, country]; - values = values.filter((value) => value?.length > 0); + values = values.filter((value) => value && value.length > 0); return values.join(separator + ' '); } diff --git a/src/app/core/user/services/user.ts b/src/app/core/user/services/user.ts index 7baae9152..cbfbe710c 100644 --- a/src/app/core/user/services/user.ts +++ b/src/app/core/user/services/user.ts @@ -21,7 +21,8 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreUserOffline } from './user.offline'; import { CoreLogger } from '@singletons/logger'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; -import { makeSingleton } from '@singletons/core.singletons'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreEvents, CoreEventUserDeletedData } from '@singletons/events'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; import { CoreError } from '@classes/errors/error'; import { USERS_TABLE_NAME, CoreUserDBRecord } from './user.db'; @@ -44,6 +45,25 @@ export class CoreUserProvider { constructor() { this.logger = CoreLogger.getInstance('CoreUserProvider'); + + CoreEvents.on(CoreEvents.USER_DELETED, (data) => { + // Search for userid in params. + let userId = 0; + + if (data.params.userid) { + userId = data.params.userid; + } else if (data.params.userids) { + userId = data.params.userids[0]; + } else if (data.params.field === 'id' && data.params.values && data.params.values.length) { + userId = data.params.values[0]; + } else if (data.params.userlist && data.params.userlist.length) { + userId = data.params.userlist[0].userid; + } + + if (userId > 0) { + this.deleteStoredUser(userId, data.siteId); + } + }); } /** @@ -72,6 +92,32 @@ export class CoreUserProvider { return !!site?.wsAvailable('core_enrol_search_users'); } + /** + * Check if WS to update profile picture is available in site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's available. + * @since 3.2 + */ + async canUpdatePicture(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canUpdatePictureInSite(site); + } + + /** + * Check if WS to search participants is available in site. + * + * @param site Site. If not defined, current site. + * @return Whether it's available. + * @since 3.2 + */ + canUpdatePictureInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site?.wsAvailable('core_user_update_picture'); + } + /** * Change the given user profile picture. * @@ -199,7 +245,7 @@ export class CoreUserProvider { courseId?: number, forceLocal: boolean = false, siteId?: string, - ): Promise { + ): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); if (forceLocal) { @@ -291,7 +337,7 @@ export class CoreUserProvider { throw new CoreError('Cannot retrieve user info.'); } - const user = users[0]; + const user: CoreUserData | CoreUserCourseProfile = users[0]; if (user.country) { user.country = CoreUtils.instance.getCountryName(user.country); } @@ -759,6 +805,22 @@ export class CoreUserProvider { export class CoreUser extends makeSingleton(CoreUserProvider) {} +/** + * Data passed to PROFILE_REFRESHED event. + */ +export type CoreUserProfileRefreshedData = { + courseId: number; // Course the user profile belongs to. + user: CoreUserProfile; // User affected. +}; + +/** + * Data passed to PROFILE_PICTURE_UPDATED event. + */ +export type CoreUserProfilePictureUpdatedData = { + userId: number; // User ID. + picture: string | undefined; // New picture URL. +}; + /** * Basic data of a user. */ @@ -921,6 +983,11 @@ export type CoreUserCourseProfile = CoreUserData & { enrolledcourses?: CoreUserEnrolledCourse[]; // Courses where the user is enrolled. }; +/** + * User data returned by getProfile. + */ +export type CoreUserProfile = (CoreUserBasicData & Partial) | CoreUserCourseProfile; + /** * Params of core_user_update_picture WS. */ diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index bd6df82ff..cc24d2297 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -17,7 +17,7 @@ import { Md5 } from 'ts-md5/dist/md5'; import { CoreApp } from '@services/app'; import { CoreDB } from '@services/db'; -import { CoreEvents } from '@singletons/events'; +import { CoreEvents, CoreEventUserDeletedData } from '@singletons/events'; import { CoreFile } from '@services/file'; import { CoreWS, @@ -588,7 +588,7 @@ export class CoreSite { error.message = Translate.instance.instant('core.lostconnection'); } else if (error.errorcode === 'userdeleted') { // User deleted, trigger event. - CoreEvents.trigger(CoreEvents.USER_DELETED, { params: data }, this.id); + CoreEvents.trigger(CoreEvents.USER_DELETED, { params: data }, this.id); error.message = Translate.instance.instant('core.userdeleted'); throw new CoreWSError(error); diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index f7033acd0..4d45a790b 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -85,11 +85,11 @@ export class CoreEvents { * @param siteId Site where to trigger the event. Undefined won't check the site. * @return Observer to stop listening. */ - static on(eventName: string, callBack: (value: unknown) => void, siteId?: string): CoreEventObserver { + static on(eventName: string, callBack: (value: T) => void, siteId?: string): CoreEventObserver { // If it's a unique event and has been triggered already, call the callBack. // We don't need to create an observer because the event won't be triggered again. if (this.uniqueEvents[eventName]) { - callBack(this.uniqueEvents[eventName].data); + callBack( this.uniqueEvents[eventName].data); // Return a fake observer to prevent errors. return { @@ -103,10 +103,10 @@ export class CoreEvents { if (typeof this.observables[eventName] == 'undefined') { // No observable for this event, create a new one. - this.observables[eventName] = new Subject(); + this.observables[eventName] = new Subject(); } - const subscription = this.observables[eventName].subscribe((value: {siteId?: string; [key: string]: unknown}) => { + const subscription = this.observables[eventName].subscribe((value: T & {siteId?: string}) => { if (!siteId || value.siteId == siteId) { callBack(value); } @@ -132,8 +132,8 @@ export class CoreEvents { * @param siteId Site where to trigger the event. Undefined won't check the site. * @return Observer to stop listening. */ - static onMultiple(eventNames: string[], callBack: (value: unknown) => void, siteId?: string): CoreEventObserver { - const observers = eventNames.map((name) => this.on(name, callBack, siteId)); + static onMultiple(eventNames: string[], callBack: (value: T) => void, siteId?: string): CoreEventObserver { + const observers = eventNames.map((name) => this.on(name, callBack, siteId)); // Create and return a CoreEventObserver. return { @@ -152,11 +152,11 @@ export class CoreEvents { * @param data Data to pass to the observers. * @param siteId Site where to trigger the event. Undefined means no Site. */ - static trigger(eventName: string, data?: unknown, siteId?: string): void { + static trigger(eventName: string, data?: T, siteId?: string): void { this.logger.debug(`Event '${eventName}' triggered.`); if (this.observables[eventName]) { if (siteId) { - data = Object.assign(data || {}, { siteId }); + Object.assign(data || {}, { siteId }); } this.observables[eventName].next(data); } @@ -169,14 +169,14 @@ export class CoreEvents { * @param data Data to pass to the observers. * @param siteId Site where to trigger the event. Undefined means no Site. */ - static triggerUnique(eventName: string, data: unknown, siteId?: string): void { + static triggerUnique(eventName: string, data: T, siteId?: string): void { if (this.uniqueEvents[eventName]) { this.logger.debug(`Unique event '${eventName}' ignored because it was already triggered.`); } else { this.logger.debug(`Unique event '${eventName}' triggered.`); if (siteId) { - data = Object.assign(data || {}, { siteId }); + Object.assign(data || {}, { siteId }); } // Store the data so it can be passed to observers that register from now on. @@ -241,3 +241,11 @@ export type CoreEventCourseStatusChanged = { courseId: number; // Course Id. status: string; }; + +/** + * Data passed to USER_DELETED event. + */ +export type CoreEventUserDeletedData = CoreEventSiteData & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: any; // Params sent to the WS that failed. +}; From c608432d240858a64ab9694da6df58abf0ad3977 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 13 Nov 2020 13:43:35 +0100 Subject: [PATCH 03/19] MOBILE-3592 user: Implement profile page --- .../user-avatar/core-user-avatar.html | 11 + .../components/user-avatar/user-avatar.scss | 33 ++ src/app/components/user-avatar/user-avatar.ts | 179 +++++++++++ src/app/core/user/lang/en.json | 27 ++ src/app/core/user/pages/profile/profile.html | 92 ++++++ .../user/pages/profile/profile.page.module.ts | 47 +++ .../core/user/pages/profile/profile.page.ts | 289 ++++++++++++++++++ src/app/core/user/pages/profile/profile.scss | 69 +++++ src/app/core/user/services/user.ts | 3 +- src/app/core/user/user-init.module.ts | 37 +++ src/app/core/user/user-routing.module.ts | 34 +++ src/app/core/user/user.module.ts | 24 ++ src/app/directives/user-link.ts | 66 ++++ src/core/components/components.module.ts | 3 + src/core/directives/directives.module.ts | 3 + .../features/mainmenu/pages/more/more.html | 2 +- src/core/services/utils/dom.ts | 10 +- src/theme/variables.scss | 1 + 18 files changed, 925 insertions(+), 5 deletions(-) create mode 100644 src/app/components/user-avatar/core-user-avatar.html create mode 100644 src/app/components/user-avatar/user-avatar.scss create mode 100644 src/app/components/user-avatar/user-avatar.ts create mode 100644 src/app/core/user/lang/en.json create mode 100644 src/app/core/user/pages/profile/profile.html create mode 100644 src/app/core/user/pages/profile/profile.page.module.ts create mode 100644 src/app/core/user/pages/profile/profile.page.ts create mode 100644 src/app/core/user/pages/profile/profile.scss create mode 100644 src/app/core/user/user-init.module.ts create mode 100644 src/app/core/user/user-routing.module.ts create mode 100644 src/app/core/user/user.module.ts create mode 100644 src/app/directives/user-link.ts diff --git a/src/app/components/user-avatar/core-user-avatar.html b/src/app/components/user-avatar/core-user-avatar.html new file mode 100644 index 000000000..318573f43 --- /dev/null +++ b/src/app/components/user-avatar/core-user-avatar.html @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/app/components/user-avatar/user-avatar.scss b/src/app/components/user-avatar/user-avatar.scss new file mode 100644 index 000000000..679796d19 --- /dev/null +++ b/src/app/components/user-avatar/user-avatar.scss @@ -0,0 +1,33 @@ +:host { + position: relative; + cursor: pointer; + + .contact-status { + position: absolute; + right: 0; + bottom: 0; + width: 14px; + height: 14px; + border-radius: 50%; + &.online { + border: 1px solid white; + background-color: var(--core-online-color); + } + } + + .core-avatar-extra-icon { + margin: 0 !important; + border-radius: 0 !important; + background: none; + position: absolute; + right: -4px; + bottom: -4px; + width: 24px; + height: 24px; + } +} + +:host-context(.toolbar) .contact-status { + width: 10px; + height: 10px; +} \ No newline at end of file diff --git a/src/app/components/user-avatar/user-avatar.ts b/src/app/components/user-avatar/user-avatar.ts new file mode 100644 index 000000000..95e634275 --- /dev/null +++ b/src/app/components/user-avatar/user-avatar.ts @@ -0,0 +1,179 @@ +// (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, Input, OnInit, OnChanges, OnDestroy, SimpleChange } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NavController } from '@ionic/angular'; + +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreObject } from '@singletons/object'; +import { CoreUserProvider, CoreUserBasicData, CoreUserProfilePictureUpdatedData } from '@core/user/services/user'; + +/** + * Component to display a "user avatar". + * + * Example: + */ +@Component({ + selector: 'core-user-avatar', + templateUrl: 'core-user-avatar.html', + styleUrls: ['user-avatar.scss'], +}) +export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy { + + @Input() user?: CoreUserWithAvatar; + // The following params will override the ones in user object. + @Input() profileUrl?: string; + @Input() protected linkProfile = true; // Avoid linking to the profile if wanted. + @Input() fullname?: string; + @Input() protected userId?: number; // If provided or found it will be used to link the image to the profile. + @Input() protected courseId?: number; + @Input() checkOnline = false; // If want to check and show online status. + @Input() extraIcon?: string; // Extra icon to show near the avatar. + + avatarUrl?: string; + + // Variable to check if we consider this user online or not. + // @TODO: Use setting when available (see MDL-63972) so we can use site setting. + protected timetoshowusers = 300000; // Miliseconds default. + protected currentUserId: number; + protected pictureObserver: CoreEventObserver; + + constructor( + protected navCtrl: NavController, + protected route: ActivatedRoute, + ) { + this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); + + this.pictureObserver = CoreEvents.on( + CoreUserProvider.PROFILE_PICTURE_UPDATED, + (data) => { + if (data.userId == this.userId) { + this.avatarUrl = data.picture; + } + }, + CoreSites.instance.getCurrentSiteId(), + ); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.setFields(); + } + + /** + * Listen to changes. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + // If something change, update the fields. + if (changes) { + this.setFields(); + } + } + + /** + * Set fields from user. + */ + protected setFields(): void { + const profileUrl = this.profileUrl || (this.user && (this.user.profileimageurl || this.user.userprofileimageurl || + this.user.userpictureurl || this.user.profileimageurlsmall || (this.user.urls && this.user.urls.profileimage))); + + if (typeof profileUrl == 'string') { + this.avatarUrl = profileUrl; + } + + this.fullname = this.fullname || (this.user && (this.user.fullname || this.user.userfullname)); + + this.userId = this.userId || (this.user && (this.user.userid || this.user.id)); + this.courseId = this.courseId || (this.user && this.user.courseid); + } + + /** + * Helper function for checking the time meets the 'online' condition. + * + * @return boolean + */ + isOnline(): boolean { + if (!this.user) { + return false; + } + + if (CoreUtils.instance.isFalseOrZero(this.user.isonline)) { + return false; + } + + if (this.user.lastaccess) { + // If the time has passed, don't show the online status. + const time = new Date().getTime() - this.timetoshowusers; + + return this.user.lastaccess * 1000 >= time; + } else { + // You have to have Internet access first. + return !!this.user.isonline && CoreApp.instance.isOnline(); + } + } + + /** + * Go to user profile. + * + * @param event Click event. + */ + gotoProfile(event: Event): void { + if (!this.linkProfile || !this.userId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + // @todo Decide which navCtrl to use. If this component is inside a split view, use the split view's master nav. + this.navCtrl.navigateForward(['user'], { + relativeTo: this.route, + queryParams: CoreObject.removeUndefined({ + userId: this.userId, + courseId: this.courseId, + }), + }); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.pictureObserver.off(); + } + +} + +/** + * Type with all possible formats of user. + */ +type CoreUserWithAvatar = CoreUserBasicData & { + userpictureurl?: string; + userprofileimageurl?: string; + profileimageurlsmall?: string; + urls?: { + profileimage?: string; + }; + userfullname?: string; + userid?: number; + isonline?: boolean; + courseid?: number; + lastaccess?: number; +}; diff --git a/src/app/core/user/lang/en.json b/src/app/core/user/lang/en.json new file mode 100644 index 000000000..528fe4c5c --- /dev/null +++ b/src/app/core/user/lang/en.json @@ -0,0 +1,27 @@ +{ + "address": "Address", + "city": "City/town", + "contact": "Contact", + "country": "Country", + "description": "Description", + "details": "Details", + "detailsnotavailable": "The details of this user are not available to you.", + "editingteacher": "Teacher", + "email": "Email address", + "emailagain": "Email (again)", + "errorloaduser": "Error loading user.", + "firstname": "First name", + "interests": "Interests", + "lastname": "Surname", + "manager": "Manager", + "newpicture": "New picture", + "noparticipants": "No participants found for this course", + "participants": "Participants", + "phone1": "Phone", + "phone2": "Mobile phone", + "roles": "Roles", + "sendemail": "Email", + "student": "Student", + "teacher": "Non-editing teacher", + "webpage": "Web page" +} \ No newline at end of file diff --git a/src/app/core/user/pages/profile/profile.html b/src/app/core/user/pages/profile/profile.html new file mode 100644 index 000000000..5f8b50497 --- /dev/null +++ b/src/app/core/user/pages/profile/profile.html @@ -0,0 +1,92 @@ + + + + + + {{ title }} + + + + + + + + + + + + + + + + +

{{ user.fullname }}

+

{{ user.address }}

+

+ {{ 'core.user.roles' | translate}}{{'core.labelsep' | translate}} + {{ rolesFormatted }} +

+
+
+ + + + + + +

{{handler.title | translate}}

+
+
+
+ + + + + +
+ + + + + + + + + +

{{ handler.title | translate }}

+
+
+ + + + + + {{ handler.title | translate }} + + + + +
+ + + + + +
+
diff --git a/src/app/core/user/pages/profile/profile.page.module.ts b/src/app/core/user/pages/profile/profile.page.module.ts new file mode 100644 index 000000000..67f81126b --- /dev/null +++ b/src/app/core/user/pages/profile/profile.page.module.ts @@ -0,0 +1,47 @@ +// (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 { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +import { CoreUserProfilePage } from './profile.page'; + +const routes: Routes = [ + { + path: '', + component: CoreUserProfilePage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + ], + declarations: [ + CoreUserProfilePage, + ], + exports: [RouterModule], +}) +export class CoreUserProfilePageModule {} diff --git a/src/app/core/user/pages/profile/profile.page.ts b/src/app/core/user/pages/profile/profile.page.ts new file mode 100644 index 000000000..96627a18b --- /dev/null +++ b/src/app/core/user/pages/profile/profile.page.ts @@ -0,0 +1,289 @@ +// (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, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { IonRefresher, NavController } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreSite } from '@classes/site'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { Translate } from '@singletons/core.singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { + CoreUser, + CoreUserProfile, + CoreUserProfilePictureUpdatedData, + CoreUserProfileRefreshedData, + CoreUserProvider, +} from '@core/user/services/user'; +import { CoreUserHelper } from '@core/user/services/user.helper'; +import { CoreUserDelegate, CoreUserProfileHandlerData } from '@core/user/services/user.delegate'; +import { CoreFileUploaderHelper } from '@core/fileuploader/services/fileuploader.helper'; +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { CoreUtils } from '@/app/services/utils/utils'; + +@Component({ + selector: 'page-core-user-profile', + templateUrl: 'profile.html', + styleUrls: ['profile.scss'], +}) +export class CoreUserProfilePage implements OnInit, OnDestroy { + + protected courseId!: number; + protected userId!: number; + protected site?: CoreSite; + protected obsProfileRefreshed: CoreEventObserver; + protected subscription?: Subscription; + + userLoaded = false; + isLoadingHandlers = false; + user?: CoreUserProfile; + title?: string; + isDeleted = false; + isEnrolled = true; + canChangeProfilePicture = false; + rolesFormatted?: string; + actionHandlers: CoreUserProfileHandlerData[] = []; + newPageHandlers: CoreUserProfileHandlerData[] = []; + communicationHandlers: CoreUserProfileHandlerData[] = []; + + constructor( + protected route: ActivatedRoute, + protected navCtrl: NavController, + protected userDelegate: CoreUserDelegate, + ) { + + this.obsProfileRefreshed = CoreEvents.on(CoreUserProvider.PROFILE_REFRESHED, (data) => { + if (!this.user || !data.user) { + return; + } + + this.user.email = data.user.email; + this.user.address = CoreUserHelper.instance.formatAddress('', data.user.city, data.user.country); + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * On init. + */ + async ngOnInit(): Promise { + this.site = CoreSites.instance.getCurrentSite(); + this.userId = this.route.snapshot.queryParams['userId']; + this.courseId = this.route.snapshot.queryParams['courseId']; + + if (!this.site) { + return; + } + + // Allow to change the profile image only in the app profile page. + this.canChangeProfilePicture = + (!this.courseId || this.courseId == this.site.getSiteHomeId()) && + this.userId == this.site.getUserId() && + this.site.canUploadFiles() && + CoreUser.instance.canUpdatePictureInSite(this.site) && + !CoreUser.instance.isUpdatePictureDisabledInSite(this.site); + + try { + await this.fetchUser(); + + try { + await CoreUser.instance.logView(this.userId, this.courseId, this.user!.fullname); + } catch (error) { + this.isDeleted = error?.errorcode === 'userdeleted'; + this.isEnrolled = error?.errorcode !== 'notenrolledprofile'; + } + } finally { + this.userLoaded = true; + } + } + + /** + * Fetches the user and updates the view. + */ + async fetchUser(): Promise { + try { + const user = await CoreUser.instance.getProfile(this.userId, this.courseId); + + user.address = CoreUserHelper.instance.formatAddress('', user.city, user.country); + this.rolesFormatted = 'roles' in user ? CoreUserHelper.instance.formatRoleList(user.roles) : ''; + + this.user = user; + this.title = user.fullname; + + // If there's already a subscription, unsubscribe because we'll get a new one. + this.subscription?.unsubscribe(); + + this.subscription = this.userDelegate.getProfileHandlersFor(user, this.courseId).subscribe((handlers) => { + this.actionHandlers = []; + this.newPageHandlers = []; + this.communicationHandlers = []; + handlers.forEach((handler) => { + switch (handler.type) { + case CoreUserDelegate.TYPE_COMMUNICATION: + this.communicationHandlers.push(handler.data); + break; + case CoreUserDelegate.TYPE_ACTION: + this.actionHandlers.push(handler.data); + break; + case CoreUserDelegate.TYPE_NEW_PAGE: + default: + this.newPageHandlers.push(handler.data); + break; + } + }); + + this.isLoadingHandlers = !this.userDelegate.areHandlersLoaded(user.id); + }); + + await this.checkUserImageUpdated(); + + } catch (error) { + // Error is null for deleted users, do not show the modal. + CoreDomUtils.instance.showErrorModal(error); + } + } + + /** + * Check if current user image has changed. + * + * @return Promise resolved when done. + */ + protected async checkUserImageUpdated(): Promise { + if (!this.site || !this.site.getInfo() || !this.user) { + return; + } + + if (this.userId != this.site.getUserId() || this.user.profileimageurl == this.site.getInfo()!.userpictureurl) { + // Not current user or hasn't changed. + return; + } + + // The current user image received is different than the one stored in site info. Assume the image was updated. + // Update the site info to get the right avatar in there. + try { + await CoreSites.instance.updateSiteInfo(this.site.getId()); + } catch { + // Cannot update site info. Assume the profile image is the right one. + CoreEvents.trigger(CoreUserProvider.PROFILE_PICTURE_UPDATED, { + userId: this.userId, + picture: this.user.profileimageurl, + }, this.site.getId()); + } + + if (this.user.profileimageurl != this.site.getInfo()!.userpictureurl) { + // The image is still different, this means that the good one is the one in site info. + await this.refreshUser(); + } else { + // Now they're the same, send event to use the right avatar in the rest of the app. + CoreEvents.trigger(CoreUserProvider.PROFILE_PICTURE_UPDATED, { + userId: this.userId, + picture: this.user.profileimageurl, + }, this.site.getId()); + } + } + + /** + * Opens dialog to change profile picture. + */ + async changeProfilePicture(): Promise { + const maxSize = -1; + const title = Translate.instance.instant('core.user.newpicture'); + const mimetypes = CoreMimetypeUtils.instance.getGroupMimeInfo('image', 'mimetypes'); + let modal: CoreIonLoadingElement | undefined; + + try { + const result = await CoreFileUploaderHelper.instance.selectAndUploadFile(maxSize, title, mimetypes); + + modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + const profileImageURL = await CoreUser.instance.changeProfilePicture(result.itemid, this.userId, this.site!.getId()); + + CoreEvents.trigger(CoreUserProvider.PROFILE_PICTURE_UPDATED, { + userId: this.userId, + picture: profileImageURL, + }, this.site!.getId()); + + CoreSites.instance.updateSiteInfo(this.site!.getId()); + + this.refreshUser(); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + modal?.dismiss(); + } + } + + /** + * Refresh the user. + * + * @param event Event. + * @return Promise resolved when done. + */ + async refreshUser(event?: CustomEvent): Promise { + await CoreUtils.instance.ignoreErrors(Promise.all([ + CoreUser.instance.invalidateUserCache(this.userId), + // @todo this.coursesProvider.invalidateUserNavigationOptions(), + // this.coursesProvider.invalidateUserAdministrationOptions() + ])); + + await this.fetchUser(); + + event?.detail.complete(); + + if (this.user) { + CoreEvents.trigger(CoreUserProvider.PROFILE_REFRESHED, { + courseId: this.courseId, + userId: this.userId, + user: this.user, + }, this.site?.getId()); + } + } + + /** + * Open the page with the user details. + */ + openUserDetails(): void { + // @todo: Navigate out of split view if this page is in the right pane. + this.navCtrl.navigateForward(['../about'], { + relativeTo: this.route, + queryParams: { + courseId: this.courseId, + userId: this.userId, + }, + }); + } + + /** + * A handler was clicked. + * + * @param event Click event. + * @param handler Handler that was clicked. + */ + handlerClicked(event: Event, handler: CoreUserProfileHandlerData): void { + // @todo: Pass the right navCtrl if this page is in the right pane of split view. + handler.action(event, this.navCtrl, this.user!, this.courseId); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + this.obsProfileRefreshed.off(); + } + +} diff --git a/src/app/core/user/pages/profile/profile.scss b/src/app/core/user/pages/profile/profile.scss new file mode 100644 index 000000000..454b80ab1 --- /dev/null +++ b/src/app/core/user/pages/profile/profile.scss @@ -0,0 +1,69 @@ +:host { + // @todo + // .core-icon-foreground { + // position: absolute; + // @include position(null, 0, 0, null); + // font-size: 24px; + // line-height: 30px; + // text-align: center; + + // width: 30px; + // height: 30px; + // border-radius: 50%; + // background-color: $white; + // @include darkmode() { + // background: $core-dark-item-bg-color; + // } + // } + // [core-user-avatar].item-avatar-center { + // display: inline-block; + // img { + // margin: 0; + // } + // .contact-status { + // width: 24px; + // height: 24px; + // } + // } + + // .core-user-communication-handlers { + // background: $list-background-color; + // border-bottom: 1px solid $list-border-color; + + // @include darkmode() { + // background: $core-dark-item-bg-color; + // } + + // .core-user-profile-handler { + // background: $list-background-color; + // border: 0; + // color: $core-user-profile-communication-icons-color; + + // @include darkmode() { + // background: $core-dark-item-bg-color; + // } + + // p { + // margin: 0; + // } + + // .icon { + // border-radius: 50%; + // width: 32px; + // height: 32px; + // max-width: 32px; + // font-size: 22px; + // line-height: 32px; + // color: white; + // background-color: $core-user-profile-communication-icons-color; + // margin-bottom: 5px; + // } + // } + // } + + // .core-user-profile-handler { + // ion-spinner { + // @include margin(null, null, null, 0.3em); + // } + // } +} \ No newline at end of file diff --git a/src/app/core/user/services/user.ts b/src/app/core/user/services/user.ts index cbfbe710c..4bec34d76 100644 --- a/src/app/core/user/services/user.ts +++ b/src/app/core/user/services/user.ts @@ -810,7 +810,8 @@ export class CoreUser extends makeSingleton(CoreUserProvider) {} */ export type CoreUserProfileRefreshedData = { courseId: number; // Course the user profile belongs to. - user: CoreUserProfile; // User affected. + userId: number; // User ID. + user?: CoreUserProfile; // User affected. }; /** diff --git a/src/app/core/user/user-init.module.ts b/src/app/core/user/user-init.module.ts new file mode 100644 index 000000000..5a98299d0 --- /dev/null +++ b/src/app/core/user/user-init.module.ts @@ -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 { Routes } from '@angular/router'; + +import { CoreMainMenuRoutingModule } from '@core/mainmenu/mainmenu-routing.module'; + +const routes: Routes = [ + { + path: 'user', + loadChildren: () => import('@core/user/user.module').then(m => m.CoreUserModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuRoutingModule.forChild(routes), + ], + exports: [ + CoreMainMenuRoutingModule, + ], + providers: [ + ], +}) +export class CoreUserInitModule {} diff --git a/src/app/core/user/user-routing.module.ts b/src/app/core/user/user-routing.module.ts new file mode 100644 index 000000000..0d117604f --- /dev/null +++ b/src/app/core/user/user-routing.module.ts @@ -0,0 +1,34 @@ +// (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 { RouterModule, Routes } from '@angular/router'; + +const routes: Routes = [ + { + path: '', + redirectTo: 'profile', + pathMatch: 'full', + }, + { + path: 'profile', + loadChildren: () => import('./pages/profile/profile.page.module').then( m => m.CoreUserProfilePageModule), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class CoreUserRoutingModule {} diff --git a/src/app/core/user/user.module.ts b/src/app/core/user/user.module.ts new file mode 100644 index 000000000..2ea4d7f42 --- /dev/null +++ b/src/app/core/user/user.module.ts @@ -0,0 +1,24 @@ +// (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 { CoreUserRoutingModule } from './user-routing.module'; + +@NgModule({ + imports: [ + CoreUserRoutingModule, + ], +}) +export class CoreUserModule {} diff --git a/src/app/directives/user-link.ts b/src/app/directives/user-link.ts new file mode 100644 index 000000000..f731306a9 --- /dev/null +++ b/src/app/directives/user-link.ts @@ -0,0 +1,66 @@ +// (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 { Directive, Input, OnInit, ElementRef } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NavController } from '@ionic/angular'; + +import { CoreObject } from '@singletons/object'; + +/** + * Directive to go to user profile on click. + */ +@Directive({ + selector: '[core-user-link]', +}) +export class CoreUserLinkDirective implements OnInit { + + @Input() userId?: number; // User id to open the profile. + @Input() courseId?: number; // If set, course id to show the user info related to that course. + + protected element: HTMLElement; + + constructor( + element: ElementRef, + protected navCtrl: NavController, + protected route: ActivatedRoute, + ) { + this.element = element.nativeElement; + } + + /** + * Function executed when the component is initialized. + */ + ngOnInit(): void { + this.element.addEventListener('click', (event) => { + // If the event prevented default action, do nothing. + if (event.defaultPrevented || !this.userId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + // @todo If this directive is inside a split view, use the split view's master nav. + this.navCtrl.navigateForward(['user'], { + relativeTo: this.route, + queryParams: CoreObject.removeUndefined({ + userId: this.userId, + courseId: this.courseId, + }), + }); + }); + } + +} diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index cfa381f7f..76c6ec45a 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -35,6 +35,7 @@ import { CoreProgressBarComponent } from './progress-bar/progress-bar'; import { CoreContextMenuComponent } from './context-menu/context-menu'; import { CoreContextMenuItemComponent } from './context-menu/context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover'; +import { CoreUserAvatarComponent } from './user-avatar/user-avatar'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; @@ -61,6 +62,7 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; CoreContextMenuItemComponent, CoreContextMenuPopoverComponent, CoreNavBarButtonsComponent, + CoreUserAvatarComponent, ], imports: [ CommonModule, @@ -89,6 +91,7 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; CoreContextMenuItemComponent, CoreContextMenuPopoverComponent, CoreNavBarButtonsComponent, + CoreUserAvatarComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts index 3be437b3c..06e4c6fa5 100644 --- a/src/core/directives/directives.module.ts +++ b/src/core/directives/directives.module.ts @@ -22,6 +22,7 @@ import { CoreLinkDirective } from './link'; import { CoreLongPressDirective } from './long-press'; import { CoreSupressEventsDirective } from './supress-events'; import { CoreFaIconDirective } from './fa-icon'; +import { CoreUserLinkDirective } from './user-link'; @NgModule({ declarations: [ @@ -33,6 +34,7 @@ import { CoreFaIconDirective } from './fa-icon'; CoreSupressEventsDirective, CoreFabDirective, CoreFaIconDirective, + CoreUserLinkDirective, ], imports: [], exports: [ @@ -44,6 +46,7 @@ import { CoreFaIconDirective } from './fa-icon'; CoreSupressEventsDirective, CoreFabDirective, CoreFaIconDirective, + CoreUserLinkDirective, ], }) export class CoreDirectivesModule {} diff --git a/src/core/features/mainmenu/pages/more/more.html b/src/core/features/mainmenu/pages/more/more.html index 4776dc9b5..84dee2d54 100644 --- a/src/core/features/mainmenu/pages/more/more.html +++ b/src/core/features/mainmenu/pages/more/more.html @@ -9,7 +9,7 @@ - +

{{siteInfo.fullname}}

diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 247e2e209..e7ae51d23 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -656,6 +656,10 @@ export class CoreDomUtilsProvider { * @return Error message, null if no error should be displayed. */ getErrorMessage(error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean): string | null { + if (typeof error != 'string' && !error) { + return null; + } + let extraInfo = ''; let errorMessage: string | undefined; @@ -1332,21 +1336,21 @@ export class CoreDomUtilsProvider { * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. * @return Promise resolved with the alert modal. */ - showErrorModal( + async showErrorModal( error: CoreError | CoreTextErrorObject | string, needsTranslate?: boolean, autocloseTime?: number, ): Promise { if (this.isCanceledError(error)) { // It's a canceled error, don't display an error. - return Promise.resolve(null); + return null; } const message = this.getErrorMessage(error, needsTranslate); if (message === null) { // Message doesn't need to be displayed, stop. - return Promise.resolve(null); + return null; } const alertOptions: AlertOptions = { diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 29eebdf2c..836edd3fd 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -24,6 +24,7 @@ --purple: var(--custom-purple, #8e24aa); // Accent (never text). --core-color: var(--custom-main-color, var(--orange)); + --core-online-color: #5cb85c; --ion-color-primary: var(--core-color); --ion-color-primary-rgb: 249,128,18; From 537846e1cfffbbfd1affce077ba90feaac6a35ab Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 17 Nov 2020 11:00:46 +0100 Subject: [PATCH 04/19] MOBILE-3592 user: Implement about page --- src/app/core/user/pages/about/about.html | 97 +++++++++++++++ .../user/pages/about/about.page.module.ts | 47 +++++++ src/app/core/user/pages/about/about.page.ts | 115 ++++++++++++++++++ src/app/core/user/user-routing.module.ts | 4 + src/theme/app.scss | 6 + 5 files changed, 269 insertions(+) create mode 100644 src/app/core/user/pages/about/about.html create mode 100644 src/app/core/user/pages/about/about.page.module.ts create mode 100644 src/app/core/user/pages/about/about.page.ts diff --git a/src/app/core/user/pages/about/about.html b/src/app/core/user/pages/about/about.html new file mode 100644 index 000000000..c45d302d3 --- /dev/null +++ b/src/app/core/user/pages/about/about.html @@ -0,0 +1,97 @@ + + + + + + {{ title }} + + + + + + + + + + {{ 'core.user.contact' | translate}} + + +

{{ 'core.user.email' | translate }}

+

+ {{ user.email }} +

+
+
+ + +

{{ 'core.user.phone1' | translate}}

+

+ {{ user.phone1 }} +

+
+
+ + +

{{ 'core.user.phone2' | translate}}

+

+ {{ user.phone2 }} +

+
+
+ + +

{{ 'core.user.address' | translate}}

+

+ {{ user.address }} +

+
+
+ + +

{{ 'core.user.city' | translate}}

+

{{ user.city }}

+
+
+ + +

{{ 'core.user.country' | translate}}

+

{{ user.country }}

+
+
+
+ + {{ 'core.userdetails' | translate}} + + +

{{ 'core.user.webpage' | translate}}

+

+ {{ user.url }} +

+
+
+ + +

{{ 'core.user.interests' | translate}}

+

{{ user.interests }}

+
+
+ +
+ + {{ 'core.user.description' | translate}} + + +

+

+
+
+
+
+ + + +
+
diff --git a/src/app/core/user/pages/about/about.page.module.ts b/src/app/core/user/pages/about/about.page.module.ts new file mode 100644 index 000000000..daa3c8811 --- /dev/null +++ b/src/app/core/user/pages/about/about.page.module.ts @@ -0,0 +1,47 @@ +// (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 { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +import { CoreUserAboutPage } from './about.page'; + +const routes: Routes = [ + { + path: '', + component: CoreUserAboutPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + ], + declarations: [ + CoreUserAboutPage, + ], + exports: [RouterModule], +}) +export class CoreUserAboutPageModule {} diff --git a/src/app/core/user/pages/about/about.page.ts b/src/app/core/user/pages/about/about.page.ts new file mode 100644 index 000000000..c7730eb83 --- /dev/null +++ b/src/app/core/user/pages/about/about.page.ts @@ -0,0 +1,115 @@ +// (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 } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { SafeUrl } from '@angular/platform-browser'; +import { IonRefresher } from '@ionic/angular'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreEvents } from '@singletons/events'; +import { CoreUser, CoreUserProfile, CoreUserProfileRefreshedData, CoreUserProvider } from '@core/user/services/user'; +import { CoreUserHelper } from '@core/user/services/user.helper'; + +/** + * Page that displays info about a user. + */ +@Component({ + selector: 'page-core-user-about', + templateUrl: 'about.html', +}) +export class CoreUserAboutPage implements OnInit { + + protected courseId!: number; + protected userId!: number; + protected siteId: string; + + userLoaded = false; + hasContact = false; + hasDetails = false; + user?: CoreUserProfile; + title?: string; + formattedAddress?: string; + encodedAddress?: SafeUrl; + + constructor( + protected route: ActivatedRoute, + ) { + this.siteId = CoreSites.instance.getCurrentSiteId(); + } + + /** + * On init. + * + * @return Promise resolved when done. + */ + async ngOnInit(): Promise { + this.userId = this.route.snapshot.queryParams['userId']; + this.courseId = this.route.snapshot.queryParams['courseId']; + + this.fetchUser().finally(() => { + this.userLoaded = true; + }); + } + + /** + * Fetches the user data. + * + * @return Promise resolved when done. + */ + async fetchUser(): Promise { + try { + const user = await CoreUser.instance.getProfile(this.userId, this.courseId); + + if (user.address) { + this.formattedAddress = CoreUserHelper.instance.formatAddress(user.address, user.city, user.country); + this.encodedAddress = CoreTextUtils.instance.buildAddressURL(user.address); + } + + this.hasContact = !!(user.email || user.phone1 || user.phone2 || user.city || user.country || user.address); + this.hasDetails = !!(user.url || user.interests || (user.customfields && user.customfields.length > 0)); + + this.user = user; + this.title = user.fullname; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.user.errorloaduser', true); + } + } + + /** + * Refresh the user data. + * + * @param event Event. + * @return Promise resolved when done. + */ + async refreshUser(event?: CustomEvent): Promise { + await CoreUtils.instance.ignoreErrors(CoreUser.instance.invalidateUserCache(this.userId)); + + await this.fetchUser(); + + event?.detail.complete(); + + if (this.user) { + CoreEvents.trigger(CoreUserProvider.PROFILE_REFRESHED, { + courseId: this.courseId, + userId: this.userId, + user: this.user, + }, this.siteId); + } + } + +} diff --git a/src/app/core/user/user-routing.module.ts b/src/app/core/user/user-routing.module.ts index 0d117604f..75a460cd6 100644 --- a/src/app/core/user/user-routing.module.ts +++ b/src/app/core/user/user-routing.module.ts @@ -25,6 +25,10 @@ const routes: Routes = [ path: 'profile', loadChildren: () => import('./pages/profile/profile.page.module').then( m => m.CoreUserProfilePageModule), }, + { + path: 'about', + loadChildren: () => import('./pages/about/about.page.module').then( m => m.CoreUserAboutPageModule), + }, ]; @NgModule({ diff --git a/src/theme/app.scss b/src/theme/app.scss index f48f74a42..66a988c47 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -261,3 +261,9 @@ ion-select.core-button-select, z-index: 100; cursor: pointer; } + +.core-anchor, core-format-text a { + color: -webkit-link; + cursor: pointer; + text-decoration: underline; +} From fa294d713507e57bdb6ed4f206d74021d2aa49da Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 23 Nov 2020 11:25:13 +0100 Subject: [PATCH 05/19] MOBILE-3592 user: Adapt user feature to new folder structure --- src/app/core/user/user.module.ts | 24 ------------------- .../user-avatar/core-user-avatar.html | 0 .../components/user-avatar/user-avatar.scss | 0 .../components/user-avatar/user-avatar.ts | 2 +- src/{app => core}/directives/user-link.ts | 0 src/core/features/features.module.ts | 2 ++ .../core => core/features}/user/lang/en.json | 0 .../features}/user/pages/about/about.html | 0 .../user/pages/about/about.module.ts} | 0 .../features}/user/pages/about/about.page.ts | 4 ++-- .../features}/user/pages/profile/profile.html | 0 .../user/pages/profile/profile.module.ts} | 0 .../user/pages/profile/profile.page.ts | 12 +++++----- .../features}/user/pages/profile/profile.scss | 0 .../features/user/services/db/user.ts} | 9 ++----- .../features/user/services/user-delegate.ts} | 0 .../features/user/services/user-helper.ts} | 2 +- .../features/user/services/user-offline.ts} | 4 ++-- .../features}/user/services/user.ts | 6 ++--- .../features/user/user-lazy.module.ts} | 7 +++--- .../features/user/user.module.ts} | 21 ++++++++++------ 21 files changed, 36 insertions(+), 57 deletions(-) delete mode 100644 src/app/core/user/user.module.ts rename src/{app => core}/components/user-avatar/core-user-avatar.html (100%) rename src/{app => core}/components/user-avatar/user-avatar.scss (100%) rename src/{app => core}/components/user-avatar/user-avatar.ts (99%) rename src/{app => core}/directives/user-link.ts (100%) rename src/{app/core => core/features}/user/lang/en.json (100%) rename src/{app/core => core/features}/user/pages/about/about.html (100%) rename src/{app/core/user/pages/about/about.page.module.ts => core/features/user/pages/about/about.module.ts} (100%) rename src/{app/core => core/features}/user/pages/about/about.page.ts (96%) rename src/{app/core => core/features}/user/pages/profile/profile.html (100%) rename src/{app/core/user/pages/profile/profile.page.module.ts => core/features/user/pages/profile/profile.module.ts} (100%) rename src/{app/core => core/features}/user/pages/profile/profile.page.ts (96%) rename src/{app/core => core/features}/user/pages/profile/profile.scss (100%) rename src/{app/core/user/services/user.db.ts => core/features/user/services/db/user.ts} (90%) rename src/{app/core/user/services/user.delegate.ts => core/features/user/services/user-delegate.ts} (100%) rename src/{app/core/user/services/user.helper.ts => core/features/user/services/user-helper.ts} (96%) rename src/{app/core/user/services/user.offline.ts => core/features/user/services/user-offline.ts} (97%) rename src/{app/core => core/features}/user/services/user.ts (99%) rename src/{app/core/user/user-routing.module.ts => core/features/user/user-lazy.module.ts} (76%) rename src/{app/core/user/user-init.module.ts => core/features/user/user.module.ts} (56%) diff --git a/src/app/core/user/user.module.ts b/src/app/core/user/user.module.ts deleted file mode 100644 index 2ea4d7f42..000000000 --- a/src/app/core/user/user.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -// (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 { CoreUserRoutingModule } from './user-routing.module'; - -@NgModule({ - imports: [ - CoreUserRoutingModule, - ], -}) -export class CoreUserModule {} diff --git a/src/app/components/user-avatar/core-user-avatar.html b/src/core/components/user-avatar/core-user-avatar.html similarity index 100% rename from src/app/components/user-avatar/core-user-avatar.html rename to src/core/components/user-avatar/core-user-avatar.html diff --git a/src/app/components/user-avatar/user-avatar.scss b/src/core/components/user-avatar/user-avatar.scss similarity index 100% rename from src/app/components/user-avatar/user-avatar.scss rename to src/core/components/user-avatar/user-avatar.scss diff --git a/src/app/components/user-avatar/user-avatar.ts b/src/core/components/user-avatar/user-avatar.ts similarity index 99% rename from src/app/components/user-avatar/user-avatar.ts rename to src/core/components/user-avatar/user-avatar.ts index 95e634275..565971a9a 100644 --- a/src/app/components/user-avatar/user-avatar.ts +++ b/src/core/components/user-avatar/user-avatar.ts @@ -21,7 +21,7 @@ import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreObject } from '@singletons/object'; -import { CoreUserProvider, CoreUserBasicData, CoreUserProfilePictureUpdatedData } from '@core/user/services/user'; +import { CoreUserProvider, CoreUserBasicData, CoreUserProfilePictureUpdatedData } from '@features/user/services/user'; /** * Component to display a "user avatar". diff --git a/src/app/directives/user-link.ts b/src/core/directives/user-link.ts similarity index 100% rename from src/app/directives/user-link.ts rename to src/core/directives/user-link.ts diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index aaf9d1cf5..2219597d1 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -23,6 +23,7 @@ import { CoreMainMenuModule } from './mainmenu/mainmenu.module'; import { CoreSettingsModule } from './settings/settings.module'; import { CoreSiteHomeModule } from './sitehome/sitehome.module'; import { CoreTagModule } from './tag/tag.module'; +import { CoreUserModule } from './user/user.module'; @NgModule({ imports: [ @@ -35,6 +36,7 @@ import { CoreTagModule } from './tag/tag.module'; CoreSettingsModule, CoreSiteHomeModule, CoreTagModule, + CoreUserModule, ], }) export class CoreFeaturesModule {} diff --git a/src/app/core/user/lang/en.json b/src/core/features/user/lang/en.json similarity index 100% rename from src/app/core/user/lang/en.json rename to src/core/features/user/lang/en.json diff --git a/src/app/core/user/pages/about/about.html b/src/core/features/user/pages/about/about.html similarity index 100% rename from src/app/core/user/pages/about/about.html rename to src/core/features/user/pages/about/about.html diff --git a/src/app/core/user/pages/about/about.page.module.ts b/src/core/features/user/pages/about/about.module.ts similarity index 100% rename from src/app/core/user/pages/about/about.page.module.ts rename to src/core/features/user/pages/about/about.module.ts diff --git a/src/app/core/user/pages/about/about.page.ts b/src/core/features/user/pages/about/about.page.ts similarity index 96% rename from src/app/core/user/pages/about/about.page.ts rename to src/core/features/user/pages/about/about.page.ts index c7730eb83..816081331 100644 --- a/src/app/core/user/pages/about/about.page.ts +++ b/src/core/features/user/pages/about/about.page.ts @@ -22,8 +22,8 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import { CoreEvents } from '@singletons/events'; -import { CoreUser, CoreUserProfile, CoreUserProfileRefreshedData, CoreUserProvider } from '@core/user/services/user'; -import { CoreUserHelper } from '@core/user/services/user.helper'; +import { CoreUser, CoreUserProfile, CoreUserProfileRefreshedData, CoreUserProvider } from '@features/user/services/user'; +import { CoreUserHelper } from '@features/user/services/user-helper'; /** * Page that displays info about a user. diff --git a/src/app/core/user/pages/profile/profile.html b/src/core/features/user/pages/profile/profile.html similarity index 100% rename from src/app/core/user/pages/profile/profile.html rename to src/core/features/user/pages/profile/profile.html diff --git a/src/app/core/user/pages/profile/profile.page.module.ts b/src/core/features/user/pages/profile/profile.module.ts similarity index 100% rename from src/app/core/user/pages/profile/profile.page.module.ts rename to src/core/features/user/pages/profile/profile.module.ts diff --git a/src/app/core/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts similarity index 96% rename from src/app/core/user/pages/profile/profile.page.ts rename to src/core/features/user/pages/profile/profile.page.ts index 96627a18b..537ce68c8 100644 --- a/src/app/core/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -21,7 +21,7 @@ import { CoreSite } from '@classes/site'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; -import { Translate } from '@singletons/core.singletons'; +import { Translate } from '@singletons'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreUser, @@ -29,12 +29,12 @@ import { CoreUserProfilePictureUpdatedData, CoreUserProfileRefreshedData, CoreUserProvider, -} from '@core/user/services/user'; -import { CoreUserHelper } from '@core/user/services/user.helper'; -import { CoreUserDelegate, CoreUserProfileHandlerData } from '@core/user/services/user.delegate'; -import { CoreFileUploaderHelper } from '@core/fileuploader/services/fileuploader.helper'; +} from '@features/user/services/user'; +import { CoreUserHelper } from '@features/user/services/user-helper'; +import { CoreUserDelegate, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; +import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper'; import { CoreIonLoadingElement } from '@classes/ion-loading'; -import { CoreUtils } from '@/app/services/utils/utils'; +import { CoreUtils } from '@services/utils/utils'; @Component({ selector: 'page-core-user-profile', diff --git a/src/app/core/user/pages/profile/profile.scss b/src/core/features/user/pages/profile/profile.scss similarity index 100% rename from src/app/core/user/pages/profile/profile.scss rename to src/core/features/user/pages/profile/profile.scss diff --git a/src/app/core/user/services/user.db.ts b/src/core/features/user/services/db/user.ts similarity index 90% rename from src/app/core/user/services/user.db.ts rename to src/core/features/user/services/db/user.ts index 8f19b47fd..7fc2e7a1c 100644 --- a/src/app/core/user/services/user.db.ts +++ b/src/core/features/user/services/db/user.ts @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreSiteSchema, registerSiteSchema } from '@services/sites'; -import { CoreUserBasicData } from './user'; +import { CoreSiteSchema } from '@services/sites'; +import { CoreUserBasicData } from '../user'; /** * Database variables for CoreUser service. @@ -88,8 +88,3 @@ export type CoreUserPreferenceDBRecord = { value: string; onlinevalue: string; }; - -export const initCoreUserDB = (): void => { - registerSiteSchema(SITE_SCHEMA); - registerSiteSchema(OFFLINE_SITE_SCHEMA); -}; diff --git a/src/app/core/user/services/user.delegate.ts b/src/core/features/user/services/user-delegate.ts similarity index 100% rename from src/app/core/user/services/user.delegate.ts rename to src/core/features/user/services/user-delegate.ts diff --git a/src/app/core/user/services/user.helper.ts b/src/core/features/user/services/user-helper.ts similarity index 96% rename from src/app/core/user/services/user.helper.ts rename to src/core/features/user/services/user-helper.ts index 8403939f8..ffd636c6b 100644 --- a/src/app/core/user/services/user.helper.ts +++ b/src/core/features/user/services/user-helper.ts @@ -14,7 +14,7 @@ import { Injectable } from '@angular/core'; -import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { makeSingleton, Translate } from '@singletons'; import { CoreUserRole } from './user'; /** diff --git a/src/app/core/user/services/user.offline.ts b/src/core/features/user/services/user-offline.ts similarity index 97% rename from src/app/core/user/services/user.offline.ts rename to src/core/features/user/services/user-offline.ts index fbc298cc6..5afd27b7c 100644 --- a/src/app/core/user/services/user.offline.ts +++ b/src/core/features/user/services/user-offline.ts @@ -15,8 +15,8 @@ import { Injectable } from '@angular/core'; import { CoreSites } from '@services/sites'; -import { makeSingleton } from '@singletons/core.singletons'; -import { PREFERENCES_TABLE_NAME, CoreUserPreferenceDBRecord } from './user.db'; +import { makeSingleton } from '@singletons'; +import { PREFERENCES_TABLE_NAME, CoreUserPreferenceDBRecord } from './db/user'; /** * Service to handle offline user preferences. diff --git a/src/app/core/user/services/user.ts b/src/core/features/user/services/user.ts similarity index 99% rename from src/app/core/user/services/user.ts rename to src/core/features/user/services/user.ts index 4bec34d76..2f1e074ab 100644 --- a/src/app/core/user/services/user.ts +++ b/src/core/features/user/services/user.ts @@ -18,14 +18,14 @@ import { CoreApp } from '@services/app'; import { CoreFilepool } from '@services/filepool'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; -import { CoreUserOffline } from './user.offline'; +import { CoreUserOffline } from './user-offline'; import { CoreLogger } from '@singletons/logger'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; -import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { makeSingleton } from '@singletons'; import { CoreEvents, CoreEventUserDeletedData } from '@singletons/events'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; import { CoreError } from '@classes/errors/error'; -import { USERS_TABLE_NAME, CoreUserDBRecord } from './user.db'; +import { USERS_TABLE_NAME, CoreUserDBRecord } from './db/user'; const ROOT_CACHE_KEY = 'mmUser:'; diff --git a/src/app/core/user/user-routing.module.ts b/src/core/features/user/user-lazy.module.ts similarity index 76% rename from src/app/core/user/user-routing.module.ts rename to src/core/features/user/user-lazy.module.ts index 75a460cd6..b7f04d730 100644 --- a/src/app/core/user/user-routing.module.ts +++ b/src/core/features/user/user-lazy.module.ts @@ -23,16 +23,15 @@ const routes: Routes = [ }, { path: 'profile', - loadChildren: () => import('./pages/profile/profile.page.module').then( m => m.CoreUserProfilePageModule), + loadChildren: () => import('./pages/profile/profile.module').then( m => m.CoreUserProfilePageModule), }, { path: 'about', - loadChildren: () => import('./pages/about/about.page.module').then( m => m.CoreUserAboutPageModule), + loadChildren: () => import('./pages/about/about.module').then( m => m.CoreUserAboutPageModule), }, ]; @NgModule({ imports: [RouterModule.forChild(routes)], - exports: [RouterModule], }) -export class CoreUserRoutingModule {} +export class CoreUserLazyModule {} diff --git a/src/app/core/user/user-init.module.ts b/src/core/features/user/user.module.ts similarity index 56% rename from src/app/core/user/user-init.module.ts rename to src/core/features/user/user.module.ts index 5a98299d0..26f0e6c98 100644 --- a/src/app/core/user/user-init.module.ts +++ b/src/core/features/user/user.module.ts @@ -15,23 +15,30 @@ import { NgModule } from '@angular/core'; import { Routes } from '@angular/router'; -import { CoreMainMenuRoutingModule } from '@core/mainmenu/mainmenu-routing.module'; +import { CoreMainMenuMoreRoutingModule } from '@features/mainmenu/pages/more/more-routing.module'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/db/user'; const routes: Routes = [ { path: 'user', - loadChildren: () => import('@core/user/user.module').then(m => m.CoreUserModule), + loadChildren: () => import('@features/user/user-lazy.module').then(m => m.CoreUserLazyModule), }, ]; @NgModule({ imports: [ - CoreMainMenuRoutingModule.forChild(routes), - ], - exports: [ - CoreMainMenuRoutingModule, + CoreMainMenuMoreRoutingModule.forChild({ siblings: routes }), ], providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ + SITE_SCHEMA, + OFFLINE_SITE_SCHEMA, + ], + multi: true, + }, ], }) -export class CoreUserInitModule {} +export class CoreUserModule {} From 3722126b5b4405233b0361997e0a7271dad7e7ec Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 27 Nov 2020 14:53:50 +0100 Subject: [PATCH 06/19] MOBILE-3592 user: Implement profile field delegate and component --- src/core/components/components.module.ts | 3 + .../core-dynamic-component.html | 5 + .../dynamic-component/dynamic-component.ts | 198 +++++++++++++++++ .../pages/email-signup/email-signup.html | 4 +- .../pages/email-signup/email-signup.module.ts | 2 + .../login/pages/email-signup/email-signup.ts | 52 ++++- .../user/components/components.module.ts | 43 ++++ .../core-user-profile-field.html | 1 + .../user-profile-field/user-profile-field.ts | 83 +++++++ src/core/features/user/pages/about/about.html | 2 +- .../features/user/pages/about/about.module.ts | 2 + .../services/user-profile-field-delegate.ts | 210 ++++++++++++++++++ src/core/features/user/user.module.ts | 2 + 13 files changed, 595 insertions(+), 12 deletions(-) create mode 100644 src/core/components/dynamic-component/core-dynamic-component.html create mode 100644 src/core/components/dynamic-component/dynamic-component.ts create mode 100644 src/core/features/user/components/components.module.ts create mode 100644 src/core/features/user/components/user-profile-field/core-user-profile-field.html create mode 100644 src/core/features/user/components/user-profile-field/user-profile-field.ts create mode 100644 src/core/features/user/services/user-profile-field-delegate.ts diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 76c6ec45a..416cd3999 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -36,6 +36,7 @@ import { CoreContextMenuComponent } from './context-menu/context-menu'; import { CoreContextMenuItemComponent } from './context-menu/context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover'; import { CoreUserAvatarComponent } from './user-avatar/user-avatar'; +import { CoreDynamicComponent } from './dynamic-component/dynamic-component'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; @@ -63,6 +64,7 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; CoreContextMenuPopoverComponent, CoreNavBarButtonsComponent, CoreUserAvatarComponent, + CoreDynamicComponent, ], imports: [ CommonModule, @@ -92,6 +94,7 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; CoreContextMenuPopoverComponent, CoreNavBarButtonsComponent, CoreUserAvatarComponent, + CoreDynamicComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/dynamic-component/core-dynamic-component.html b/src/core/components/dynamic-component/core-dynamic-component.html new file mode 100644 index 000000000..99c89fec9 --- /dev/null +++ b/src/core/components/dynamic-component/core-dynamic-component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/core/components/dynamic-component/dynamic-component.ts b/src/core/components/dynamic-component/dynamic-component.ts new file mode 100644 index 000000000..5ae5512ab --- /dev/null +++ b/src/core/components/dynamic-component/dynamic-component.ts @@ -0,0 +1,198 @@ +// (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, + Input, + ViewChild, + OnChanges, + DoCheck, + ViewContainerRef, + ComponentFactoryResolver, + ComponentRef, + KeyValueDiffers, + SimpleChange, + ChangeDetectorRef, + Optional, + ElementRef, + KeyValueDiffer, + Type, +} from '@angular/core'; +import { NavController } from '@ionic/angular'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Component to create another component dynamically. + * + * You need to pass the class of the component to this component (the class, not the name), along with the input data. + * + * So you should do something like: + * + * import { MyComponent } from './component'; + * + * ... + * + * this.component = MyComponent; + * + * And in the template: + * + * + *

Cannot render the data.

+ *
+ * + * Please notice that the component that you pass needs to be declared in entryComponents of the module to be created dynamically. + * + * Alternatively, you can also supply a ComponentRef instead of the class of the component. In this case, the component won't + * be instantiated because it already is, it will be attached to the view and the right data will be passed to it. + * Passing ComponentRef is meant for site plugins, so we'll inject a NavController instance to the component. + * + * The contents of this component will be displayed if no component is supplied or it cannot be created. In the example above, + * if no component is supplied then the template will show the message "Cannot render the data.". + */ +/* eslint-disable @angular-eslint/no-conflicting-lifecycle */ +@Component({ + selector: 'core-dynamic-component', + templateUrl: 'core-dynamic-component.html', +}) +export class CoreDynamicComponent implements OnChanges, DoCheck { + + @Input() component?: Type; + @Input() data?: Record; + + // Get the container where to put the dynamic component. + @ViewChild('dynamicComponent', { read: ViewContainerRef }) set dynamicComponent(el: ViewContainerRef) { + this.container = el; + this.createComponent(); + } + + instance?: any; // eslint-disable-line @typescript-eslint/no-explicit-any + container?: ViewContainerRef; + + protected logger: CoreLogger; + protected differ: KeyValueDiffer; // To detect changes in the data input. + protected lastComponent?: Type; + + constructor( + protected factoryResolver: ComponentFactoryResolver, + differs: KeyValueDiffers, + @Optional() protected navCtrl: NavController, + protected cdr: ChangeDetectorRef, + protected element: ElementRef, + ) { + + this.logger = CoreLogger.getInstance('CoreDynamicComponent'); + this.differ = differs.find([]).create(); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.component && !this.component) { + // Component not set, destroy the instance if any. + this.lastComponent = undefined; + this.instance = undefined; + this.container?.clear(); + } else if (changes.component && (!this.instance || this.component != this.lastComponent)) { + this.createComponent(); + } + } + + /** + * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). + */ + ngDoCheck(): void { + if (this.instance) { + // Check if there's any change in the data object. + const changes = this.differ.diff(this.data || {}); + if (changes) { + this.setInputData(); + if (this.instance.ngOnChanges) { + this.instance.ngOnChanges(CoreDomUtils.instance.createChangesFromKeyValueDiff(changes)); + } + } + } + } + + /** + * Call a certain function on the component. + * + * @param name Name of the function to call. + * @param params List of params to send to the function. + * @return Result of the call. Undefined if no component instance or the function doesn't exist. + */ + callComponentFunction(name: string, params?: unknown[]): T | undefined { + if (this.instance && typeof this.instance[name] == 'function') { + return this.instance[name].apply(this.instance, params); + } + } + + /** + * Create a component, add it to a container and set the input data. + * + * @return Whether the component was successfully created. + */ + protected createComponent(): boolean { + this.lastComponent = this.component; + + if (!this.component || !this.container) { + // No component to instantiate or container doesn't exist right now. + return false; + } + + if (this.instance) { + // Component already instantiated. + return true; + } + + if (this.component instanceof ComponentRef) { + // A ComponentRef was supplied instead of the component class. Add it to the view. + this.container.insert(this.component.hostView); + this.instance = this.component.instance; + + // This feature is usually meant for site plugins. Inject some properties. + this.instance['ChangeDetectorRef'] = this.cdr; + this.instance['NavController'] = this.navCtrl; + this.instance['componentContainer'] = this.element.nativeElement; + } else { + try { + // Create the component and add it to the container. + const factory = this.factoryResolver.resolveComponentFactory(this.component); + const componentRef = this.container.createComponent(factory); + + this.instance = componentRef.instance; + } catch (ex) { + this.logger.error('Error creating component', ex); + + return false; + } + } + + this.setInputData(); + + return true; + } + + /** + * Set the input data for the component. + */ + protected setInputData(): void { + for (const name in this.data) { + this.instance[name] = this.data[name]; + } + } + +} diff --git a/src/core/features/login/pages/email-signup/email-signup.html b/src/core/features/login/pages/email-signup/email-signup.html index bbef473a6..95378a395 100644 --- a/src/core/features/login/pages/email-signup/email-signup.html +++ b/src/core/features/login/pages/email-signup/email-signup.html @@ -172,8 +172,8 @@ {{ category.name }} - + diff --git a/src/core/features/login/pages/email-signup/email-signup.module.ts b/src/core/features/login/pages/email-signup/email-signup.module.ts index 7956c875d..b1a454123 100644 --- a/src/core/features/login/pages/email-signup/email-signup.module.ts +++ b/src/core/features/login/pages/email-signup/email-signup.module.ts @@ -21,6 +21,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreUserComponentsModule } from '@features/user/components/components.module'; import { CoreLoginEmailSignupPage } from './email-signup'; @@ -41,6 +42,7 @@ const routes: Routes = [ ReactiveFormsModule, CoreComponentsModule, CoreDirectivesModule, + CoreUserComponentsModule, ], declarations: [ CoreLoginEmailSignupPage, diff --git a/src/core/features/login/pages/email-signup/email-signup.ts b/src/core/features/login/pages/email-signup/email-signup.ts index d5e080862..8935a09e3 100644 --- a/src/core/features/login/pages/email-signup/email-signup.ts +++ b/src/core/features/login/pages/email-signup/email-signup.ts @@ -25,6 +25,7 @@ import { CoreWS, CoreWSExternalWarning } from '@services/ws'; import { CoreConstants } from '@/core/constants'; import { Translate } from '@singletons'; import { CoreSitePublicConfigResponse } from '@classes/site'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; import { AuthEmailSignupProfileFieldsCategory, @@ -82,6 +83,7 @@ export class CoreLoginEmailSignupPage implements OnInit { protected navCtrl: NavController, protected fb: FormBuilder, protected route: ActivatedRoute, + protected userProfileFieldDelegate: CoreUserProfileFieldDelegate, ) { // Create the ageVerificationForm. this.ageVerificationForm = this.fb.group({ @@ -156,7 +158,7 @@ export class CoreLoginEmailSignupPage implements OnInit { if (typeof this.ageDigitalConsentVerification == 'undefined') { const result = await CoreUtils.instance.ignoreErrors( - CoreWS.instance.callAjax( + CoreWS.instance.callAjax( 'core_auth_is_age_digital_consent_verification_enabled', {}, { siteUrl: this.siteUrl }, @@ -189,7 +191,11 @@ export class CoreLoginEmailSignupPage implements OnInit { { siteUrl: this.siteUrl }, ); - // @todo userProfileFieldDelegate + if (this.userProfileFieldDelegate.hasRequiredUnsupportedField(this.settings.profilefields)) { + this.allRequiredSupported = false; + + throw new Error(Translate.instance.instant('core.login.signuprequiredfieldnotsupported')); + } this.categories = CoreLoginHelper.instance.formatProfileFieldsForSignup(this.settings.profilefields); @@ -274,7 +280,7 @@ export class CoreLoginEmailSignupPage implements OnInit { const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); - const params: Record = { + const params: SignupUserWSParams = { username: this.signupForm.value.username.trim().toLowerCase(), password: this.signupForm.value.password, firstname: CoreTextUtils.instance.cleanTags(this.signupForm.value.firstname), @@ -295,8 +301,15 @@ export class CoreLoginEmailSignupPage implements OnInit { } try { - // @todo Get the data for the custom profile fields. - const result = await CoreWS.instance.callAjax( + // Get the data for the custom profile fields. + params.customprofilefields = await this.userProfileFieldDelegate.getDataForFields( + this.settings?.profilefields, + true, + 'email', + this.signupForm.value, + ); + + const result = await CoreWS.instance.callAjax( 'auth_email_signup_user', params, { siteUrl: this.siteUrl }, @@ -376,7 +389,7 @@ export class CoreLoginEmailSignupPage implements OnInit { params.age = parseInt(params.age, 10); // Use just the integer part. try { - const result = await CoreWS.instance.callAjax('core_auth_is_minor', params, { siteUrl: this.siteUrl }); + const result = await CoreWS.instance.callAjax('core_auth_is_minor', params, { siteUrl: this.siteUrl }); CoreDomUtils.instance.triggerFormSubmittedEvent(this.ageFormElement, true); @@ -404,14 +417,35 @@ export class CoreLoginEmailSignupPage implements OnInit { /** * Result of WS core_auth_is_age_digital_consent_verification_enabled. */ -export type IsAgeVerificationEnabledResponse = { +type IsAgeVerificationEnabledWSResponse = { status: boolean; // True if digital consent verification is enabled, false otherwise. }; +/** + * Params for WS auth_email_signup_user. + */ +type SignupUserWSParams = { + username: string; // Username. + password: string; // Plain text password. + firstname: string; // The first name(s) of the user. + lastname: string; // The family name of the user. + email: string; // A valid and unique email address. + city?: string; // Home city of the user. + country?: string; // Home country code. + recaptchachallengehash?: string; // Recaptcha challenge hash. + recaptcharesponse?: string; // Recaptcha response. + customprofilefields?: { // User custom fields (also known as user profile fields). + type: string; // The type of the custom field. + name: string; // The name of the custom field. + value: unknown; // Custom field value, can be an encoded json if required. + }[]; + redirect?: string; // Redirect the user to this site url after confirmation. +}; + /** * Result of WS auth_email_signup_user. */ -export type SignupUserResult = { +type SignupUserWSResult = { success: boolean; // True if the user was created false otherwise. warnings?: CoreWSExternalWarning[]; }; @@ -419,6 +453,6 @@ export type SignupUserResult = { /** * Result of WS core_auth_is_minor. */ -export type IsMinorResult = { +type IsMinorWSResult = { status: boolean; // True if the user is considered to be a digital minor, false if not. }; diff --git a/src/core/features/user/components/components.module.ts b/src/core/features/user/components/components.module.ts new file mode 100644 index 000000000..cd7b1d33f --- /dev/null +++ b/src/core/features/user/components/components.module.ts @@ -0,0 +1,43 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { CoreUserProfileFieldComponent } from './user-profile-field/user-profile-field'; + +@NgModule({ + declarations: [ + CoreUserProfileFieldComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + ], + providers: [ + ], + exports: [ + CoreUserProfileFieldComponent, + ], +}) +export class CoreUserComponentsModule {} diff --git a/src/core/features/user/components/user-profile-field/core-user-profile-field.html b/src/core/features/user/components/user-profile-field/core-user-profile-field.html new file mode 100644 index 000000000..1f3a37007 --- /dev/null +++ b/src/core/features/user/components/user-profile-field/core-user-profile-field.html @@ -0,0 +1 @@ + diff --git a/src/core/features/user/components/user-profile-field/user-profile-field.ts b/src/core/features/user/components/user-profile-field/user-profile-field.ts new file mode 100644 index 000000000..6a4aae6c5 --- /dev/null +++ b/src/core/features/user/components/user-profile-field/user-profile-field.ts @@ -0,0 +1,83 @@ +// (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, Input, OnInit, Injector, Type } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Directive to render user profile field. + */ +@Component({ + selector: 'core-user-profile-field', + templateUrl: 'core-user-profile-field.html', +}) +export class CoreUserProfileFieldComponent implements OnInit { + + @Input() field?: AuthEmailSignupProfileField; // The profile field to be rendered. + @Input() signup = false; // True if editing the field in signup. Defaults to false. + @Input() edit = false; // True if editing the field. Defaults to false. + @Input() form?: FormGroup; // Form where to add the form control. Required if edit=true or signup=true. + @Input() registerAuth?: string; // Register auth method. E.g. 'email'. + @Input() contextLevel?: string; // The context level. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() courseId?: number; // Course ID the field belongs to (if any). It can be used to improve performance with filters. + + componentClass?: Type; // The class of the component to render. + data: CoreUserProfileFieldComponentData = {}; // Data to pass to the component. + + constructor( + protected userProfileFieldsDelegate: CoreUserProfileFieldDelegate, + protected injector: Injector, + ) { } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!this.field) { + return; + } + + this.componentClass = await this.userProfileFieldsDelegate.getComponent(this.injector, this.field, this.signup); + + this.data.field = this.field; + this.data.edit = CoreUtils.instance.isTrueOrOne(this.edit); + if (this.edit) { + this.data.signup = CoreUtils.instance.isTrueOrOne(this.signup); + this.data.disabled = CoreUtils.instance.isTrueOrOne(this.field.locked); + this.data.form = this.form; + this.data.registerAuth = this.registerAuth; + this.data.contextLevel = this.contextLevel; + this.data.contextInstanceId = this.contextInstanceId; + this.data.courseId = this.courseId; + } + } + +} + +export type CoreUserProfileFieldComponentData = { + field?: AuthEmailSignupProfileField; + edit?: boolean; + signup?: boolean; + disabled?: boolean; + form?: FormGroup; + registerAuth?: string; + contextLevel?: string; + contextInstanceId?: number; + courseId?: number; +}; diff --git a/src/core/features/user/pages/about/about.html b/src/core/features/user/pages/about/about.html index c45d302d3..633ecd788 100644 --- a/src/core/features/user/pages/about/about.html +++ b/src/core/features/user/pages/about/about.html @@ -41,7 +41,7 @@

{{ 'core.user.address' | translate}}

-

+

{{ user.address }}

diff --git a/src/core/features/user/pages/about/about.module.ts b/src/core/features/user/pages/about/about.module.ts index daa3c8811..2c2e2c340 100644 --- a/src/core/features/user/pages/about/about.module.ts +++ b/src/core/features/user/pages/about/about.module.ts @@ -20,6 +20,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreUserComponentsModule } from '@features/user/components/components.module'; import { CoreUserAboutPage } from './about.page'; @@ -38,6 +39,7 @@ const routes: Routes = [ TranslateModule.forChild(), CoreComponentsModule, CoreDirectivesModule, + CoreUserComponentsModule, ], declarations: [ CoreUserAboutPage, diff --git a/src/core/features/user/services/user-profile-field-delegate.ts b/src/core/features/user/services/user-profile-field-delegate.ts new file mode 100644 index 000000000..6ec0e6e3d --- /dev/null +++ b/src/core/features/user/services/user-profile-field-delegate.ts @@ -0,0 +1,210 @@ +// (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 { Injectable, Injector, Type } from '@angular/core'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreError } from '@classes/errors/error'; +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from './user'; + +/** + * Interface that all user profile field handlers must implement. + */ +export interface CoreUserProfileFieldHandler extends CoreDelegateHandler { + /** + * Type of the field the handler supports. E.g. 'checkbox'. + */ + type: string; + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector): Type | Promise>; + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + getData?( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise; +} + +export interface CoreUserProfileFieldHandlerData { + /** + * Name of the custom field. + */ + name: string; + + /** + * The type of the custom field + */ + type: string; + + /** + * Value of the custom field. + */ + value: unknown; +} + +/** + * Service to interact with user profile fields. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreUserProfileFieldDelegate extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor() { + super('CoreUserProfileFieldDelegate', true); + } + + /** + * Get the type of a field. + * + * @param field The field to get its type. + * @return The field type. + */ + protected getType(field: AuthEmailSignupProfileField | CoreUserProfileField): string { + return ('type' in field ? field.type : field.datatype) || ''; + } + + /** + * Get the component to use to display an user field. + * + * @param injector Injector. + * @param field User field to get the directive for. + * @param signup True if user is in signup page. + * @return Promise resolved with component to use, undefined if not found. + */ + async getComponent( + injector: Injector, + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + ): Promise | undefined> { + const type = this.getType(field); + + try { + if (signup) { + return await this.executeFunction(type, 'getComponent', [injector]); + } else { + return await this.executeFunctionOnEnabled(type, 'getComponent', [injector]); + } + } catch (error) { + this.logger.error('Error getting component for field', type, error); + } + } + + /** + * Get the data to send for a certain field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form values. + * @return Data to send for the field. + */ + async getDataForField( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const type = this.getType(field); + const handler = this.getHandler(type, !signup); + + if (handler) { + const name = 'profile_field_' + field.shortname; + + if (handler.getData) { + return await handler.getData(field, signup, registerAuth, formValues); + } else if (field.shortname && typeof formValues[name] != 'undefined') { + // Handler doesn't implement the function, but the form has data for the field. + return { + type: type, + name: name, + value: formValues[name], + }; + } + } + + throw new CoreError('User profile field handler not found.'); + } + + /** + * Get the data to send for a list of fields based on the input data. + * + * @param fields User fields to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form values. + * @return Data to send. + */ + async getDataForFields( + fields: (AuthEmailSignupProfileField | CoreUserProfileField)[] | undefined, + signup: boolean = false, + registerAuth: string = '', + formValues: Record, + ): Promise { + if (!fields) { + return []; + } + + const result: CoreUserProfileFieldHandlerData[] = []; + + await Promise.all(fields.map(async (field) => { + try { + const data = await this.getDataForField(field, signup, registerAuth, formValues); + + if (data) { + result.push(data); + } + } catch (error) { + // Ignore errors. + } + })); + + return result; + } + + /** + * Check if any of the profile fields is not supported in the app. + * + * @param fields List of fields. + * @return Whether any of the profile fields is not supported in the app. + */ + hasRequiredUnsupportedField(fields?: AuthEmailSignupProfileField[]): boolean { + if (!fields || !fields.length) { + return false; + } + + return fields.some((field) => field.required && !this.hasHandler(this.getType(field))); + } + +} diff --git a/src/core/features/user/user.module.ts b/src/core/features/user/user.module.ts index 26f0e6c98..6c580f42d 100644 --- a/src/core/features/user/user.module.ts +++ b/src/core/features/user/user.module.ts @@ -18,6 +18,7 @@ import { Routes } from '@angular/router'; import { CoreMainMenuMoreRoutingModule } from '@features/mainmenu/pages/more/more-routing.module'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/db/user'; +import { CoreUserComponentsModule } from './components/components.module'; const routes: Routes = [ { @@ -29,6 +30,7 @@ const routes: Routes = [ @NgModule({ imports: [ CoreMainMenuMoreRoutingModule.forChild({ siblings: routes }), + CoreUserComponentsModule, ], providers: [ { From a783a89db3f037d022e6cb45f59416bc84a9c747 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 1 Dec 2020 12:50:10 +0100 Subject: [PATCH 07/19] MOBILE-3592 user: Implement user sync cron handler --- src/core/classes/base-sync.ts | 307 ++++++++++++++++++ .../user/services/handlers/sync-cron.ts | 50 +++ src/core/features/user/services/user-sync.ts | 107 ++++++ src/core/features/user/user.module.ts | 7 + src/core/services/sync.ts | 2 +- 5 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 src/core/classes/base-sync.ts create mode 100644 src/core/features/user/services/handlers/sync-cron.ts create mode 100644 src/core/features/user/services/user-sync.ts diff --git a/src/core/classes/base-sync.ts b/src/core/classes/base-sync.ts new file mode 100644 index 000000000..4f7c1b63f --- /dev/null +++ b/src/core/classes/base-sync.ts @@ -0,0 +1,307 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { Translate } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreError } from '@classes/errors/error'; + +/** + * Blocked sync error. + */ +export class CoreSyncBlockedError extends CoreError {} + +/** + * Base class to create sync providers. It provides some common functions. + */ +export class CoreSyncBaseProvider { + + /** + * Logger instance. + */ + protected logger: CoreLogger; + + /** + * Component of the sync provider. + */ + component = 'core'; + + /** + * Sync provider's interval. + */ + syncInterval = 300000; + + // Store sync promises. + protected syncPromises: { [siteId: string]: { [uniqueId: string]: Promise } } = {}; + + constructor(component: string) { + this.logger = CoreLogger.getInstance(component); + this.component = component; + } + + /** + * Add an offline data deleted warning to a list of warnings. + * + * @param warnings List of warnings. + * @param component Component. + * @param name Instance name. + * @param error Specific error message. + */ + protected addOfflineDataDeletedWarning(warnings: string[], component: string, name: string, error: string): void { + const warning = Translate.instance.instant('core.warningofflinedatadeleted', { + component: component, + name: name, + error: error, + }); + + if (warnings.indexOf(warning) == -1) { + warnings.push(warning); + } + } + + /** + * Add an ongoing sync to the syncPromises list. On finish the promise will be removed. + * + * @param id Unique sync identifier per component. + * @param promise The promise of the sync to add. + * @param siteId Site ID. If not defined, current site. + * @return The sync promise. + */ + async addOngoingSync(id: string | number, promise: Promise, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + throw new CoreError('CoreSyncBaseProvider: Site ID not supplied'); + } + + const uniqueId = this.getUniqueSyncId(id); + if (!this.syncPromises[siteId]) { + this.syncPromises[siteId] = {}; + } + + this.syncPromises[siteId][uniqueId] = promise; + + // Promise will be deleted when finish. + try { + return await promise; + } finally { + delete this.syncPromises[siteId!][uniqueId]; + } + } + + /** + * If there's an ongoing sync for a certain identifier return it. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise of the current sync or undefined if there isn't any. + */ + getOngoingSync(id: string | number, siteId?: string): Promise | undefined { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!this.isSyncing(id, siteId)) { + return; + } + + // There's already a sync ongoing for this id, return the promise. + const uniqueId = this.getUniqueSyncId(id); + + return this.syncPromises[siteId][uniqueId]; + } + + /** + * Get the synchronization time in a human readable format. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the readable time. + */ + async getReadableSyncTime(id: string | number, siteId?: string): Promise { + const time = await this.getSyncTime(id, siteId); + + return this.getReadableTimeFromTimestamp(time); + } + + /** + * Given a timestamp return it in a human readable format. + * + * @param timestamp Timestamp + * @return Human readable time. + */ + getReadableTimeFromTimestamp(timestamp: number): string { + if (!timestamp) { + return Translate.instance.instant('core.never'); + } else { + return CoreTimeUtils.instance.userDate(timestamp); + } + } + + /** + * Get the synchronization time. Returns 0 if no time stored. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the time. + */ + async getSyncTime(id: string | number, siteId?: string): Promise { + try { + const entry = await CoreSync.instance.getSyncRecord(this.component, id, siteId); + + return entry.time; + } catch { + return 0; + } + } + + /** + * Get the synchronization warnings of an instance. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the warnings. + */ + async getSyncWarnings(id: string | number, siteId?: string): Promise { + try { + const entry = await CoreSync.instance.getSyncRecord(this.component, id, siteId); + + return CoreTextUtils.instance.parseJSON(entry.warnings, []); + } catch { + return []; + } + } + + /** + * Create a unique identifier from component and id. + * + * @param id Unique sync identifier per component. + * @return Unique identifier from component and id. + */ + protected getUniqueSyncId(id: string | number): string { + return this.component + '#' + id; + } + + /** + * Check if a there's an ongoing syncronization for the given id. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Whether it's synchronizing. + */ + isSyncing(id: string | number, siteId?: string): boolean { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const uniqueId = this.getUniqueSyncId(id); + + return !!(this.syncPromises[siteId] && this.syncPromises[siteId][uniqueId]); + } + + /** + * Check if a sync is needed: if a certain time has passed since the last time. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether sync is needed. + */ + async isSyncNeeded(id: string | number, siteId?: string): Promise { + const time = await this.getSyncTime(id, siteId); + + return Date.now() - this.syncInterval >= time; + } + + /** + * Set the synchronization time. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @param time Time to set. If not defined, current time. + * @return Promise resolved when the time is set. + */ + async setSyncTime(id: string, siteId?: string, time?: number): Promise { + time = typeof time != 'undefined' ? time : Date.now(); + + await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { time: time }, siteId); + } + + /** + * Set the synchronization warnings. + * + * @param id Unique sync identifier per component. + * @param warnings Warnings to set. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async setSyncWarnings(id: string, warnings: string[], siteId?: string): Promise { + const warningsText = JSON.stringify(warnings || []); + + await CoreSync.instance.insertOrUpdateSyncRecord(this.component, id, { warnings: warningsText }, siteId); + } + + /** + * Execute a sync function on selected sites. + * + * @param syncFunctionLog Log message to explain the sync function purpose. + * @param syncFunction Sync function to execute. + * @param siteId Site ID to sync. If not defined, sync all sites. + * @return Resolved with siteIds selected. Rejected if offline. + */ + async syncOnSites(syncFunctionLog: string, syncFunction: (siteId: string) => void, siteId?: string): Promise { + if (!CoreApp.instance.isOnline()) { + const message = `Cannot sync '${syncFunctionLog}' because device is offline.`; + this.logger.debug(message); + + throw new CoreError(message); + } + + let siteIds: string[] = []; + + if (!siteId) { + // No site ID defined, sync all sites. + this.logger.debug(`Try to sync '${syncFunctionLog}' in all sites.`); + siteIds = await CoreSites.instance.getLoggedInSitesIds(); + } else { + this.logger.debug(`Try to sync '${syncFunctionLog}' in site '${siteId}'.`); + siteIds = [siteId]; + } + + // Execute function for every site. + await Promise.all(siteIds.map((siteId) => syncFunction(siteId))); + } + + /** + * If there's an ongoing sync for a certain identifier, wait for it to end. + * If there's no sync ongoing the promise will be resolved right away. + * + * @param id Unique sync identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when there's no sync going on for the identifier. + */ + async waitForSync(id: string | number, siteId?: string): Promise { + const promise = this.getOngoingSync(id, siteId); + + if (!promise) { + return; + } + + try { + return await promise; + } catch { + return; + } + } + +} diff --git a/src/core/features/user/services/handlers/sync-cron.ts b/src/core/features/user/services/handlers/sync-cron.ts new file mode 100644 index 000000000..96a99cc17 --- /dev/null +++ b/src/core/features/user/services/handlers/sync-cron.ts @@ -0,0 +1,50 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreCronHandler } from '@services/cron'; +import { CoreUserSync } from '../user-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserSyncCronHandler implements CoreCronHandler { + + name = 'CoreUserSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + execute(siteId?: string, force?: boolean): Promise { + return CoreUserSync.instance.syncPreferences(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } + +} diff --git a/src/core/features/user/services/user-sync.ts b/src/core/features/user/services/user-sync.ts new file mode 100644 index 000000000..644c920ec --- /dev/null +++ b/src/core/features/user/services/user-sync.ts @@ -0,0 +1,107 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { makeSingleton } from '@singletons'; +import { CoreUserOffline } from './user-offline'; +import { CoreUser } from './user'; + +/** + * Service to sync user preferences. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'core_user_autom_synced'; + + constructor() { + super('CoreUserSync'); + } + + /** + * Try to synchronize user preferences in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @return Promise resolved with warnings if sync is successful, rejected if sync fails. + */ + syncPreferences(siteId?: string): Promise { + return this.syncOnSites('all user preferences', this.syncSitePreferences.bind(this), siteId); + } + + /** + * Sync user preferences of a site. + * + * @param siteId Site ID to sync. + * @param Promise resolved with warnings if sync is successful, rejected if sync fails. + */ + async syncSitePreferences(siteId: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const syncId = 'preferences'; + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing, return the promise. + return this.getOngoingSync(syncId, siteId)!; + } + + this.logger.debug('Try to sync user preferences'); + + const syncPromise = this.performSyncSitePreferences(siteId); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Sync user preferences of a site. + * + * @param siteId Site ID to sync. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async performSyncSitePreferences(siteId: string): Promise { + const warnings: string[] = []; + + const preferences = await CoreUserOffline.instance.getChangedPreferences(siteId); + + await CoreUtils.instance.allPromises(preferences.map(async (preference) => { + const onlineValue = await CoreUser.instance.getUserPreferenceOnline(preference.name, siteId); + + if (onlineValue !== null && preference.onlinevalue != onlineValue) { + // Preference was changed on web while the app was offline, do not sync. + return CoreUserOffline.instance.setPreference(preference.name, onlineValue, onlineValue, siteId); + } + + try { + await CoreUser.instance.setUserPreference(preference.name, preference.value, siteId); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + warnings.push(CoreTextUtils.instance.getErrorMessageFromError(error)!); + } else { + // Couldn't connect to server, reject. + throw error; + } + } + })); + + // All done, return the warnings. + return warnings; + } + +} + +export class CoreUserSync extends makeSingleton(CoreUserSyncProvider) {} diff --git a/src/core/features/user/user.module.ts b/src/core/features/user/user.module.ts index 6c580f42d..1a7190632 100644 --- a/src/core/features/user/user.module.ts +++ b/src/core/features/user/user.module.ts @@ -41,6 +41,13 @@ const routes: Routes = [ ], multi: true, }, + // { @todo: Uncomment when the init process has been fixed. + // provide: APP_INITIALIZER, + // multi: true, + // deps: [CoreCronDelegate, CoreUserSyncCronHandler], + // useFactory: (cronDelegate: CoreCronDelegate, syncHandler: CoreUserSyncCronHandler) => + // () => cronDelegate.register(syncHandler), + // }, ], }) export class CoreUserModule {} diff --git a/src/core/services/sync.ts b/src/core/services/sync.ts index 7c3c7ca22..84a22e8ef 100644 --- a/src/core/services/sync.ts +++ b/src/core/services/sync.ts @@ -112,7 +112,7 @@ export class CoreSyncProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved with done. */ - async insertOrUpdateSyncRecord(component: string, id: string, data: CoreSyncRecord, siteId?: string): Promise { + async insertOrUpdateSyncRecord(component: string, id: string, data: Partial, siteId?: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); data.component = component; From dbec10b9d6413bcf2932a3b563b40335d67398fd Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 1 Dec 2020 15:23:40 +0100 Subject: [PATCH 08/19] MOBILE-3592 user: Implement profile link and mail handlers --- .../user/pages/profile/profile.page.ts | 2 +- .../user/services/handlers/profile-link.ts | 76 +++++++++++++++++++ .../user/services/handlers/profile-mail.ts | 75 ++++++++++++++++++ .../features/user/services/user-delegate.ts | 4 +- src/core/features/user/user.module.ts | 29 +++++-- 5 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 src/core/features/user/services/handlers/profile-link.ts create mode 100644 src/core/features/user/services/handlers/profile-mail.ts diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index 537ce68c8..86f53ecfe 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -275,7 +275,7 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { */ handlerClicked(event: Event, handler: CoreUserProfileHandlerData): void { // @todo: Pass the right navCtrl if this page is in the right pane of split view. - handler.action(event, this.navCtrl, this.user!, this.courseId); + handler.action(event, this.user!, this.courseId); } /** diff --git a/src/core/features/user/services/handlers/profile-link.ts b/src/core/features/user/services/handlers/profile-link.ts new file mode 100644 index 000000000..4cd8b434b --- /dev/null +++ b/src/core/features/user/services/handlers/profile-link.ts @@ -0,0 +1,76 @@ +// (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 { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; + +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; + +/** + * Handler to treat links to user profiles. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserProfileLinkHandler extends CoreContentLinksHandlerBase { + + name = 'CoreUserProfileLinkHandler'; + // Match user/view.php and user/profile.php but NOT grade/report/user/. + pattern = /((\/user\/view\.php)|(\/user\/profile\.php)).*([?&]id=\d+)/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @param data Extra data to handle the URL. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + siteIds: string[], + url: string, + params: Params, + courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars + data?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars + ): CoreContentLinksAction[] | Promise { + return [{ + action: (siteId): void => { + const pageParams = { + courseId: params.course, + userId: parseInt(params.id, 10), + }; + + CoreContentLinksHelper.instance.goInSite('/user', pageParams, siteId); + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise { + return url.indexOf('/grade/report/') == -1; + } + +} diff --git a/src/core/features/user/services/handlers/profile-mail.ts b/src/core/features/user/services/handlers/profile-mail.ts new file mode 100644 index 000000000..0b61adce8 --- /dev/null +++ b/src/core/features/user/services/handlers/profile-mail.ts @@ -0,0 +1,75 @@ +// (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 { Injectable } from '@angular/core'; + +import { CoreUserDelegate, CoreUserProfileHandler, CoreUserProfileHandlerData } from '../user-delegate'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreUserProfile } from '../user'; + +/** + * Handler to send a email to a user. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserProfileMailHandler implements CoreUserProfileHandler { + + name = 'CoreUserProfileMail'; + priority = 700; + type = CoreUserDelegate.TYPE_COMMUNICATION; + + /** + * Check if handler is enabled. + * + * @return Always enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Check if handler is enabled for this user in this context. + * + * @param user User to check. + * @param courseId Course ID. + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Promise resolved with true if enabled, resolved with false otherwise. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async isEnabledForUser(user: CoreUserProfile, courseId: number, navOptions?: unknown, admOptions?: unknown): Promise { + return user.id != CoreSites.instance.getCurrentSiteUserId() && !!user.email; + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getDisplayData(user: CoreUserProfile, courseId: number): CoreUserProfileHandlerData { + return { + icon: 'mail', + title: 'core.user.sendemail', + class: 'core-user-profile-mail', + action: (event: Event, user: CoreUserProfile): void => { + event.preventDefault(); + event.stopPropagation(); + + CoreUtils.instance.openInBrowser('mailto:' + user.email); + }, + }; + } + +} diff --git a/src/core/features/user/services/user-delegate.ts b/src/core/features/user/services/user-delegate.ts index 411a375f3..0c11f480c 100644 --- a/src/core/features/user/services/user-delegate.ts +++ b/src/core/features/user/services/user-delegate.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { NavController } from '@ionic/angular'; import { Subject, BehaviorSubject } from 'rxjs'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; @@ -93,11 +92,10 @@ export interface CoreUserProfileHandlerData { * Action to do when clicked. * * @param event Click event. - * @param Nav controller to use to navigate. * @param user User object. * @param courseId Course ID being viewed. If not defined, site context. */ - action(event: Event, navCtrl: NavController, user: CoreUserProfile, courseId?: number): void; + action(event: Event, user: CoreUserProfile, courseId?: number): void; } /** diff --git a/src/core/features/user/user.module.ts b/src/core/features/user/user.module.ts index 1a7190632..591ed616c 100644 --- a/src/core/features/user/user.module.ts +++ b/src/core/features/user/user.module.ts @@ -12,13 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import { Routes } from '@angular/router'; import { CoreMainMenuMoreRoutingModule } from '@features/mainmenu/pages/more/more-routing.module'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/db/user'; import { CoreUserComponentsModule } from './components/components.module'; +import { CoreUserDelegate } from './services/user-delegate'; +import { CoreUserProfileMailHandler } from './services/handlers/profile-mail'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreUserProfileLinkHandler } from './services/handlers/profile-link'; const routes: Routes = [ { @@ -41,13 +45,22 @@ const routes: Routes = [ ], multi: true, }, - // { @todo: Uncomment when the init process has been fixed. - // provide: APP_INITIALIZER, - // multi: true, - // deps: [CoreCronDelegate, CoreUserSyncCronHandler], - // useFactory: (cronDelegate: CoreCronDelegate, syncHandler: CoreUserSyncCronHandler) => - // () => cronDelegate.register(syncHandler), - // }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [CoreUserDelegate, CoreUserProfileMailHandler, CoreContentLinksDelegate, CoreUserProfileLinkHandler], + useFactory: ( + userDelegate: CoreUserDelegate, + mailHandler: CoreUserProfileMailHandler, + linksDelegate: CoreContentLinksDelegate, + profileLinkHandler: CoreUserProfileLinkHandler, + + ) => () => { + // @todo: Register sync handler when init process has been fixed. + userDelegate.registerHandler(mailHandler); + linksDelegate.registerHandler(profileLinkHandler); + }, + }, ], }) export class CoreUserModule {} From fd0ea5109684e5ac4c2da1780aa9d15746fa1656 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 2 Dec 2020 11:05:57 +0100 Subject: [PATCH 09/19] MOBILE-3592 profilefields: Implement user profile fields --- src/addons/addons.module.ts | 2 + .../checkbox/checkbox.module.ts | 56 ++++++++++ .../addon-user-profile-field-checkbox.html | 22 ++++ .../checkbox/component/checkbox.ts | 45 ++++++++ .../checkbox/services/handlers/checkbox.ts | 76 +++++++++++++ .../addon-user-profile-field-datetime.html | 18 ++++ .../datetime/component/datetime.ts | 95 +++++++++++++++++ .../datetime/datetime.module.ts | 58 ++++++++++ .../datetime/services/handlers/datetime.ts | 78 ++++++++++++++ .../addon-user-profile-field-menu.html | 21 ++++ .../userprofilefield/menu/component/menu.ts | 47 ++++++++ .../userprofilefield/menu/menu.module.ts | 58 ++++++++++ .../menu/services/handlers/menu.ts | 76 +++++++++++++ .../addon-user-profile-field-text.html | 18 ++++ .../userprofilefield/text/component/text.ts | 50 +++++++++ .../text/services/handlers/text.ts | 75 +++++++++++++ .../userprofilefield/text/text.module.ts | 58 ++++++++++ .../addon-user-profile-field-textarea.html | 20 ++++ .../textarea/component/textarea.ts | 26 +++++ .../textarea/services/handlers/textarea.ts | 85 +++++++++++++++ .../textarea/textarea.module.ts | 60 +++++++++++ .../userprofilefield.module.ts | 33 ++++++ .../features/mainmenu/pages/more/more.html | 2 +- .../classes/base-profilefield-component.ts | 100 ++++++++++++++++++ .../user-profile-field/user-profile-field.ts | 5 +- src/core/features/user/pages/about/about.html | 4 +- .../features/user/pages/about/about.page.ts | 2 +- .../services/user-profile-field-delegate.ts | 14 ++- 28 files changed, 1189 insertions(+), 15 deletions(-) create mode 100644 src/addons/userprofilefield/checkbox/checkbox.module.ts create mode 100644 src/addons/userprofilefield/checkbox/component/addon-user-profile-field-checkbox.html create mode 100644 src/addons/userprofilefield/checkbox/component/checkbox.ts create mode 100644 src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts create mode 100644 src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html create mode 100644 src/addons/userprofilefield/datetime/component/datetime.ts create mode 100644 src/addons/userprofilefield/datetime/datetime.module.ts create mode 100644 src/addons/userprofilefield/datetime/services/handlers/datetime.ts create mode 100644 src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html create mode 100644 src/addons/userprofilefield/menu/component/menu.ts create mode 100644 src/addons/userprofilefield/menu/menu.module.ts create mode 100644 src/addons/userprofilefield/menu/services/handlers/menu.ts create mode 100644 src/addons/userprofilefield/text/component/addon-user-profile-field-text.html create mode 100644 src/addons/userprofilefield/text/component/text.ts create mode 100644 src/addons/userprofilefield/text/services/handlers/text.ts create mode 100644 src/addons/userprofilefield/text/text.module.ts create mode 100644 src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html create mode 100644 src/addons/userprofilefield/textarea/component/textarea.ts create mode 100644 src/addons/userprofilefield/textarea/services/handlers/textarea.ts create mode 100644 src/addons/userprofilefield/textarea/textarea.module.ts create mode 100644 src/addons/userprofilefield/userprofilefield.module.ts create mode 100644 src/core/features/user/classes/base-profilefield-component.ts diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index 66777ebfa..98845ab4a 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -16,11 +16,13 @@ import { NgModule } from '@angular/core'; import { AddonPrivateFilesModule } from './privatefiles/privatefiles.module'; import { AddonFilterModule } from './filter/filter.module'; +import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield.module'; @NgModule({ imports: [ AddonPrivateFilesModule, AddonFilterModule, + AddonUserProfileFieldModule, ], }) export class AddonsModule {} diff --git a/src/addons/userprofilefield/checkbox/checkbox.module.ts b/src/addons/userprofilefield/checkbox/checkbox.module.ts new file mode 100644 index 000000000..d6134503c --- /dev/null +++ b/src/addons/userprofilefield/checkbox/checkbox.module.ts @@ -0,0 +1,56 @@ +// (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 } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AddonUserProfileFieldCheckboxHandler } from './services/handlers/checkbox'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldCheckboxComponent } from './component/checkbox'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldCheckboxComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldCheckboxHandler], + useFactory: ( + userProfileFieldDelegate: CoreUserProfileFieldDelegate, + handler: AddonUserProfileFieldCheckboxHandler, + ) => () => userProfileFieldDelegate.registerHandler(handler), + }, + ], + exports: [ + AddonUserProfileFieldCheckboxComponent, + ], + entryComponents: [ + AddonUserProfileFieldCheckboxComponent, + ], +}) +export class AddonUserProfileFieldCheckboxModule {} diff --git a/src/addons/userprofilefield/checkbox/component/addon-user-profile-field-checkbox.html b/src/addons/userprofilefield/checkbox/component/addon-user-profile-field-checkbox.html new file mode 100644 index 000000000..eaa9ec043 --- /dev/null +++ b/src/addons/userprofilefield/checkbox/component/addon-user-profile-field-checkbox.html @@ -0,0 +1,22 @@ + + + +

{{ field.name }}

+

+ {{ 'core.yes' | translate }} +

+

+ {{ 'core.no' | translate }} +

+
+
+ + + + + {{ field.name }} + + + + + \ No newline at end of file diff --git a/src/addons/userprofilefield/checkbox/component/checkbox.ts b/src/addons/userprofilefield/checkbox/component/checkbox.ts new file mode 100644 index 000000000..672307930 --- /dev/null +++ b/src/addons/userprofilefield/checkbox/component/checkbox.ts @@ -0,0 +1,45 @@ +// (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 } from '@angular/core'; +import { Validators, FormControl } from '@angular/forms'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Directive to render a checkbox user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-checkbox', + templateUrl: 'addon-user-profile-field-checkbox.html', +}) +export class AddonUserProfileFieldCheckboxComponent extends CoreUserProfileFieldBaseComponent { + + /** + * Create the Form control. + * + * @return Form control. + */ + protected createFormControl(field: AuthEmailSignupProfileField): FormControl { + const formData = { + value: CoreUtils.instance.isTrueOrOne(field.defaultdata), + disabled: this.disabled, + }; + + return new FormControl(formData, this.required && !field.locked ? Validators.requiredTrue : null); + } + +} diff --git a/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts b/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts new file mode 100644 index 000000000..02d31fd4e --- /dev/null +++ b/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts @@ -0,0 +1,76 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldCheckboxComponent } from '../../component/checkbox'; + +/** + * Checkbox user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldCheckboxHandler implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldCheckbox'; + type = 'checkbox'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + if (typeof formValues[name] != 'undefined') { + return { + type: 'checkbox', + name: name, + value: formValues[name] ? 1 : 0, + }; + } + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldCheckboxComponent; + } + +} diff --git a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html new file mode 100644 index 000000000..7d75067a0 --- /dev/null +++ b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html @@ -0,0 +1,18 @@ + + + +

{{ field.name }}

+

{{ valueNumber * 1000 | coreFormatDate }}

+
+
+ + + + + {{ field.name }} + + + + + \ No newline at end of file diff --git a/src/addons/userprofilefield/datetime/component/datetime.ts b/src/addons/userprofilefield/datetime/component/datetime.ts new file mode 100644 index 000000000..3deb698d2 --- /dev/null +++ b/src/addons/userprofilefield/datetime/component/datetime.ts @@ -0,0 +1,95 @@ +// (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 { FormControl, Validators } from '@angular/forms'; +import { Component } from '@angular/core'; + +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { Translate } from '@singletons'; +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; + +/** + * Directive to render a datetime user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-datetime', + templateUrl: 'addon-user-profile-field-datetime.html', +}) +export class AddonUserProfileFieldDatetimeComponent extends CoreUserProfileFieldBaseComponent { + + format?: string; + min?: number; + max?: number; + valueNumber = 0; + + /** + * Init the data when the field is meant to be displayed without editing. + * + * @param field Field to render. + */ + protected initForNonEdit(field: CoreUserProfileField): void { + this.valueNumber = Number(field.value); + } + + /** + * Init the data when the field is meant to be displayed for editing. + * + * @param field Field to render. + */ + protected initForEdit(field: AuthEmailSignupProfileField): void { + super.initForEdit(field); + + // Check if it's only date or it has time too. + const hasTime = CoreUtils.instance.isTrueOrOne(field.param3); + + // Calculate format to use. + this.format = CoreTimeUtils.instance.fixFormatForDatetime(CoreTimeUtils.instance.convertPHPToMoment( + Translate.instance.instant('core.' + (hasTime ? 'strftimedatetime' : 'strftimedate')), + )); + + // Check min value. + if (field.param1) { + const year = parseInt(field.param1, 10); + if (year) { + this.min = year; + } + } + + // Check max value. + if (field.param2) { + const year = parseInt(field.param2, 10); + if (year) { + this.max = year; + } + } + } + + /** + * Create the Form control. + * + * @return Form control. + */ + protected createFormControl(field: AuthEmailSignupProfileField): FormControl { + const formData = { + value: field.defaultdata != '0' ? field.defaultdata : undefined, + disabled: this.disabled, + }; + + return new FormControl(formData, this.required && !field.locked ? Validators.required : null); + } + +} diff --git a/src/addons/userprofilefield/datetime/datetime.module.ts b/src/addons/userprofilefield/datetime/datetime.module.ts new file mode 100644 index 000000000..02cd226f4 --- /dev/null +++ b/src/addons/userprofilefield/datetime/datetime.module.ts @@ -0,0 +1,58 @@ +// (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 } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AddonUserProfileFieldDatetimeHandler } from './services/handlers/datetime'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldDatetimeComponent } from './component/datetime'; +import { CoreComponentsModule } from '@components/components.module'; +import { CorePipesModule } from '@pipes/pipes.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldDatetimeComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + CorePipesModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldDatetimeHandler], + useFactory: ( + userProfileFieldDelegate: CoreUserProfileFieldDelegate, + handler: AddonUserProfileFieldDatetimeHandler, + ) => () => userProfileFieldDelegate.registerHandler(handler), + }, + ], + exports: [ + AddonUserProfileFieldDatetimeComponent, + ], + entryComponents: [ + AddonUserProfileFieldDatetimeComponent, + ], +}) +export class AddonUserProfileFieldDatetimeModule {} diff --git a/src/addons/userprofilefield/datetime/services/handlers/datetime.ts b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts new file mode 100644 index 000000000..b35feb867 --- /dev/null +++ b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts @@ -0,0 +1,78 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { CoreTimeUtils } from '@services/utils/time'; +import { AddonUserProfileFieldDatetimeComponent } from '../../component/datetime'; + +/** + * Datetime user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldDatetimeHandler implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldDatetime'; + type = 'datetime'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + if (formValues[name]) { + return { + type: 'datetime', + name: 'profile_field_' + field.shortname, + value: CoreTimeUtils.instance.convertToTimestamp( formValues[name]), + }; + } + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldDatetimeComponent; + } + +} diff --git a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html new file mode 100644 index 000000000..ce2e84f68 --- /dev/null +++ b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html @@ -0,0 +1,21 @@ + + + +

{{ field.name }}

+

+

+
+
+ + + + + {{ field.name }} + + + {{ 'core.choosedots' | translate }} + {{option}} + + + diff --git a/src/addons/userprofilefield/menu/component/menu.ts b/src/addons/userprofilefield/menu/component/menu.ts new file mode 100644 index 000000000..b497b69de --- /dev/null +++ b/src/addons/userprofilefield/menu/component/menu.ts @@ -0,0 +1,47 @@ +// (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 } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; + +/** + * Directive to render a menu user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-menu', + templateUrl: 'addon-user-profile-field-menu.html', +}) +export class AddonUserProfileFieldMenuComponent extends CoreUserProfileFieldBaseComponent { + + options?: string[]; + + /** + * Init the data when the field is meant to be displayed for editing. + * + * @param field Field to render. + */ + protected initForEdit(field: AuthEmailSignupProfileField): void { + super.initForEdit(field); + + // Parse options. + if (field.param1) { + this.options = field.param1.split(/\r\n|\r|\n/g); + } else { + this.options = []; + } + } + +} diff --git a/src/addons/userprofilefield/menu/menu.module.ts b/src/addons/userprofilefield/menu/menu.module.ts new file mode 100644 index 000000000..e91183197 --- /dev/null +++ b/src/addons/userprofilefield/menu/menu.module.ts @@ -0,0 +1,58 @@ +// (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 } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AddonUserProfileFieldMenuHandler } from './services/handlers/menu'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldMenuComponent } from './component/menu'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldMenuComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + CoreDirectivesModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldMenuHandler], + useFactory: ( + userProfileFieldDelegate: CoreUserProfileFieldDelegate, + handler: AddonUserProfileFieldMenuHandler, + ) => () => userProfileFieldDelegate.registerHandler(handler), + }, + ], + exports: [ + AddonUserProfileFieldMenuComponent, + ], + entryComponents: [ + AddonUserProfileFieldMenuComponent, + ], +}) +export class AddonUserProfileFieldMenuModule {} diff --git a/src/addons/userprofilefield/menu/services/handlers/menu.ts b/src/addons/userprofilefield/menu/services/handlers/menu.ts new file mode 100644 index 000000000..d40444adf --- /dev/null +++ b/src/addons/userprofilefield/menu/services/handlers/menu.ts @@ -0,0 +1,76 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldMenuComponent } from '../../component/menu'; + +/** + * Menu user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldMenuHandler implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldMenu'; + type = 'menu'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + if (formValues[name]) { + return { + type: 'menu', + name: name, + value: formValues[name], + }; + } + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldMenuComponent; + } + +} diff --git a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html new file mode 100644 index 000000000..51d52320d --- /dev/null +++ b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html @@ -0,0 +1,18 @@ + + + +

{{ field.name }}

+

+

+
+
+ + + + + {{ field.name }} + + + + diff --git a/src/addons/userprofilefield/text/component/text.ts b/src/addons/userprofilefield/text/component/text.ts new file mode 100644 index 000000000..3b7f42030 --- /dev/null +++ b/src/addons/userprofilefield/text/component/text.ts @@ -0,0 +1,50 @@ +// (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 } from '@angular/core'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Directive to render a text user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-text', + templateUrl: 'addon-user-profile-field-text.html', +}) +export class AddonUserProfileFieldTextComponent extends CoreUserProfileFieldBaseComponent { + + inputType?: string; + maxLength?: number; + + /** + * Init the data when the field is meant to be displayed for editing. + * + * @param field Field to render. + */ + protected initForEdit(field: AuthEmailSignupProfileField): void { + super.initForEdit(field); + + // Check max length. + if (field.param2) { + this.maxLength = parseInt(field.param2, 10) || Number.MAX_VALUE; + } + + // Check if it's a password or text. + this.inputType = CoreUtils.instance.isTrueOrOne(field.param3) ? 'password' : 'text'; + } + +} diff --git a/src/addons/userprofilefield/text/services/handlers/text.ts b/src/addons/userprofilefield/text/services/handlers/text.ts new file mode 100644 index 000000000..11abe2c18 --- /dev/null +++ b/src/addons/userprofilefield/text/services/handlers/text.ts @@ -0,0 +1,75 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldTextComponent } from '../../component/text'; +import { CoreTextUtils } from '@services/utils/text'; +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; + +/** + * Text user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldTextHandler implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldText'; + type = 'text'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + return { + type: 'text', + name: name, + value: CoreTextUtils.instance.cleanTags( formValues[name]), + }; + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldTextComponent; + } + +} diff --git a/src/addons/userprofilefield/text/text.module.ts b/src/addons/userprofilefield/text/text.module.ts new file mode 100644 index 000000000..7287868ae --- /dev/null +++ b/src/addons/userprofilefield/text/text.module.ts @@ -0,0 +1,58 @@ +// (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 } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AddonUserProfileFieldTextHandler } from './services/handlers/text'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldTextComponent } from './component/text'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldTextComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + CoreDirectivesModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldTextHandler], + useFactory: ( + userProfileFieldDelegate: CoreUserProfileFieldDelegate, + handler: AddonUserProfileFieldTextHandler, + ) => () => userProfileFieldDelegate.registerHandler(handler), + }, + ], + exports: [ + AddonUserProfileFieldTextComponent, + ], + entryComponents: [ + AddonUserProfileFieldTextComponent, + ], +}) +export class AddonUserProfileFieldTextModule {} diff --git a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html new file mode 100644 index 000000000..49011943d --- /dev/null +++ b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html @@ -0,0 +1,20 @@ + + + +

{{ field.name }}

+

+

+
+
+ + + + + {{ field.name }} + + + + \ No newline at end of file diff --git a/src/addons/userprofilefield/textarea/component/textarea.ts b/src/addons/userprofilefield/textarea/component/textarea.ts new file mode 100644 index 000000000..affe2f616 --- /dev/null +++ b/src/addons/userprofilefield/textarea/component/textarea.ts @@ -0,0 +1,26 @@ +// (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 } from '@angular/core'; + +import { CoreUserProfileFieldBaseComponent } from '@features/user/classes/base-profilefield-component'; + +/** + * Directive to render a textarea user profile field. + */ +@Component({ + selector: 'addon-user-profile-field-textarea', + templateUrl: 'addon-user-profile-field-textarea.html', +}) +export class AddonUserProfileFieldTextareaComponent extends CoreUserProfileFieldBaseComponent {} diff --git a/src/addons/userprofilefield/textarea/services/handlers/textarea.ts b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts new file mode 100644 index 000000000..56ed58bcd --- /dev/null +++ b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts @@ -0,0 +1,85 @@ +// (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 { Injectable, Type } from '@angular/core'; + +import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldTextareaComponent } from '../../component/textarea'; +import { CoreTextUtils } from '@services/utils/text'; +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; + +/** + * Textarea user profile field handlers. + */ +@Injectable({ providedIn: 'root' }) +export class AddonUserProfileFieldTextareaHandler implements CoreUserProfileFieldHandler { + + name = 'AddonUserProfileFieldTextarea'; + type = 'textarea'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data to send for the field based on the input data. + * + * @param field User field to get the data for. + * @param signup True if user is in signup page. + * @param registerAuth Register auth method. E.g. 'email'. + * @param formValues Form Values. + * @return Data to send for the field. + */ + async getData( + field: AuthEmailSignupProfileField | CoreUserProfileField, + signup: boolean, + registerAuth: string, + formValues: Record, + ): Promise { + const name = 'profile_field_' + field.shortname; + + if (formValues[name]) { + let text = formValues[name] || ''; + // Add some HTML to the message in case the user edited with textarea. + text = CoreTextUtils.instance.formatHtmlLines(text); + + return { + type: 'textarea', + name: name, + value: JSON.stringify({ + text: text, + format: 1, // Always send this format. + }), + }; + } + } + + /** + * Return the Component to use to display the user profile field. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return AddonUserProfileFieldTextareaComponent; + } + +} diff --git a/src/addons/userprofilefield/textarea/textarea.module.ts b/src/addons/userprofilefield/textarea/textarea.module.ts new file mode 100644 index 000000000..a3352bc9d --- /dev/null +++ b/src/addons/userprofilefield/textarea/textarea.module.ts @@ -0,0 +1,60 @@ +// (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 } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AddonUserProfileFieldTextareaHandler } from './services/handlers/textarea'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { AddonUserProfileFieldTextareaComponent } from './component/textarea'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +// @todo import { CoreEditorComponentsModule } from '@core/editor/components/components.module'; + +@NgModule({ + declarations: [ + AddonUserProfileFieldTextareaComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + CoreDirectivesModule, + // CoreEditorComponentsModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldTextareaHandler], + useFactory: ( + userProfileFieldDelegate: CoreUserProfileFieldDelegate, + handler: AddonUserProfileFieldTextareaHandler, + ) => () => userProfileFieldDelegate.registerHandler(handler), + }, + ], + exports: [ + AddonUserProfileFieldTextareaComponent, + ], + entryComponents: [ + AddonUserProfileFieldTextareaComponent, + ], +}) +export class AddonUserProfileFieldTextareaModule {} diff --git a/src/addons/userprofilefield/userprofilefield.module.ts b/src/addons/userprofilefield/userprofilefield.module.ts new file mode 100644 index 000000000..50910d3a2 --- /dev/null +++ b/src/addons/userprofilefield/userprofilefield.module.ts @@ -0,0 +1,33 @@ +// (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 { AddonUserProfileFieldCheckboxModule } from './checkbox/checkbox.module'; +import { AddonUserProfileFieldDatetimeModule } from './datetime/datetime.module'; +import { AddonUserProfileFieldMenuModule } from './menu/menu.module'; +import { AddonUserProfileFieldTextModule } from './text/text.module'; +import { AddonUserProfileFieldTextareaModule } from './textarea/textarea.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonUserProfileFieldCheckboxModule, + AddonUserProfileFieldDatetimeModule, + AddonUserProfileFieldMenuModule, + AddonUserProfileFieldTextModule, + AddonUserProfileFieldTextareaModule, + ], + exports: [], +}) +export class AddonUserProfileFieldModule { } diff --git a/src/core/features/mainmenu/pages/more/more.html b/src/core/features/mainmenu/pages/more/more.html index 84dee2d54..0661d0347 100644 --- a/src/core/features/mainmenu/pages/more/more.html +++ b/src/core/features/mainmenu/pages/more/more.html @@ -9,7 +9,7 @@ - +

{{siteInfo.fullname}}

diff --git a/src/core/features/user/classes/base-profilefield-component.ts b/src/core/features/user/classes/base-profilefield-component.ts new file mode 100644 index 000000000..4d3a6d830 --- /dev/null +++ b/src/core/features/user/classes/base-profilefield-component.ts @@ -0,0 +1,100 @@ +// (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, Input, OnInit } from '@angular/core'; +import { FormGroup, Validators, FormControl } from '@angular/forms'; + +import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { CoreUserProfileField } from '@features/user/services/user'; + +/** + * Base class for components to render a user profile field. + */ +@Component({ + template: '', +}) +export class CoreUserProfileFieldBaseComponent implements OnInit { + + @Input() field?: AuthEmailSignupProfileField | CoreUserProfileField; // The profile field to be rendered. + @Input() edit = false; // True if editing the field. Defaults to false. + @Input() disabled = false; // True if disabled. Defaults to false. + @Input() form?: FormGroup; // Form where to add the form control. + @Input() contextLevel?: string; // The context level. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() courseId?: number; // The course the field belongs to (if any). + + control?: FormControl; + modelName = ''; + value?: string; + required?: boolean; + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.field) { + return; + } + + if (!this.edit && 'value' in this.field) { + this.initForNonEdit(this.field); + + return; + } + + if (this.edit && 'required' in this.field) { + this.initForEdit(this.field); + + return; + } + + } + + /** + * Init the data when the field is meant to be displayed without editing. + * + * @param field Field to render. + */ + protected initForNonEdit(field: CoreUserProfileField): void { + this.value = field.value; + } + + /** + * Init the data when the field is meant to be displayed for editing. + * + * @param field Field to render. + */ + protected initForEdit(field: AuthEmailSignupProfileField): void { + this.modelName = 'profile_field_' + field.shortname; + this.required = !!field.required; + + this.control = this.createFormControl(field); + this.form?.addControl(this.modelName, this.control); + } + + /** + * Create the Form control. + * + * @return Form control. + */ + protected createFormControl(field: AuthEmailSignupProfileField): FormControl { + const formData = { + value: field.defaultdata, + disabled: this.disabled, + }; + + return new FormControl(formData, this.required && !field.locked ? Validators.required : null); + } + +} diff --git a/src/core/features/user/components/user-profile-field/user-profile-field.ts b/src/core/features/user/components/user-profile-field/user-profile-field.ts index 6a4aae6c5..6bccb4b92 100644 --- a/src/core/features/user/components/user-profile-field/user-profile-field.ts +++ b/src/core/features/user/components/user-profile-field/user-profile-field.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, Injector, Type } from '@angular/core'; +import { Component, Input, OnInit, Type } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; @@ -42,7 +42,6 @@ export class CoreUserProfileFieldComponent implements OnInit { constructor( protected userProfileFieldsDelegate: CoreUserProfileFieldDelegate, - protected injector: Injector, ) { } /** @@ -53,7 +52,7 @@ export class CoreUserProfileFieldComponent implements OnInit { return; } - this.componentClass = await this.userProfileFieldsDelegate.getComponent(this.injector, this.field, this.signup); + this.componentClass = await this.userProfileFieldsDelegate.getComponent(this.field, this.signup); this.data.field = this.field; this.data.edit = CoreUtils.instance.isTrueOrOne(this.edit); diff --git a/src/core/features/user/pages/about/about.html b/src/core/features/user/pages/about/about.html index 633ecd788..c2a48db58 100644 --- a/src/core/features/user/pages/about/about.html +++ b/src/core/features/user/pages/about/about.html @@ -75,9 +75,9 @@

{{ user.interests }}

- + {{ 'core.user.description' | translate}} diff --git a/src/core/features/user/pages/about/about.page.ts b/src/core/features/user/pages/about/about.page.ts index 816081331..bd2dd5c8b 100644 --- a/src/core/features/user/pages/about/about.page.ts +++ b/src/core/features/user/pages/about/about.page.ts @@ -34,10 +34,10 @@ import { CoreUserHelper } from '@features/user/services/user-helper'; }) export class CoreUserAboutPage implements OnInit { - protected courseId!: number; protected userId!: number; protected siteId: string; + courseId!: number; userLoaded = false; hasContact = false; hasDetails = false; diff --git a/src/core/features/user/services/user-profile-field-delegate.ts b/src/core/features/user/services/user-profile-field-delegate.ts index 6ec0e6e3d..3f1ec6436 100644 --- a/src/core/features/user/services/user-profile-field-delegate.ts +++ b/src/core/features/user/services/user-profile-field-delegate.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable, Injector, Type } from '@angular/core'; +import { Injectable, Type } from '@angular/core'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { CoreError } from '@classes/errors/error'; @@ -32,10 +32,9 @@ export interface CoreUserProfileFieldHandler extends CoreDelegateHandler { * Return the Component to use to display the user profile field. * It's recommended to return the class of the component, but you can also return an instance of the component. * - * @param injector Injector. * @return The component (or promise resolved with component) to use, undefined if not found. */ - getComponent(injector: Injector): Type | Promise>; + getComponent(): Type | Promise>; /** * Get the data to send for the field based on the input data. @@ -51,7 +50,7 @@ export interface CoreUserProfileFieldHandler extends CoreDelegateHandler { signup: boolean, registerAuth: string, formValues: Record, - ): Promise; + ): Promise; } export interface CoreUserProfileFieldHandlerData { @@ -104,7 +103,6 @@ export class CoreUserProfileFieldDelegate extends CoreDelegate | undefined> { @@ -112,9 +110,9 @@ export class CoreUserProfileFieldDelegate extends CoreDelegate, - ): Promise { + ): Promise { const type = this.getType(field); const handler = this.getHandler(type, !signup); From 90a0f184805a1cb7bb980e9c365d2903a288765b Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 3 Dec 2020 16:01:02 +0100 Subject: [PATCH 10/19] MOBILE-3592 editor: Migrate rich text editor --- .../addon-user-profile-field-textarea.html | 4 +- .../textarea/textarea.module.ts | 4 +- src/core/components/tabs/core-tabs.html | 2 +- src/core/components/tabs/tabs.ts | 8 +- .../editor/components/components.module.ts | 42 + .../core-editor-rich-text-editor.html | 113 ++ .../rich-text-editor/rich-text-editor.scss | 181 +++ .../rich-text-editor/rich-text-editor.ts | 1057 +++++++++++++++++ src/core/features/editor/editor.module.ts | 35 + src/core/features/editor/lang/en.json | 17 + .../editor/services/database/editor.ts | 93 ++ .../editor/services/editor-offline.ts | 239 ++++ src/core/services/utils/dom.ts | 6 +- src/core/singletons/events.ts | 14 + 14 files changed, 1803 insertions(+), 12 deletions(-) create mode 100644 src/core/features/editor/components/components.module.ts create mode 100644 src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html create mode 100644 src/core/features/editor/components/rich-text-editor/rich-text-editor.scss create mode 100644 src/core/features/editor/components/rich-text-editor/rich-text-editor.ts create mode 100644 src/core/features/editor/editor.module.ts create mode 100644 src/core/features/editor/lang/en.json create mode 100644 src/core/features/editor/services/database/editor.ts create mode 100644 src/core/features/editor/services/editor-offline.ts diff --git a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html index 49011943d..6903fb29f 100644 --- a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html +++ b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html @@ -14,7 +14,7 @@ {{ field.name }}
- +
\ No newline at end of file diff --git a/src/addons/userprofilefield/textarea/textarea.module.ts b/src/addons/userprofilefield/textarea/textarea.module.ts index a3352bc9d..d42d37ab1 100644 --- a/src/addons/userprofilefield/textarea/textarea.module.ts +++ b/src/addons/userprofilefield/textarea/textarea.module.ts @@ -23,7 +23,7 @@ import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profi import { AddonUserProfileFieldTextareaComponent } from './component/textarea'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; -// @todo import { CoreEditorComponentsModule } from '@core/editor/components/components.module'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; @NgModule({ declarations: [ @@ -37,7 +37,7 @@ import { CoreDirectivesModule } from '@directives/directives.module'; ReactiveFormsModule, CoreComponentsModule, CoreDirectivesModule, - // CoreEditorComponentsModule, + CoreEditorComponentsModule, ], providers: [ { diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index fd8cbfd53..a1cb8137a 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -6,7 +6,7 @@ - { this.numTabsShown = this.tabs.reduce((prev: number, current: CoreTab) => current.enabled ? prev + 1 : prev, 0); - this.slideOpts.slidesPerView = Math.min(this.maxSlides, this.numTabsShown); - this.slidesSwiper.params.slidesPerView = this.slideOpts.slidesPerView; + this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) }; this.calculateTabBarHeight(); await this.slides!.update(); - if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slideOpts.slidesPerView) { + if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { this.hasSliddenToInitial = true; this.shouldSlideToInitial = true; @@ -637,6 +636,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe window.removeEventListener('resize', this.resizeFunction); } this.stackEventsSubscription?.unsubscribe(); + this.languageChangedSubscription.unsubscribe(); } } diff --git a/src/core/features/editor/components/components.module.ts b/src/core/features/editor/components/components.module.ts new file mode 100644 index 000000000..b57a73036 --- /dev/null +++ b/src/core/features/editor/components/components.module.ts @@ -0,0 +1,42 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreEditorRichTextEditorComponent } from './rich-text-editor/rich-text-editor'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + CoreEditorRichTextEditorComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + ], + providers: [ + ], + exports: [ + CoreEditorRichTextEditorComponent, + ], + entryComponents: [ + CoreEditorRichTextEditorComponent, + ], +}) +export class CoreEditorComponentsModule {} diff --git a/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html b/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html new file mode 100644 index 000000000..a409f709c --- /dev/null +++ b/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html @@ -0,0 +1,113 @@ +
+
+
+ + + + +
+ + {{ infoMessage | translate }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss new file mode 100644 index 000000000..5f641ead3 --- /dev/null +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss @@ -0,0 +1,181 @@ +:host { + height: 40vh; + overflow: hidden; + min-height: 200px; /* Just in case vh is not supported */ + min-height: 40vh; + width: 100%; + display: flex; + flex-direction: column; + // @include darkmode() { + // background-color: $gray-darker; + // } + + .core-rte-editor-container { + max-height: calc(100% - 46px); + display: flex; + flex-direction: column; + flex-grow: 1; + &.toolbar-hidden { + max-height: 100%; + } + + .core-rte-info-message { + padding: 5px; + border-top: 1px solid var(--ion-color-secondary); + background: white; + flex-shrink: 1; + font-size: 1.4rem; + + .icon { + color: var(--ion-color-secondary); + } + } + } + + .core-rte-editor, .core-textarea { + padding: 2px; + margin: 2px; + width: 100%; + resize: none; + background-color: white; + flex-grow: 1; + // @include darkmode() { + // background-color: var(--gray-darker); + // color: var(--white); + // } + } + + .core-rte-editor { + flex-grow: 1; + flex-shrink: 1; + -webkit-user-select: auto !important; + word-wrap: break-word; + overflow-x: hidden; + overflow-y: auto; + cursor: text; + img { + // @include padding(null, null, null, 2px); + max-width: 95%; + width: auto; + } + &:empty:before { + content: attr(data-placeholder-text); + display: block; + color: var(--gray-light); + font-weight: bold; + + // @include darkmode() { + // color: $gray; + // } + } + + // Make empty elements selectable (to move the cursor). + *:empty:after { + content: '\200B'; + } + } + + .core-textarea { + flex-grow: 1; + flex-shrink: 1; + position: relative; + + textarea { + margin: 0 !important; + padding: 0; + height: 100% !important; + width: 100% !important; + resize: none; + overflow-x: hidden; + overflow-y: auto; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + } + + div.core-rte-toolbar { + display: flex; + width: 100%; + z-index: 1; + flex-grow: 0; + flex-shrink: 0; + background-color: var(--white); + + // @include darkmode() { + // background-color: $black; + // } + // @include padding(5px, null); + border-top: 1px solid var(--gray); + + ion-slides { + width: 240px; + flex-grow: 1; + flex-shrink: 1; + } + + button { + display: flex; + justify-content: center; + align-items: center; + width: 36px; + height: 36px; + padding-right: 6px; + padding-left: 6px; + margin: 0 auto; + font-size: 18px; + background-color: var(--white); + border-radius: 4px; + // @include core-transition(background-color, 200ms); + color: var(--ion-text-color); + cursor: pointer; + + // @include darkmode() { + // background-color: $black; + // color: $core-dark-text-color; + // } + + &.toolbar-button-enable { + width: 100%; + } + + &:active, &[aria-pressed="true"] { + background-color: var(--gray); + // @include darkmode() { + // background-color: $gray-dark; + // } + } + + &.toolbar-arrow { + width: 28px; + flex-grow: 0; + flex-shrink: 0; + opacity: 1; + // @include core-transition(opacity, 200ms); + + &:active { + background-color: var(--white); + // @include darkmode() { + // background-color: $black; + // } + } + + &.toolbar-arrow-hidden { + opacity: 0; + } + } + } + + &.toolbar-hidden { + visibility: none; + height: 0; + border: none; + } + } +} + +:host-context(.keyboard-is-open) { + min-height: 200px; +} \ No newline at end of file diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts new file mode 100644 index 000000000..ac8e1a15b --- /dev/null +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -0,0 +1,1057 @@ +// (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, + Input, + Output, + EventEmitter, + ViewChild, + ElementRef, + OnInit, + AfterContentInit, + OnDestroy, + Optional, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { IonTextarea, IonContent, IonSlides } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreFilepool } from '@services/filepool'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { Platform, Translate } from '@singletons'; +import { CoreEventFormActionData, CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreEditorOffline } from '../../services/editor-offline'; + +/** + * Component to display a rich text editor if enabled. + * + * If enabled, this component will show a rich text editor. Otherwise it'll show a regular textarea. + * + * Example: + * + */ +@Component({ + selector: 'core-rich-text-editor', + templateUrl: 'core-editor-rich-text-editor.html', + styleUrls: ['rich-text-editor.scss'], +}) +export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentInit, OnDestroy { + + // Based on: https://github.com/judgewest2000/Ionic3RichText/ + // @todo: Anchor button, fullscreen... + // @todo: Textarea height is not being updated when editor is resized. Height is calculated if any css is changed. + + @Input() placeholder = ''; // Placeholder to set in textarea. + @Input() control?: FormControl; // Form control. + @Input() name = 'core-rich-text-editor'; // Name to set to the textarea. + @Input() component?: string; // The component to link the files to. + @Input() componentId?: number; // An ID to use in conjunction with the component. + @Input() autoSave?: boolean | string; // Whether to auto-save the contents in a draft. Defaults to true. + @Input() contextLevel?: string; // The context level of the text. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() elementId?: string; // An ID to set to the element. + @Input() draftExtraParams?: Record; // Extra params to identify the draft. + @Output() contentChanged: EventEmitter; + + @ViewChild('editor') editor?: ElementRef; // WYSIWYG editor. + @ViewChild('textarea') textarea?: IonTextarea; // Textarea editor. + @ViewChild('toolbar') toolbar?: ElementRef; + @ViewChild(IonSlides) toolbarSlides?: IonSlides; + + protected readonly DRAFT_AUTOSAVE_FREQUENCY = 30000; + protected readonly RESTORE_MESSAGE_CLEAR_TIME = 6000; + protected readonly SAVE_MESSAGE_CLEAR_TIME = 2000; + + protected element: HTMLDivElement; + protected editorElement?: HTMLDivElement; + protected kbHeight = 0; // Last known keyboard height. + protected minHeight = 200; // Minimum height of the editor. + + protected valueChangeSubscription?: Subscription; + protected keyboardObserver?: CoreEventObserver; + protected resetObserver?: CoreEventObserver; + protected initHeightInterval?: number; + protected isCurrentView = true; + protected toolbarButtonWidth = 40; + protected toolbarArrowWidth = 28; + protected pageInstance: string; + protected autoSaveInterval?: number; + protected hideMessageTimeout?: number; + protected lastDraft = ''; + protected draftWasRestored = false; + protected originalContent?: string; + protected resizeFunction?: () => Promise; + protected selectionChangeFunction?: () => void; + protected languageChangedSubscription?: Subscription; + + rteEnabled = false; + isPhone = false; + toolbarHidden = false; + toolbarArrows = false; + toolbarPrevHidden = true; + toolbarNextHidden = false; + canScanQR = false; + infoMessage?: string; + direction = 'ltr'; + toolbarStyles = { + strong: 'false', + em: 'false', + u: 'false', + strike: 'false', + p: 'false', + h3: 'false', + h4: 'false', + h5: 'false', + ul: 'false', + ol: 'false', + }; + + slidesOpts = { + initialSlide: 0, + slidesPerView: 6, + centerInsufficientSlides: true, + }; + + constructor( + @Optional() protected content: IonContent, + elementRef: ElementRef, + ) { + this.contentChanged = new EventEmitter(); + this.element = elementRef.nativeElement as HTMLDivElement; + this.pageInstance = 'app_' + Date.now(); // Generate a "unique" ID based on timestamp. + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.canScanQR = CoreUtils.instance.canScanQR(); + this.isPhone = Platform.instance.is('mobile') && !Platform.instance.is('tablet'); + this.toolbarHidden = this.isPhone; + this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; + } + + /** + * Init editor. + */ + async ngAfterContentInit(): Promise { + this.rteEnabled = await CoreDomUtils.instance.isRichTextEditorEnabled(); + + // Setup the editor. + this.editorElement = this.editor?.nativeElement as HTMLDivElement; + this.setContent(this.control?.value); + this.originalContent = this.control?.value; + this.lastDraft = this.control?.value; + this.editorElement.onchange = this.onChange.bind(this); + this.editorElement.onkeyup = this.onChange.bind(this); + this.editorElement.onpaste = this.onChange.bind(this); + this.editorElement.oninput = this.onChange.bind(this); + this.editorElement.onkeydown = this.moveCursor.bind(this); + + // Use paragraph on enter. + document.execCommand('DefaultParagraphSeparator', false, 'p'); + + let i = 0; + this.initHeightInterval = window.setInterval(async () => { + const height = await this.maximizeEditorSize(); + if (i >= 5 || height != 0) { + clearInterval(this.initHeightInterval); + } + i++; + }, 750); + + this.setListeners(); + this.updateToolbarButtons(); + + if (this.elementId) { + // Prepend elementId with 'id_' like in web. Don't use a setter for this because the value shouldn't change. + this.elementId = 'id_' + this.elementId; + this.element.setAttribute('id', this.elementId); + } + + // Update tags for a11y. + this.replaceTags('b', 'strong'); + this.replaceTags('i', 'em'); + + if (this.shouldAutoSaveDrafts()) { + this.restoreDraft(); + + this.autoSaveDrafts(); + + this.deleteDraftOnSubmitOrCancel(); + } + } + + /** + * Set listeners and observers. + */ + protected setListeners(): void { + // Listen for changes on the control to update the editor (if it is updated from outside of this component). + this.valueChangeSubscription = this.control?.valueChanges.subscribe((newValue) => { + if (this.draftWasRestored && this.originalContent == newValue) { + // A draft was restored and the content hasn't changed in the site. Use the draft value instead of this one. + this.control?.setValue(this.lastDraft, { emitEvent: false }); + + return; + } + + // Apply the new content. + this.setContent(newValue); + this.originalContent = newValue; + this.infoMessage = undefined; + + // Save a draft so the original content is saved. + this.lastDraft = newValue; + CoreEditorOffline.instance.saveDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + this.pageInstance, + newValue, + newValue, + ); + }); + + this.resizeFunction = this.maximizeEditorSize.bind(this); + this.selectionChangeFunction = this.updateToolbarStyles.bind(this); + window.addEventListener('resize', this.resizeFunction!); + document.addEventListener('selectionchange', this.selectionChangeFunction!); + + this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, (kbHeight: number) => { + this.kbHeight = kbHeight; + this.maximizeEditorSize(); + }); + + // Change the side when the language changes. + this.languageChangedSubscription = Translate.instance.onLangChange.subscribe(() => { + setTimeout(() => { + this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; + }); + }); + } + + /** + * Resize editor to maximize the space occupied. + * + * @return Resolved with calculated editor size. + */ + protected maximizeEditorSize(): Promise { + // this.content.resize(); + + const deferred = CoreUtils.instance.promiseDefer(); + + setTimeout(async () => { + let contentVisibleHeight = await CoreDomUtils.instance.getContentHeight(this.content); + if (!CoreApp.instance.isAndroid()) { + // In Android we ignore the keyboard height because it is not part of the web view. + contentVisibleHeight -= this.kbHeight; + } + + if (contentVisibleHeight <= 0) { + deferred.resolve(0); + + return; + } + + setTimeout(async () => { + // Editor is ready, adjust Height if needed. + let height; + + if (CoreApp.instance.isAndroid()) { + // In Android we ignore the keyboard height because it is not part of the web view. + const contentHeight = await CoreDomUtils.instance.getContentHeight(this.content); + height = contentHeight - this.getSurroundingHeight(this.element); + } else if (CoreApp.instance.isIOS() && this.kbHeight > 0 && CoreApp.instance.getPlatformMajorVersion() < 12) { + // Keyboard open in iOS 11 or previous. The window height changes when the keyboard is open. + height = window.innerHeight - this.getSurroundingHeight(this.element); + + if (this.element.getBoundingClientRect().top < 40) { + // In iOS sometimes the editor is placed below the status bar. Move the scroll a bit so it doesn't happen. + window.scrollTo(window.scrollX, window.scrollY - 40); + } + + } else { + // Header is fixed, use the content to calculate the editor height. + const contentHeight = await CoreDomUtils.instance.getContentHeight(this.content); + height = contentHeight - this.kbHeight - this.getSurroundingHeight(this.element); + } + + if (height > this.minHeight) { + this.element.style.height = CoreDomUtils.instance.formatPixelsSize(height - 1); + } else { + this.element.style.height = ''; + } + + deferred.resolve(height); + }, 100); + }, 100); + + return deferred.promise; + } + + /** + * Get the height of the surrounding elements from the current to the top element. + * + * @param element Directive DOM element to get surroundings elements from. + * @return Surrounding height in px. + */ + protected getSurroundingHeight(element: HTMLElement): number { + let height = 0; + + while (element.parentElement?.tagName != 'ION-CONTENT') { + const parent = element.parentElement!; + if (element.tagName && element.tagName != 'CORE-LOADING') { + for (let x = 0; x < parent.children.length; x++) { + const child = parent.children[x]; + if (child.tagName && child != element) { + height += CoreDomUtils.instance.getElementHeight(child, false, true, true); + } + } + } + element = parent; + } + + const computedStyle = getComputedStyle(element); + height += CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'paddingTop') + + CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'paddingBottom'); + + if (element.parentElement?.tagName == 'ION-CONTENT') { + const cs2 = getComputedStyle(element); + + height -= CoreDomUtils.instance.getComputedStyleMeasure(cs2, 'paddingTop') + + CoreDomUtils.instance.getComputedStyleMeasure(cs2, 'paddingBottom'); + } + + return height; + } + + /** + * On change function to sync with form data. + */ + onChange(): void { + if (this.rteEnabled) { + if (!this.editorElement) { + return; + } + + if (this.isNullOrWhiteSpace(this.editorElement.innerText)) { + this.clearText(); + } else { + // The textarea and the form control must receive the original URLs. + this.restoreExternalContent(); + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control?.setValue(this.editorElement.innerHTML, { emitEvent: false }); + this.control?.markAsDirty(); + if (this.textarea) { + this.textarea.value = this.editorElement.innerHTML; + } + // Treat URLs again for the editor. + this.treatExternalContent(); + } + } else { + if (!this.textarea) { + return; + } + + if (this.isNullOrWhiteSpace(this.textarea.value || '')) { + this.clearText(); + } else { + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control?.setValue(this.textarea.value, { emitEvent: false }); + this.control?.markAsDirty(); + } + } + + this.contentChanged.emit(this.control?.value); + } + + /** + * On key down function to move the cursor. + * https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div + * + * @param event The event. + */ + moveCursor(event: KeyboardEvent): void { + if (!this.rteEnabled || !this.editorElement) { + return; + } + + if (event.key != 'ArrowLeft' && event.key != 'ArrowRight') { + return; + } + + this.stopBubble(event); + + const move = event.key == 'ArrowLeft' ? -1 : +1; + const cursor = this.getCurrentCursorPosition(this.editorElement); + + this.setCurrentCursorPosition(this.editorElement, cursor + move); + } + + /** + * Returns the number of chars from the beggining where is placed the cursor. + * + * @param parent Parent where to get the position from. + * @return Position in chars. + */ + protected getCurrentCursorPosition(parent: Node): number { + const selection = window.getSelection(); + + let charCount = -1; + + if (selection?.focusNode && parent.contains(selection.focusNode)) { + let node: Node | null = selection.focusNode; + charCount = selection.focusOffset; + + while (node) { + if (node.isSameNode(parent)) { + break; + } + + if (node.previousSibling) { + node = node.previousSibling; + charCount += (node.textContent || '').length; + } else { + node = node.parentNode; + if (node === null) { + break; + } + } + } + } + + return charCount; + } + + /** + * Set the caret position on the character number. + * + * @param parent Parent where to set the position. + * @param chars Number of chars where to place the caret. If not defined it will go to the end. + */ + protected setCurrentCursorPosition(parent: Node, chars?: number): void { + /** + * Loops round all the child text nodes within the supplied node and sets a range from the start of the initial node to + * the characters. + * + * @param node Node where to start. + * @param range Previous calculated range. + * @param chars Object with counting of characters (input-output param). + * @return Selection range. + */ + const setRange = (node: Node, range: Range, chars: { count: number }): Range => { + if (chars.count === 0) { + range.setEnd(node, 0); + } else if (node && chars.count > 0) { + if (node.hasChildNodes()) { + // Navigate through children. + for (let lp = 0; lp < node.childNodes.length; lp++) { + range = setRange(node.childNodes[lp], range, chars); + + if (chars.count === 0) { + break; + } + } + } else if ((node.textContent || '').length < chars.count) { + // Jump this node. + // @todo: empty nodes will be omitted. + chars.count -= (node.textContent || '').length; + } else { + // The cursor will be placed in this element. + range.setEnd(node, chars.count); + chars.count = 0; + } + } + + return range; + }; + + let range = document.createRange(); + if (typeof chars === 'undefined') { + // Select all so it will go to the end. + range.selectNode(parent); + range.selectNodeContents(parent); + } else if (chars < 0 || chars > (parent.textContent || '').length) { + return; + } else { + range.selectNode(parent); + range.setStart(parent, 0); + range = setRange(parent, range, { count: chars }); + } + + if (range) { + const selection = window.getSelection(); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } + + /** + * Toggle from rte editor to textarea syncing values. + * + * @param event The event. + */ + async toggleEditor(event: Event): Promise { + this.stopBubble(event); + + this.setContent(this.control?.value || ''); + + this.rteEnabled = !this.rteEnabled; + + // Set focus and cursor at the end. + // Modify the DOM directly so the keyboard stays open. + if (this.rteEnabled) { + // Update tags for a11y. + this.replaceTags('b', 'strong'); + this.replaceTags('i', 'em'); + this.editorElement?.removeAttribute('hidden'); + const textareaInputElement = await this.textarea?.getInputElement(); + textareaInputElement?.setAttribute('hidden', ''); + this.editorElement?.focus(); + } else { + this.editorElement?.setAttribute('hidden', ''); + const textareaInputElement = await this.textarea?.getInputElement(); + textareaInputElement?.removeAttribute('hidden'); + this.textarea?.setFocus(); + } + } + + /** + * Treat elements that can contain external content. + * We only search for images because the editor should receive unfiltered text, so the multimedia filter won't be applied. + * Treating videos and audios in here is complex, so if a user manually adds one he won't be able to play it in the editor. + */ + protected treatExternalContent(): void { + if (!CoreSites.instance.isLoggedIn() || !this.editorElement) { + // Only treat external content if the user is logged in. + return; + } + + const elements = Array.from(this.editorElement.querySelectorAll('img')); + const siteId = CoreSites.instance.getCurrentSiteId(); + const canDownloadFiles = CoreSites.instance.getCurrentSite()!.canDownloadFiles(); + elements.forEach(async (el) => { + if (el.getAttribute('data-original-src')) { + // Already treated. + return; + } + + const url = el.src; + + if (!url || !CoreUrlUtils.instance.isDownloadableUrl(url) || + (!canDownloadFiles && CoreUrlUtils.instance.isPluginFileUrl(url))) { + // Nothing to treat. + return; + } + + // Check if it's downloaded. + const finalUrl = await CoreFilepool.instance.getSrcByUrl(siteId, url, this.component, this.componentId); + + // Check again if it's already treated, this function can be called concurrently more than once. + if (!el.getAttribute('data-original-src')) { + el.setAttribute('data-original-src', el.src); + el.setAttribute('src', finalUrl); + } + }); + } + + /** + * Reverts changes made by treatExternalContent. + */ + protected restoreExternalContent(): void { + if (!this.editorElement) { + return; + } + + const elements = Array.from(this.editorElement.querySelectorAll('img')); + elements.forEach((el) => { + const originalUrl = el.getAttribute('data-original-src'); + if (originalUrl) { + el.setAttribute('src', originalUrl); + el.removeAttribute('data-original-src'); + } + }); + } + + /** + * Check if text is empty. + * + * @param value text + */ + protected isNullOrWhiteSpace(value: string | null): boolean { + if (value == null || typeof value == 'undefined') { + return true; + } + + value = value.replace(/[\n\r]/g, ''); + value = value.split(' ').join(''); + + return value.length === 0; + } + + /** + * Set the content of the textarea and the editor element. + * + * @param value New content. + */ + protected setContent(value: string | null): void { + if (!this.editorElement || !this.textarea) { + return; + } + + if (this.isNullOrWhiteSpace(value)) { + this.editorElement.innerHTML = '

'; + this.textarea.value = ''; + } else { + this.editorElement.innerHTML = value!; + this.textarea.value = value; + this.treatExternalContent(); + } + } + + /** + * Clear the text. + */ + clearText(): void { + this.setContent(null); + + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control?.setValue(null, { emitEvent: false }); + + setTimeout(() => { + if (this.rteEnabled && this.editorElement) { + this.setCurrentCursorPosition(this.editorElement); + } + }, 1); + } + + /** + * Execute an action over the selected text. + * API docs: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand + * + * @param event Event data + * @param command Command to execute. + * @param parameters If parameters is set to block, a formatBlock command will be performed. Otherwise it will switch the + * toolbar styles button when set. + */ + buttonAction(event: Event, command: string, parameters?: string): void { + this.stopBubble(event); + + if (!command) { + return; + } + + if (parameters == 'block') { + document.execCommand('formatBlock', false, '<' + command + '>'); + + return; + } + + if (parameters) { + this.toolbarStyles[parameters] = this.toolbarStyles[parameters] == 'true' ? 'false' : 'true'; + } + + document.execCommand(command, false); + + // Modern browsers are using non a11y tags, so replace them. + if (command == 'bold') { + this.replaceTags('b', 'strong'); + } else if (command == 'italic') { + this.replaceTags('i', 'em'); + } + } + + /** + * Replace tags for a11y. + * + * @param originTag Origin tag to be replaced. + * @param destinationTag Destination tag to replace. + */ + protected replaceTags(originTag: string, destinationTag: string): void { + if (!this.editorElement) { + return; + } + + const elems = Array.from(this.editorElement.getElementsByTagName(originTag)); + + elems.forEach((elem) => { + const newElem = document.createElement(destinationTag); + newElem.innerHTML = elem.innerHTML; + + if (elem.hasAttributes()) { + const attrs = Array.from(elem.attributes); + attrs.forEach((attr) => { + newElem.setAttribute(attr.name, attr.value); + }); + } + + elem.parentNode?.replaceChild(newElem, elem); + }); + + this.onChange(); + } + + /** + * Focus editor when click the area. + */ + focusRTE(): void { + if (this.rteEnabled) { + this.editorElement?.focus(); + } else { + this.textarea?.setFocus(); + } + } + + /** + * Hide the toolbar in phone mode. + */ + hideToolbar(event: Event): void { + this.stopBubble(event); + + if (this.isPhone) { + this.toolbarHidden = true; + } + } + + /** + * Show the toolbar. + */ + showToolbar(event: Event): void { + this.stopBubble(event); + + this.editorElement?.focus(); + this.toolbarHidden = false; + } + + /** + * Stop event default and propagation. + * + * @param event Event. + */ + stopBubble(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + } + + /** + * When a button is clicked first we should stop event propagation, but it has some cases to not. + * + * @param event Event. + */ + mouseDownAction(event: Event): void { + const selection = window.getSelection()?.toString(); + + // When RTE is focused with a whole paragraph in desktop the stopBubble will not fire click. + if (CoreApp.instance.isMobile() || !this.rteEnabled || document.activeElement != this.editorElement || selection == '') { + this.stopBubble(event); + } + } + + /** + * Method that shows the next toolbar buttons. + */ + async toolbarNext(event: Event): Promise { + this.stopBubble(event); + + if (!this.toolbarNextHidden) { + const currentIndex = await this.toolbarSlides?.getActiveIndex(); + this.toolbarSlides?.slideTo((currentIndex || 0) + this.slidesOpts.slidesPerView); + } + + await this.updateToolbarArrows(); + } + + /** + * Method that shows the previous toolbar buttons. + */ + async toolbarPrev(event: Event): Promise { + this.stopBubble(event); + + if (!this.toolbarPrevHidden) { + const currentIndex = await this.toolbarSlides?.getActiveIndex(); + this.toolbarSlides?.slideTo((currentIndex || 0) - this.slidesOpts.slidesPerView); + } + + await this.updateToolbarArrows(); + } + + /** + * Update the number of toolbar buttons displayed. + */ + async updateToolbarButtons(): Promise { + if (!this.isCurrentView || !this.toolbar || !this.toolbarSlides) { + // Don't calculate if component isn't in current view, the calculations are wrong. + return; + } + + const length = await this.toolbarSlides.length(); + + const width = CoreDomUtils.instance.getElementWidth(this.toolbar.nativeElement); + + if (!width) { + // Width is not available yet, try later. + setTimeout(this.updateToolbarButtons.bind(this), 100); + + return; + } + + if (width > length * this.toolbarButtonWidth) { + this.slidesOpts = { ...this.slidesOpts, slidesPerView: length }; + this.toolbarArrows = false; + } else { + const slidesPerView = Math.floor((width - this.toolbarArrowWidth * 2) / this.toolbarButtonWidth); + this.slidesOpts = { ...this.slidesOpts, slidesPerView }; + this.toolbarArrows = true; + } + + await this.toolbarSlides.update(); + + await this.updateToolbarArrows(); + } + + /** + * Show or hide next/previous toolbar arrows. + */ + async updateToolbarArrows(): Promise { + if (!this.toolbarSlides) { + return; + } + + const currentIndex = await this.toolbarSlides.getActiveIndex(); + const length = await this.toolbarSlides.length(); + this.toolbarPrevHidden = currentIndex <= 0; + this.toolbarNextHidden = currentIndex + this.slidesOpts.slidesPerView >= length; + } + + /** + * Update highlighted toolbar styles. + */ + updateToolbarStyles(): void { + const node = window.getSelection()?.focusNode; + if (!node) { + return; + } + + let element = node.nodeType == 1 ? node as HTMLElement : node.parentElement; + const styles = {}; + + while (element != null && element !== this.editorElement) { + const tagName = element.tagName.toLowerCase(); + if (this.toolbarStyles[tagName]) { + styles[tagName] = 'true'; + } + element = element.parentElement; + } + + for (const tagName in this.toolbarStyles) { + this.toolbarStyles[tagName] = 'false'; + } + + if (element === this.editorElement) { + Object.assign(this.toolbarStyles, styles); + } + } + + /** + * Check if should auto save drafts. + * + * @return {boolean} Whether it should auto save drafts. + */ + protected shouldAutoSaveDrafts(): boolean { + return !!CoreSites.instance.getCurrentSite() && + (typeof this.autoSave == 'undefined' || CoreUtils.instance.isTrueOrOne(this.autoSave)) && + typeof this.contextLevel != 'undefined' && + typeof this.contextInstanceId != 'undefined' && + typeof this.elementId != 'undefined'; + } + + /** + * Restore a draft if there is any. + * + * @return Promise resolved when done. + */ + protected async restoreDraft(): Promise { + try { + const entry = await CoreEditorOffline.instance.resumeDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + this.pageInstance, + this.originalContent, + ); + + if (typeof entry == 'undefined') { + // No draft found. + return; + } + + let draftText = entry.drafttext || ''; + + // Revert untouched editor contents to an empty string. + if (draftText == '

' || draftText == '


' || draftText == '
' || + draftText == '

 

' || draftText == '


 

') { + draftText = ''; + } + + if (draftText !== '' && this.control && draftText != this.control.value) { + // Restore the draft. + this.control.setValue(draftText, { emitEvent: false }); + this.setContent(draftText); + this.lastDraft = draftText; + this.draftWasRestored = true; + this.originalContent = entry.originalcontent; + + if (entry.drafttext != entry.originalcontent) { + // Notify the user. + this.showMessage('core.editor.textrecovered', this.RESTORE_MESSAGE_CLEAR_TIME); + } + } + } catch (error) { + // Ignore errors, shouldn't happen. + } + } + + /** + * Automatically save drafts every certain time. + */ + protected autoSaveDrafts(): void { + this.autoSaveInterval = window.setInterval(async () => { + if (!this.control) { + return; + } + + const newText = this.control.value; + + if (this.lastDraft == newText) { + // Text hasn't changed, nothing to save. + return; + } + + try { + await CoreEditorOffline.instance.saveDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + this.pageInstance, + newText, + this.originalContent, + ); + + // Draft saved, notify the user. + this.lastDraft = newText; + this.showMessage('core.editor.autosavesucceeded', this.SAVE_MESSAGE_CLEAR_TIME); + } catch (error) { + // Error saving draft. + } + }, this.DRAFT_AUTOSAVE_FREQUENCY); + } + + /** + * Delete the draft when the form is submitted or cancelled. + */ + protected deleteDraftOnSubmitOrCancel(): void { + this.resetObserver = CoreEvents.on(CoreEvents.FORM_ACTION, async (data: CoreEventFormActionData) => { + const form = this.element.closest('form'); + + if (data.form && form && data.form == form) { + try { + await CoreEditorOffline.instance.deleteDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + ); + } catch (error) { + // Error deleting draft. Shouldn't happen. + } + } + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Show a message. + * + * @param message Identifier of the message to display. + * @param timeout Number of milliseconds when to remove the message. + */ + protected showMessage(message: string, timeout: number): void { + clearTimeout(this.hideMessageTimeout); + + this.infoMessage = message; + + this.hideMessageTimeout = window.setTimeout(() => { + this.hideMessageTimeout = undefined; + this.infoMessage = undefined; + }, timeout); + } + + /** + * Scan a QR code and put its text in the editor. + * + * @param event Event data + * @return Promise resolved when done. + */ + async scanQR(event: Event): Promise { + this.stopBubble(event); + + // Scan for a QR code. + const text = await CoreUtils.instance.scanQR(); + + if (text) { + document.execCommand('insertText', false, text); + } + // this.content.resize(); // Resize content, otherwise the content height becomes 1 for some reason. + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.isCurrentView = true; + + this.updateToolbarButtons(); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.isCurrentView = false; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.valueChangeSubscription?.unsubscribe(); + this.languageChangedSubscription?.unsubscribe(); + window.removeEventListener('resize', this.resizeFunction!); + document.removeEventListener('selectionchange', this.selectionChangeFunction!); + clearInterval(this.initHeightInterval); + clearInterval(this.autoSaveInterval); + clearTimeout(this.hideMessageTimeout); + this.resetObserver?.off(); + this.keyboardObserver?.off(); + } + +} diff --git a/src/core/features/editor/editor.module.ts b/src/core/features/editor/editor.module.ts new file mode 100644 index 000000000..012860b7c --- /dev/null +++ b/src/core/features/editor/editor.module.ts @@ -0,0 +1,35 @@ +// (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 { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CoreEditorComponentsModule } from './components/components.module'; +import { SITE_SCHEMA } from './services/database/editor'; + +@NgModule({ + declarations: [ + ], + imports: [ + CoreEditorComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA], + multi: true, + }, + ], +}) +export class CoreEditorModule {} diff --git a/src/core/features/editor/lang/en.json b/src/core/features/editor/lang/en.json new file mode 100644 index 000000000..508c6ddb0 --- /dev/null +++ b/src/core/features/editor/lang/en.json @@ -0,0 +1,17 @@ +{ + "autosavesucceeded": "Draft saved.", + "bold": "Bold", + "clear": "Clear formatting", + "h3": "Heading (large)", + "h4": "Heading (medium)", + "h5": "Heading (small)", + "hidetoolbar": "Hide toolbar", + "italic": "Italic", + "orderedlist": "Ordered list", + "p": "Paragraph", + "strike": "Strike through", + "textrecovered": "A draft version of this text was automatically restored.", + "toggle": "Toggle editor", + "underline": "Underline", + "unorderedlist": "Unordered list" +} \ No newline at end of file diff --git a/src/core/features/editor/services/database/editor.ts b/src/core/features/editor/services/database/editor.ts new file mode 100644 index 000000000..1a0eac575 --- /dev/null +++ b/src/core/features/editor/services/database/editor.ts @@ -0,0 +1,93 @@ +// (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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreEditorOffline service. + */ +export const DRAFT_TABLE = 'editor_draft'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreEditorProvider', + version: 1, + tables: [ + { + name: DRAFT_TABLE, + columns: [ + { + name: 'contextlevel', + type: 'TEXT', + }, + { + name: 'contextinstanceid', + type: 'INTEGER', + }, + { + name: 'elementid', + type: 'TEXT', + }, + { + name: 'extraparams', // Moodle web uses a page hash built with URL. App will use some params stringified. + type: 'TEXT', + }, + { + name: 'drafttext', + type: 'TEXT', + notNull: true, + }, + { + name: 'pageinstance', + type: 'TEXT', + notNull: true, + }, + { + name: 'timecreated', + type: 'INTEGER', + notNull: true, + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true, + }, + { + name: 'originalcontent', + type: 'TEXT', + }, + ], + primaryKeys: ['contextlevel', 'contextinstanceid', 'elementid', 'extraparams'], + }, + ], +}; + +/** + * Primary data to identify a stored draft. + */ +export type CoreEditorDraftPrimaryData = { + contextlevel: string; // Context level. + contextinstanceid: number; // The instance ID related to the context. + elementid: string; // Element ID. + extraparams: string; // Extra params stringified. +}; + +/** + * Draft data stored. + */ +export type CoreEditorDraft = CoreEditorDraftPrimaryData & { + drafttext?: string; // Draft text stored. + pageinstance?: string; // Unique identifier to prevent storing data from several sources at the same time. + timecreated?: number; // Time created. + timemodified?: number; // Time modified. + originalcontent?: string; // Original content of the editor. +}; diff --git a/src/core/features/editor/services/editor-offline.ts b/src/core/features/editor/services/editor-offline.ts new file mode 100644 index 000000000..f9dda9ea3 --- /dev/null +++ b/src/core/features/editor/services/editor-offline.ts @@ -0,0 +1,239 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; + +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreEditorDraft, CoreEditorDraftPrimaryData, DRAFT_TABLE } from './database/editor'; + +/** + * Service with features regarding rich text editor in offline. + */ +@Injectable({ providedIn: 'root' }) +export class CoreEditorOfflineProvider { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreEditorOfflineProvider'); + } + + /** + * Delete a draft from DB. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + siteId?: string, + ): Promise { + try { + const db = await CoreSites.instance.getSiteDb(siteId); + + const params = this.fixDraftPrimaryData(contextLevel, contextInstanceId, elementId, extraParams); + + await db.deleteRecords(DRAFT_TABLE, params); + } catch (error) { + // Ignore errors, probably no draft stored. + } + } + + /** + * Return an object with the draft primary data converted to the right format. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @return Object with the fixed primary data. + */ + protected fixDraftPrimaryData( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + ): CoreEditorDraftPrimaryData { + + return { + contextlevel: contextLevel, + contextinstanceid: contextInstanceId, + elementid: elementId, + extraparams: CoreUtils.instance.sortAndStringify(extraParams || {}), + }; + } + + /** + * Get a draft from DB. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the draft data. Undefined if no draft stored. + */ + async getDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const params = this.fixDraftPrimaryData(contextLevel, contextInstanceId, elementId, extraParams); + + return db.getRecord(DRAFT_TABLE, params); + } + + /** + * Get draft to resume it. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param pageInstance Unique identifier to prevent storing data from several sources at the same time. + * @param originalContent Original content of the editor. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the draft data. Undefined if no draft stored. + */ + async resumeDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + pageInstance: string, + originalContent?: string, + siteId?: string, + ): Promise { + + try { + // Check if there is a draft stored. + const entry = await this.getDraft(contextLevel, contextInstanceId, elementId, extraParams, siteId); + + // There is a draft stored. Update its page instance. + try { + const db = await CoreSites.instance.getSiteDb(siteId); + + entry.pageinstance = pageInstance; + entry.timemodified = Date.now(); + + if (originalContent && entry.originalcontent != originalContent) { + entry.originalcontent = originalContent; + entry.drafttext = ''; // "Discard" the draft. + } + + await db.insertRecord(DRAFT_TABLE, entry); + } catch (error) { + // Ignore errors saving the draft. It shouldn't happen. + } + + return entry; + } catch (error) { + // No draft stored. Store an empty draft to save the pageinstance. + await this.saveDraft( + contextLevel, + contextInstanceId, + elementId, + extraParams, + pageInstance, + '', + originalContent, + siteId, + ); + } + } + + /** + * Save a draft in DB. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param pageInstance Unique identifier to prevent storing data from several sources at the same time. + * @param draftText The text to store. + * @param originalContent Original content of the editor. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + pageInstance: string, + draftText: string, + originalContent?: string, + siteId?: string, + ): Promise { + + let timecreated = Date.now(); + let entry: CoreEditorDraft | undefined; + + // Check if there is a draft already stored. + try { + entry = await this.getDraft(contextLevel, contextInstanceId, elementId, extraParams, siteId); + + timecreated = entry.timecreated || timecreated; + } catch (error) { + // No draft already stored. + } + + if (entry) { + if (entry.pageinstance != pageInstance) { + this.logger.warn(`Discarding draft because of pageinstance. Context '${contextLevel}' '${contextInstanceId}', ` + + `element '${elementId}'`); + + throw new CoreError('Draft was discarded because it was modified in another page.'); + } + + if (!originalContent) { + // Original content not set, use the one in the entry. + originalContent = entry.originalcontent; + } + } + + const db = await CoreSites.instance.getSiteDb(siteId); + + const data: CoreEditorDraft = this.fixDraftPrimaryData(contextLevel, contextInstanceId, elementId, extraParams); + + data.drafttext = (draftText || '').trim(); + data.pageinstance = pageInstance; + data.timecreated = timecreated; + data.timemodified = Date.now(); + if (originalContent) { + data.originalcontent = originalContent; + } + + await db.insertRecord(DRAFT_TABLE, data); + } + +} + +export class CoreEditorOffline extends makeSingleton(CoreEditorOfflineProvider) {} diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index e7ae51d23..fa85d4366 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -20,7 +20,7 @@ import { Md5 } from 'ts-md5'; import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; -import { CoreEvents } from '@singletons/events'; +import { CoreEventFormAction, CoreEvents } from '@singletons/events'; import { CoreFile } from '@services/file'; import { CoreWSExternalWarning } from '@services/ws'; import { CoreTextUtils, CoreTextErrorObject } from '@services/utils/text'; @@ -1717,7 +1717,7 @@ export class CoreDomUtilsProvider { } CoreEvents.trigger(CoreEvents.FORM_ACTION, { - action: 'cancel', + action: CoreEventFormAction.CANCEL, form: formRef.nativeElement, }, siteId); } @@ -1735,7 +1735,7 @@ export class CoreDomUtilsProvider { } CoreEvents.trigger(CoreEvents.FORM_ACTION, { - action: 'submit', + action: CoreEventFormAction.SUBMIT, form: formRef.nativeElement || formRef, online: !!online, }, siteId); diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 4d45a790b..eae75d94f 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -249,3 +249,17 @@ export type CoreEventUserDeletedData = CoreEventSiteData & { // eslint-disable-next-line @typescript-eslint/no-explicit-any params: any; // Params sent to the WS that failed. }; + +export enum CoreEventFormAction { + CANCEL = 'cancel', + SUBMIT = 'submit', +} + +/** + * Data passed to FORM_ACTION event. + */ +export type CoreEventFormActionData = CoreEventSiteData & { + action: CoreEventFormAction; // Action performed. + form: HTMLElement; // Form element. + online?: boolean; // Whether the data was sent to server or not. Only when submitting. +}; From fd19dd8c62502dc2509cc38158281aba6a4efb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 4 Dec 2020 15:23:00 +0100 Subject: [PATCH 11/19] MOBILE-3592 user: Style user avatars --- .../components/user-avatar/user-avatar.scss | 12 +++- .../components/user-avatar/user-avatar.ts | 2 +- .../pages/course-preview/course-preview.html | 4 +- .../features/mainmenu/pages/more/more.html | 2 +- .../features/user/pages/profile/profile.html | 15 ++-- .../features/user/pages/profile/profile.scss | 72 ++++++++++++------- src/theme/app.scss | 16 +++-- src/theme/variables.scss | 4 ++ 8 files changed, 82 insertions(+), 45 deletions(-) diff --git a/src/core/components/user-avatar/user-avatar.scss b/src/core/components/user-avatar/user-avatar.scss index 679796d19..94b738f0e 100644 --- a/src/core/components/user-avatar/user-avatar.scss +++ b/src/core/components/user-avatar/user-avatar.scss @@ -1,6 +1,11 @@ :host { position: relative; cursor: pointer; + img { + border-radius: 50%; + width: var(--core-avatar-size); + height: var(--core-avatar-size); + } .contact-status { position: absolute; @@ -30,4 +35,9 @@ :host-context(.toolbar) .contact-status { width: 10px; height: 10px; -} \ No newline at end of file +} + +:host-context([dir="rtl"]) .contact-status { + left: 0; + right: unset; +} diff --git a/src/core/components/user-avatar/user-avatar.ts b/src/core/components/user-avatar/user-avatar.ts index 565971a9a..dbbd25b31 100644 --- a/src/core/components/user-avatar/user-avatar.ts +++ b/src/core/components/user-avatar/user-avatar.ts @@ -26,7 +26,7 @@ import { CoreUserProvider, CoreUserBasicData, CoreUserProfilePictureUpdatedData /** * Component to display a "user avatar". * - * Example: + * Example: */ @Component({ selector: 'core-user-avatar', diff --git a/src/core/features/courses/pages/course-preview/course-preview.html b/src/core/features/courses/pages/course-preview/course-preview.html index 444ad7027..1424ea2a5 100644 --- a/src/core/features/courses/pages/course-preview/course-preview.html +++ b/src/core/features/courses/pages/course-preview/course-preview.html @@ -47,11 +47,11 @@ [userId]="contact.id" [courseId]="isEnrolled ? course.id : null" [attr.aria-label]="'core.viewprofile' | translate"> - - +

{{contact.fullname}}

diff --git a/src/core/features/mainmenu/pages/more/more.html b/src/core/features/mainmenu/pages/more/more.html index 0661d0347..aba57a084 100644 --- a/src/core/features/mainmenu/pages/more/more.html +++ b/src/core/features/mainmenu/pages/more/more.html @@ -10,7 +10,7 @@ - +

{{siteInfo.fullname}}

diff --git a/src/core/features/user/pages/profile/profile.html b/src/core/features/user/pages/profile/profile.html index 5f8b50497..332e7cdbf 100644 --- a/src/core/features/user/pages/profile/profile.html +++ b/src/core/features/user/pages/profile/profile.html @@ -12,15 +12,14 @@ - - - - +

+ - - +
+

{{ user.fullname }}

{{ user.address }}

diff --git a/src/core/features/user/pages/profile/profile.scss b/src/core/features/user/pages/profile/profile.scss index 454b80ab1..d3bcabffe 100644 --- a/src/core/features/user/pages/profile/profile.scss +++ b/src/core/features/user/pages/profile/profile.scss @@ -1,31 +1,49 @@ :host { + + .core-user-profile-maininfo::part(native) { + flex-direction: column; + } + ::ng-deep { + core-user-avatar { + display: block; + --core-avatar-size: var(--core-large-avatar-size); + + img { + margin: 0; + } + + .contact-status { + width: 24px !important; + height: 24px !important; + } + + .core-icon-foreground { + position: absolute; + right: 0; + bottom: 0; + line-height: 30px; + text-align: center; + width: 30px; + height: 30px; + border-radius: 50%; + background-color:var(--background); + + :host-context([dir="rtl"]) & { + left: 0; + right: unset; + } + } + } + } + +} + +:host-context([dir="rtl"]) ::ng-deep core-user-avatar .core-icon-foreground { + left: 0; + right: unset; +} + // @todo - // .core-icon-foreground { - // position: absolute; - // @include position(null, 0, 0, null); - // font-size: 24px; - // line-height: 30px; - // text-align: center; - - // width: 30px; - // height: 30px; - // border-radius: 50%; - // background-color: $white; - // @include darkmode() { - // background: $core-dark-item-bg-color; - // } - // } - // [core-user-avatar].item-avatar-center { - // display: inline-block; - // img { - // margin: 0; - // } - // .contact-status { - // width: 24px; - // height: 24px; - // } - // } - // .core-user-communication-handlers { // background: $list-background-color; // border-bottom: 1px solid $list-border-color; @@ -66,4 +84,4 @@ // @include margin(null, null, null, 0.3em); // } // } -} \ No newline at end of file + diff --git a/src/theme/app.scss b/src/theme/app.scss index 66a988c47..106fcab25 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -203,13 +203,14 @@ ion-card.core-danger-card { // Avatar // ------------------------- // Large centered avatar -img.large-avatar { +img.large-avatar, +.large-avatar img { display: block; margin: auto; - width: 90px; - height: 90px; - max-width: 90px; - max-height: 90px; + width: var(--core-large-avatar-size); + height: var(--core-large-avatar-size); + max-width: var(--core-large-avatar-size); + max-height: var(--core-large-avatar-size); margin-bottom: 10px; border-radius : 50%; padding: 4px; @@ -217,6 +218,11 @@ img.large-avatar { background-color: transparent; } +ion-avatar.large-avatar { + width: var(--core-large-avatar-size); + height: var(--core-large-avatar-size); +} + ion-avatar ion-img, ion-avatar img { text-indent: -99999px; background-color: var(--gray-light); diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 836edd3fd..2c7b87910 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -170,6 +170,10 @@ --core-course-color-8: var(--custom-course-color-9, #fd79a8); --core-course-color-9: var(--custom-course-color-90, #6c5ce7); --core-star-color: var(--custom-star-color, var(--core-color)); + + --core-large-avatar-size: var(--custom-large-avatar-size, 90px); + + --core-avatar-size: var(--custom-avatar-size, 64px); } /* From 46cec40cfeacd86259c9ff32c96e208db528af2d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 10 Dec 2020 13:21:05 +0100 Subject: [PATCH 12/19] MOBILE-3592 user: Make delegate and handlers singleton --- .../checkbox/checkbox.module.ts | 8 +++----- .../checkbox/services/handlers/checkbox.ts | 5 ++++- .../datetime/datetime.module.ts | 8 +++----- .../datetime/services/handlers/datetime.ts | 5 ++++- .../userprofilefield/menu/menu.module.ts | 8 +++----- .../menu/services/handlers/menu.ts | 5 ++++- .../text/services/handlers/text.ts | 5 ++++- .../userprofilefield/text/text.module.ts | 8 +++----- .../textarea/services/handlers/textarea.ts | 5 ++++- .../textarea/textarea.module.ts | 8 +++----- .../login/pages/email-signup/email-signup.ts | 5 ++--- .../user-profile-field/user-profile-field.ts | 6 +----- .../user/pages/profile/profile.page.ts | 13 ++++++------ .../user/services/{db => database}/user.ts | 0 .../user/services/handlers/profile-link.ts | 5 ++++- .../user/services/handlers/profile-mail.ts | 9 ++++++--- .../user/services/handlers/sync-cron.ts | 5 ++++- .../features/user/services/user-delegate.ts | 13 ++++++------ .../features/user/services/user-helper.ts | 4 +--- .../features/user/services/user-offline.ts | 6 ++---- .../services/user-profile-field-delegate.ts | 9 +++++---- src/core/features/user/services/user.ts | 6 ++---- src/core/features/user/user.module.ts | 20 ++++++++----------- 23 files changed, 83 insertions(+), 83 deletions(-) rename src/core/features/user/services/{db => database}/user.ts (100%) diff --git a/src/addons/userprofilefield/checkbox/checkbox.module.ts b/src/addons/userprofilefield/checkbox/checkbox.module.ts index d6134503c..4b232c5e2 100644 --- a/src/addons/userprofilefield/checkbox/checkbox.module.ts +++ b/src/addons/userprofilefield/checkbox/checkbox.module.ts @@ -39,11 +39,9 @@ import { CoreComponentsModule } from '@components/components.module'; { provide: APP_INITIALIZER, multi: true, - deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldCheckboxHandler], - useFactory: ( - userProfileFieldDelegate: CoreUserProfileFieldDelegate, - handler: AddonUserProfileFieldCheckboxHandler, - ) => () => userProfileFieldDelegate.registerHandler(handler), + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldCheckboxHandler.instance), }, ], exports: [ diff --git a/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts b/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts index 02d31fd4e..f085e2f3b 100644 --- a/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts +++ b/src/addons/userprofilefield/checkbox/services/handlers/checkbox.ts @@ -17,13 +17,14 @@ import { Injectable, Type } from '@angular/core'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; import { CoreUserProfileField } from '@features/user/services/user'; import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { makeSingleton } from '@singletons'; import { AddonUserProfileFieldCheckboxComponent } from '../../component/checkbox'; /** * Checkbox user profile field handlers. */ @Injectable({ providedIn: 'root' }) -export class AddonUserProfileFieldCheckboxHandler implements CoreUserProfileFieldHandler { +export class AddonUserProfileFieldCheckboxHandlerService implements CoreUserProfileFieldHandler { name = 'AddonUserProfileFieldCheckbox'; type = 'checkbox'; @@ -74,3 +75,5 @@ export class AddonUserProfileFieldCheckboxHandler implements CoreUserProfileFiel } } + +export class AddonUserProfileFieldCheckboxHandler extends makeSingleton(AddonUserProfileFieldCheckboxHandlerService) {} diff --git a/src/addons/userprofilefield/datetime/datetime.module.ts b/src/addons/userprofilefield/datetime/datetime.module.ts index 02cd226f4..4d9625d8a 100644 --- a/src/addons/userprofilefield/datetime/datetime.module.ts +++ b/src/addons/userprofilefield/datetime/datetime.module.ts @@ -41,11 +41,9 @@ import { CorePipesModule } from '@pipes/pipes.module'; { provide: APP_INITIALIZER, multi: true, - deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldDatetimeHandler], - useFactory: ( - userProfileFieldDelegate: CoreUserProfileFieldDelegate, - handler: AddonUserProfileFieldDatetimeHandler, - ) => () => userProfileFieldDelegate.registerHandler(handler), + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldDatetimeHandler.instance), }, ], exports: [ diff --git a/src/addons/userprofilefield/datetime/services/handlers/datetime.ts b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts index b35feb867..5a1ab635f 100644 --- a/src/addons/userprofilefield/datetime/services/handlers/datetime.ts +++ b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts @@ -18,13 +18,14 @@ import { AuthEmailSignupProfileField } from '@features/login/services/login-help import { CoreUserProfileField } from '@features/user/services/user'; import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; import { CoreTimeUtils } from '@services/utils/time'; +import { makeSingleton } from '@singletons'; import { AddonUserProfileFieldDatetimeComponent } from '../../component/datetime'; /** * Datetime user profile field handlers. */ @Injectable({ providedIn: 'root' }) -export class AddonUserProfileFieldDatetimeHandler implements CoreUserProfileFieldHandler { +export class AddonUserProfileFieldDatetimeHandlerService implements CoreUserProfileFieldHandler { name = 'AddonUserProfileFieldDatetime'; type = 'datetime'; @@ -76,3 +77,5 @@ export class AddonUserProfileFieldDatetimeHandler implements CoreUserProfileFiel } } + +export class AddonUserProfileFieldDatetimeHandler extends makeSingleton(AddonUserProfileFieldDatetimeHandlerService) {} diff --git a/src/addons/userprofilefield/menu/menu.module.ts b/src/addons/userprofilefield/menu/menu.module.ts index e91183197..792d82624 100644 --- a/src/addons/userprofilefield/menu/menu.module.ts +++ b/src/addons/userprofilefield/menu/menu.module.ts @@ -41,11 +41,9 @@ import { CoreDirectivesModule } from '@directives/directives.module'; { provide: APP_INITIALIZER, multi: true, - deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldMenuHandler], - useFactory: ( - userProfileFieldDelegate: CoreUserProfileFieldDelegate, - handler: AddonUserProfileFieldMenuHandler, - ) => () => userProfileFieldDelegate.registerHandler(handler), + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldMenuHandler.instance), }, ], exports: [ diff --git a/src/addons/userprofilefield/menu/services/handlers/menu.ts b/src/addons/userprofilefield/menu/services/handlers/menu.ts index d40444adf..7a9396aed 100644 --- a/src/addons/userprofilefield/menu/services/handlers/menu.ts +++ b/src/addons/userprofilefield/menu/services/handlers/menu.ts @@ -17,13 +17,14 @@ import { Injectable, Type } from '@angular/core'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; import { CoreUserProfileField } from '@features/user/services/user'; import { CoreUserProfileFieldHandler, CoreUserProfileFieldHandlerData } from '@features/user/services/user-profile-field-delegate'; +import { makeSingleton } from '@singletons'; import { AddonUserProfileFieldMenuComponent } from '../../component/menu'; /** * Menu user profile field handlers. */ @Injectable({ providedIn: 'root' }) -export class AddonUserProfileFieldMenuHandler implements CoreUserProfileFieldHandler { +export class AddonUserProfileFieldMenuHandlerService implements CoreUserProfileFieldHandler { name = 'AddonUserProfileFieldMenu'; type = 'menu'; @@ -74,3 +75,5 @@ export class AddonUserProfileFieldMenuHandler implements CoreUserProfileFieldHan } } + +export class AddonUserProfileFieldMenuHandler extends makeSingleton(AddonUserProfileFieldMenuHandlerService) {} diff --git a/src/addons/userprofilefield/text/services/handlers/text.ts b/src/addons/userprofilefield/text/services/handlers/text.ts index 11abe2c18..630872d5e 100644 --- a/src/addons/userprofilefield/text/services/handlers/text.ts +++ b/src/addons/userprofilefield/text/services/handlers/text.ts @@ -19,12 +19,13 @@ import { AddonUserProfileFieldTextComponent } from '../../component/text'; import { CoreTextUtils } from '@services/utils/text'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; import { CoreUserProfileField } from '@features/user/services/user'; +import { makeSingleton } from '@singletons'; /** * Text user profile field handlers. */ @Injectable({ providedIn: 'root' }) -export class AddonUserProfileFieldTextHandler implements CoreUserProfileFieldHandler { +export class AddonUserProfileFieldTextHandlerService implements CoreUserProfileFieldHandler { name = 'AddonUserProfileFieldText'; type = 'text'; @@ -73,3 +74,5 @@ export class AddonUserProfileFieldTextHandler implements CoreUserProfileFieldHan } } + +export class AddonUserProfileFieldTextHandler extends makeSingleton(AddonUserProfileFieldTextHandlerService) {} diff --git a/src/addons/userprofilefield/text/text.module.ts b/src/addons/userprofilefield/text/text.module.ts index 7287868ae..edc4f1299 100644 --- a/src/addons/userprofilefield/text/text.module.ts +++ b/src/addons/userprofilefield/text/text.module.ts @@ -41,11 +41,9 @@ import { CoreDirectivesModule } from '@directives/directives.module'; { provide: APP_INITIALIZER, multi: true, - deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldTextHandler], - useFactory: ( - userProfileFieldDelegate: CoreUserProfileFieldDelegate, - handler: AddonUserProfileFieldTextHandler, - ) => () => userProfileFieldDelegate.registerHandler(handler), + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldTextHandler.instance), }, ], exports: [ diff --git a/src/addons/userprofilefield/textarea/services/handlers/textarea.ts b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts index 56ed58bcd..e1a233652 100644 --- a/src/addons/userprofilefield/textarea/services/handlers/textarea.ts +++ b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts @@ -19,12 +19,13 @@ import { AddonUserProfileFieldTextareaComponent } from '../../component/textarea import { CoreTextUtils } from '@services/utils/text'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; import { CoreUserProfileField } from '@features/user/services/user'; +import { makeSingleton } from '@singletons'; /** * Textarea user profile field handlers. */ @Injectable({ providedIn: 'root' }) -export class AddonUserProfileFieldTextareaHandler implements CoreUserProfileFieldHandler { +export class AddonUserProfileFieldTextareaHandlerService implements CoreUserProfileFieldHandler { name = 'AddonUserProfileFieldTextarea'; type = 'textarea'; @@ -83,3 +84,5 @@ export class AddonUserProfileFieldTextareaHandler implements CoreUserProfileFiel } } + +export class AddonUserProfileFieldTextareaHandler extends makeSingleton(AddonUserProfileFieldTextareaHandlerService) {} diff --git a/src/addons/userprofilefield/textarea/textarea.module.ts b/src/addons/userprofilefield/textarea/textarea.module.ts index d42d37ab1..e07ef0e71 100644 --- a/src/addons/userprofilefield/textarea/textarea.module.ts +++ b/src/addons/userprofilefield/textarea/textarea.module.ts @@ -43,11 +43,9 @@ import { CoreEditorComponentsModule } from '@features/editor/components/componen { provide: APP_INITIALIZER, multi: true, - deps: [CoreUserProfileFieldDelegate, AddonUserProfileFieldTextareaHandler], - useFactory: ( - userProfileFieldDelegate: CoreUserProfileFieldDelegate, - handler: AddonUserProfileFieldTextareaHandler, - ) => () => userProfileFieldDelegate.registerHandler(handler), + deps: [], + useFactory: () => () => + CoreUserProfileFieldDelegate.instance.registerHandler(AddonUserProfileFieldTextareaHandler.instance), }, ], exports: [ diff --git a/src/core/features/login/pages/email-signup/email-signup.ts b/src/core/features/login/pages/email-signup/email-signup.ts index 8935a09e3..ba3d36dae 100644 --- a/src/core/features/login/pages/email-signup/email-signup.ts +++ b/src/core/features/login/pages/email-signup/email-signup.ts @@ -83,7 +83,6 @@ export class CoreLoginEmailSignupPage implements OnInit { protected navCtrl: NavController, protected fb: FormBuilder, protected route: ActivatedRoute, - protected userProfileFieldDelegate: CoreUserProfileFieldDelegate, ) { // Create the ageVerificationForm. this.ageVerificationForm = this.fb.group({ @@ -191,7 +190,7 @@ export class CoreLoginEmailSignupPage implements OnInit { { siteUrl: this.siteUrl }, ); - if (this.userProfileFieldDelegate.hasRequiredUnsupportedField(this.settings.profilefields)) { + if (CoreUserProfileFieldDelegate.instance.hasRequiredUnsupportedField(this.settings.profilefields)) { this.allRequiredSupported = false; throw new Error(Translate.instance.instant('core.login.signuprequiredfieldnotsupported')); @@ -302,7 +301,7 @@ export class CoreLoginEmailSignupPage implements OnInit { try { // Get the data for the custom profile fields. - params.customprofilefields = await this.userProfileFieldDelegate.getDataForFields( + params.customprofilefields = await CoreUserProfileFieldDelegate.instance.getDataForFields( this.settings?.profilefields, true, 'email', diff --git a/src/core/features/user/components/user-profile-field/user-profile-field.ts b/src/core/features/user/components/user-profile-field/user-profile-field.ts index 6bccb4b92..678f7b200 100644 --- a/src/core/features/user/components/user-profile-field/user-profile-field.ts +++ b/src/core/features/user/components/user-profile-field/user-profile-field.ts @@ -40,10 +40,6 @@ export class CoreUserProfileFieldComponent implements OnInit { componentClass?: Type; // The class of the component to render. data: CoreUserProfileFieldComponentData = {}; // Data to pass to the component. - constructor( - protected userProfileFieldsDelegate: CoreUserProfileFieldDelegate, - ) { } - /** * Component being initialized. */ @@ -52,7 +48,7 @@ export class CoreUserProfileFieldComponent implements OnInit { return; } - this.componentClass = await this.userProfileFieldsDelegate.getComponent(this.field, this.signup); + this.componentClass = await CoreUserProfileFieldDelegate.instance.getComponent(this.field, this.signup); this.data.field = this.field; this.data.edit = CoreUtils.instance.isTrueOrOne(this.edit); diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index 86f53ecfe..f4e29d3bb 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -31,7 +31,7 @@ import { CoreUserProvider, } from '@features/user/services/user'; import { CoreUserHelper } from '@features/user/services/user-helper'; -import { CoreUserDelegate, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; +import { CoreUserDelegate, CoreUserDelegateService, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreUtils } from '@services/utils/utils'; @@ -64,7 +64,6 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { constructor( protected route: ActivatedRoute, protected navCtrl: NavController, - protected userDelegate: CoreUserDelegate, ) { this.obsProfileRefreshed = CoreEvents.on(CoreUserProvider.PROFILE_REFRESHED, (data) => { @@ -127,26 +126,26 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { // If there's already a subscription, unsubscribe because we'll get a new one. this.subscription?.unsubscribe(); - this.subscription = this.userDelegate.getProfileHandlersFor(user, this.courseId).subscribe((handlers) => { + this.subscription = CoreUserDelegate.instance.getProfileHandlersFor(user, this.courseId).subscribe((handlers) => { this.actionHandlers = []; this.newPageHandlers = []; this.communicationHandlers = []; handlers.forEach((handler) => { switch (handler.type) { - case CoreUserDelegate.TYPE_COMMUNICATION: + case CoreUserDelegateService.TYPE_COMMUNICATION: this.communicationHandlers.push(handler.data); break; - case CoreUserDelegate.TYPE_ACTION: + case CoreUserDelegateService.TYPE_ACTION: this.actionHandlers.push(handler.data); break; - case CoreUserDelegate.TYPE_NEW_PAGE: + case CoreUserDelegateService.TYPE_NEW_PAGE: default: this.newPageHandlers.push(handler.data); break; } }); - this.isLoadingHandlers = !this.userDelegate.areHandlersLoaded(user.id); + this.isLoadingHandlers = !CoreUserDelegate.instance.areHandlersLoaded(user.id); }); await this.checkUserImageUpdated(); diff --git a/src/core/features/user/services/db/user.ts b/src/core/features/user/services/database/user.ts similarity index 100% rename from src/core/features/user/services/db/user.ts rename to src/core/features/user/services/database/user.ts diff --git a/src/core/features/user/services/handlers/profile-link.ts b/src/core/features/user/services/handlers/profile-link.ts index 4cd8b434b..e1453224d 100644 --- a/src/core/features/user/services/handlers/profile-link.ts +++ b/src/core/features/user/services/handlers/profile-link.ts @@ -18,12 +18,13 @@ import { Params } from '@angular/router'; import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; +import { makeSingleton } from '@singletons'; /** * Handler to treat links to user profiles. */ @Injectable({ providedIn: 'root' }) -export class CoreUserProfileLinkHandler extends CoreContentLinksHandlerBase { +export class CoreUserProfileLinkHandlerService extends CoreContentLinksHandlerBase { name = 'CoreUserProfileLinkHandler'; // Match user/view.php and user/profile.php but NOT grade/report/user/. @@ -74,3 +75,5 @@ export class CoreUserProfileLinkHandler extends CoreContentLinksHandlerBase { } } + +export class CoreUserProfileLinkHandler extends makeSingleton(CoreUserProfileLinkHandlerService) {} diff --git a/src/core/features/user/services/handlers/profile-mail.ts b/src/core/features/user/services/handlers/profile-mail.ts index 0b61adce8..a240fc59a 100644 --- a/src/core/features/user/services/handlers/profile-mail.ts +++ b/src/core/features/user/services/handlers/profile-mail.ts @@ -14,20 +14,21 @@ import { Injectable } from '@angular/core'; -import { CoreUserDelegate, CoreUserProfileHandler, CoreUserProfileHandlerData } from '../user-delegate'; +import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '../user-delegate'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreUserProfile } from '../user'; +import { makeSingleton } from '@singletons'; /** * Handler to send a email to a user. */ @Injectable({ providedIn: 'root' }) -export class CoreUserProfileMailHandler implements CoreUserProfileHandler { +export class CoreUserProfileMailHandlerService implements CoreUserProfileHandler { name = 'CoreUserProfileMail'; priority = 700; - type = CoreUserDelegate.TYPE_COMMUNICATION; + type = CoreUserDelegateService.TYPE_COMMUNICATION; /** * Check if handler is enabled. @@ -73,3 +74,5 @@ export class CoreUserProfileMailHandler implements CoreUserProfileHandler { } } + +export class CoreUserProfileMailHandler extends makeSingleton(CoreUserProfileMailHandlerService) {} diff --git a/src/core/features/user/services/handlers/sync-cron.ts b/src/core/features/user/services/handlers/sync-cron.ts index 96a99cc17..b188d8d59 100644 --- a/src/core/features/user/services/handlers/sync-cron.ts +++ b/src/core/features/user/services/handlers/sync-cron.ts @@ -15,13 +15,14 @@ import { Injectable } from '@angular/core'; import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; import { CoreUserSync } from '../user-sync'; /** * Synchronization cron handler. */ @Injectable({ providedIn: 'root' }) -export class CoreUserSyncCronHandler implements CoreCronHandler { +export class CoreUserSyncCronHandlerService implements CoreCronHandler { name = 'CoreUserSyncCronHandler'; @@ -48,3 +49,5 @@ export class CoreUserSyncCronHandler implements CoreCronHandler { } } + +export class CoreUserSyncCronHandler extends makeSingleton(CoreUserSyncCronHandlerService) {} diff --git a/src/core/features/user/services/user-delegate.ts b/src/core/features/user/services/user-delegate.ts index 0c11f480c..08b3f799f 100644 --- a/src/core/features/user/services/user-delegate.ts +++ b/src/core/features/user/services/user-delegate.ts @@ -19,6 +19,7 @@ import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { CoreUtils } from '@services/utils/utils'; import { CoreEvents } from '@singletons/events'; import { CoreUserProfile } from './user'; +import { makeSingleton } from '@singletons'; /** * Interface that all user profile handlers must implement. @@ -127,10 +128,8 @@ export interface CoreUserProfileHandlerToDisplay { * Service to interact with plugins to be shown in user profile. Provides functions to register a plugin * and notify an update in the data. */ -@Injectable({ - providedIn: 'root', -}) -export class CoreUserDelegate extends CoreDelegate { +@Injectable({ providedIn: 'root' }) +export class CoreUserDelegateService extends CoreDelegate { /** * User profile handler type for communication. @@ -164,7 +163,7 @@ export class CoreUserDelegate extends CoreDelegate { constructor() { super('CoreUserDelegate', true); - CoreEvents.on(CoreUserDelegate.UPDATE_HANDLER_EVENT, (data) => { + CoreEvents.on(CoreUserDelegateService.UPDATE_HANDLER_EVENT, (data) => { if (!data || !data.handler || !this.userHandlers[data.userId]) { return; } @@ -255,7 +254,7 @@ export class CoreUserDelegate extends CoreDelegate { name: name, data: handler.getDisplayData(user, courseId), priority: handler.priority || 0, - type: handler.type || CoreUserDelegate.TYPE_NEW_PAGE, + type: handler.type || CoreUserDelegateService.TYPE_NEW_PAGE, }); } } catch { @@ -271,6 +270,8 @@ export class CoreUserDelegate extends CoreDelegate { } +export class CoreUserDelegate extends makeSingleton(CoreUserDelegateService) {} + /** * Data passed to UPDATE_HANDLER_EVENT event. */ diff --git a/src/core/features/user/services/user-helper.ts b/src/core/features/user/services/user-helper.ts index ffd636c6b..3b6bf0a39 100644 --- a/src/core/features/user/services/user-helper.ts +++ b/src/core/features/user/services/user-helper.ts @@ -20,9 +20,7 @@ import { CoreUserRole } from './user'; /** * Service that provides some features regarding users information. */ -@Injectable({ - providedIn: 'root', -}) +@Injectable({ providedIn: 'root' }) export class CoreUserHelperProvider { /** diff --git a/src/core/features/user/services/user-offline.ts b/src/core/features/user/services/user-offline.ts index 5afd27b7c..ebe3c8e3e 100644 --- a/src/core/features/user/services/user-offline.ts +++ b/src/core/features/user/services/user-offline.ts @@ -16,14 +16,12 @@ import { Injectable } from '@angular/core'; import { CoreSites } from '@services/sites'; import { makeSingleton } from '@singletons'; -import { PREFERENCES_TABLE_NAME, CoreUserPreferenceDBRecord } from './db/user'; +import { PREFERENCES_TABLE_NAME, CoreUserPreferenceDBRecord } from './database/user'; /** * Service to handle offline user preferences. */ -@Injectable({ - providedIn: 'root', -}) +@Injectable({ providedIn: 'root' }) export class CoreUserOfflineProvider { /** diff --git a/src/core/features/user/services/user-profile-field-delegate.ts b/src/core/features/user/services/user-profile-field-delegate.ts index 3f1ec6436..833686c4f 100644 --- a/src/core/features/user/services/user-profile-field-delegate.ts +++ b/src/core/features/user/services/user-profile-field-delegate.ts @@ -17,6 +17,7 @@ import { Injectable, Type } from '@angular/core'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { CoreError } from '@classes/errors/error'; import { AuthEmailSignupProfileField } from '@features/login/services/login-helper'; +import { makeSingleton } from '@singletons'; import { CoreUserProfileField } from './user'; /** @@ -73,10 +74,8 @@ export interface CoreUserProfileFieldHandlerData { /** * Service to interact with user profile fields. */ -@Injectable({ - providedIn: 'root', -}) -export class CoreUserProfileFieldDelegate extends CoreDelegate { +@Injectable({ providedIn: 'root' }) +export class CoreUserProfileFieldDelegateService extends CoreDelegate { protected handlerNameProperty = 'type'; @@ -206,3 +205,5 @@ export class CoreUserProfileFieldDelegate extends CoreDelegate () => { - // @todo: Register sync handler when init process has been fixed. - userDelegate.registerHandler(mailHandler); - linksDelegate.registerHandler(profileLinkHandler); + deps: [], + useFactory: () => () => { + CoreUserDelegate.instance.registerHandler(CoreUserProfileMailHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(CoreUserProfileLinkHandler.instance); + CoreCronDelegate.instance.register(CoreUserSyncCronHandler.instance); }, }, ], From 90bd583147dc02c7ba2aad2925ee3bd15c08f13d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 10 Dec 2020 13:28:37 +0100 Subject: [PATCH 13/19] MOBILE-3592 core: Uncomment user-avatar and dynamic-component --- .../courses/pages/course-preview/course-preview.html | 12 ++++-------- .../features/tag/pages/index-area/index-area.html | 4 ++-- src/core/features/user/pages/profile/profile.html | 3 +-- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/core/features/courses/pages/course-preview/course-preview.html b/src/core/features/courses/pages/course-preview/course-preview.html index 1424ea2a5..0baa7286b 100644 --- a/src/core/features/courses/pages/course-preview/course-preview.html +++ b/src/core/features/courses/pages/course-preview/course-preview.html @@ -43,19 +43,15 @@

{{ 'core.teachers' | translate }}

- +
diff --git a/src/core/features/tag/pages/index-area/index-area.html b/src/core/features/tag/pages/index-area/index-area.html index b08faa747..ca219d19d 100644 --- a/src/core/features/tag/pages/index-area/index-area.html +++ b/src/core/features/tag/pages/index-area/index-area.html @@ -13,9 +13,9 @@ - + diff --git a/src/core/features/user/pages/profile/profile.html b/src/core/features/user/pages/profile/profile.html index 332e7cdbf..d7f1c466b 100644 --- a/src/core/features/user/pages/profile/profile.html +++ b/src/core/features/user/pages/profile/profile.html @@ -13,8 +13,7 @@