From db569fe2c3249f5331793208722763ee4d7db332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillermo=20Vay=C3=A1?= Date: Tue, 27 May 2025 16:24:40 +0200 Subject: [PATCH] [MM-62565] Custom Profile attributes websocket changes (#8758) * user_info websocket listening * add to edit profile * add tests * fix user_info test * improved types * add websocket tests * fix tests to adapt to the database * temp * fix test, remove unneeded file * Update app/actions/remote/custom_profile.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix test and storing fields and attributes * consistency * remove await from loop * remove db from component * simplify functions * address reviews * adress review comments * fix promises not being fulfilled before ordering * fix tests * address comments * address review comments * test_helper --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/actions/remote/custom_profile.test.ts | 176 +++++++++ app/actions/remote/custom_profile.ts | 144 +++++++ app/actions/remote/user.test.ts | 150 -------- app/actions/websocket/event.ts | 14 +- app/actions/websocket/users.test.ts | 270 +++++++++++++- app/actions/websocket/users.ts | 82 ++++ app/constants/websocket.ts | 4 + .../handlers/custom_profile.test.ts | 67 ++++ .../handlers/custom_profile.ts | 10 +- app/queries/servers/custom_profile.test.ts | 229 ------------ app/queries/servers/custom_profile.ts | 61 +-- .../edit_profile/edit_profile.test.tsx | 352 ++++++++++++++++-- app/screens/edit_profile/edit_profile.tsx | 26 +- app/screens/edit_profile/index.ts | 31 +- app/screens/user_profile/index.ts | 24 +- app/screens/user_profile/user_info.test.tsx | 324 +++++++++------- app/screens/user_profile/user_info.tsx | 35 +- app/screens/user_profile/user_profile.tsx | 4 + app/utils/user/index.test.ts | 201 ++++++++++ app/utils/user/index.ts | 54 ++- test/test_helper.ts | 31 ++ types/screens/edit_profile.ts | 4 + 22 files changed, 1668 insertions(+), 625 deletions(-) create mode 100644 app/actions/remote/custom_profile.test.ts create mode 100644 app/actions/remote/custom_profile.ts diff --git a/app/actions/remote/custom_profile.test.ts b/app/actions/remote/custom_profile.test.ts new file mode 100644 index 000000000..80c957bc6 --- /dev/null +++ b/app/actions/remote/custom_profile.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetworkManager from '@managers/network_manager'; + +import { + fetchCustomProfileAttributes, + updateCustomProfileAttributes, +} from './custom_profile'; + +jest.mock('@database/manager', () => ({ + getServerDatabaseAndOperator: jest.fn().mockReturnValue({ + operator: { + batchRecords: jest.fn(), + handleCustomProfileFields: jest.fn(), + handleCustomProfileAttributes: jest.fn(), + }, + }), +})); + +beforeAll(() => { + // @ts-ignore + NetworkManager.getClient = () => mockClient; +}); + +const mockClient = { + getCustomProfileAttributeFields: jest.fn(), + getCustomProfileAttributeValues: jest.fn(), + updateCustomProfileAttributeValues: jest.fn(), +}; +const serverUrl = 'baseHandler.test.com'; + +describe('Custom Profile Attributes', () => { + it('fetchCustomProfileAttributes - base case without filter', async () => { + mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([ + {id: 'field1', name: 'Field 1', attrs: {sort_order: 1}}, + {id: 'field2', name: 'Field 2', attrs: {sort_order: 2}}, + {id: 'field3', name: 'Field 3', attrs: {sort_order: 3}}, + ]); + mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({ + field1: 'value1', + field2: '', + field3: 'value3', + }); + + const result = await fetchCustomProfileAttributes(serverUrl, 'user1'); + expect(result.error).toBeUndefined(); + expect(result.attributes).toBeDefined(); + expect(Object.keys(result.attributes!)).toHaveLength(3); + expect(result.attributes!.field1).toEqual({ + id: 'field1', + name: 'Field 1', + value: 'value1', + sort_order: 1, + }); + expect(result.attributes!.field2).toEqual({ + id: 'field2', + name: 'Field 2', + value: '', + sort_order: 2, + }); + expect(result.attributes!.field3).toEqual({ + id: 'field3', + name: 'Field 3', + value: 'value3', + sort_order: 3, + }); + }); + + it('fetchCustomProfileAttributes - with filter empty values', async () => { + mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([ + {id: 'field1', name: 'Field 1'}, + {id: 'field2', name: 'Field 2'}, + {id: 'field3', name: 'Field 3'}, + ]); + mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({ + field1: 'value1', + field2: '', + field3: 'value3', + }); + + const result = await fetchCustomProfileAttributes(serverUrl, 'user1', true); + expect(result.error).toBeUndefined(); + expect(result.attributes).toBeDefined(); + expect(Object.keys(result.attributes!)).toHaveLength(2); + expect(result.attributes!.field1).toEqual({ + id: 'field1', + name: 'Field 1', + value: 'value1', + }); + expect(result.attributes!.field2).toBeUndefined(); + expect(result.attributes!.field3).toEqual({ + id: 'field3', + name: 'Field 3', + value: 'value3', + }); + }); + + it('fetchCustomProfileAttributes - no fields', async () => { + mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([]); + mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({}); + + const result = await fetchCustomProfileAttributes(serverUrl, 'user1'); + expect(result.error).toBeUndefined(); + expect(result.attributes).toEqual({}); + }); + + it('fetchCustomProfileAttributes - error on fields', async () => { + const error = new Error('Sample error'); + + mockClient.getCustomProfileAttributeFields = jest.fn().mockRejectedValue(error); + mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({ + field1: 'value1', + field2: 'value2', + }); + + const result = await fetchCustomProfileAttributes(serverUrl, 'user1'); + expect(result.error).toBeDefined(); + }); + + it('fetchCustomProfileAttributes - error on values', async () => { + const error = new Error('Sample error'); + + mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([ + {id: 'field1', name: 'Field 1'}, + {id: 'field2', name: 'Field 2'}, + ]); + mockClient.getCustomProfileAttributeValues = jest.fn().mockRejectedValue(error); + + const result = await fetchCustomProfileAttributes(serverUrl, 'user1'); + expect(result.error).toBeDefined(); + }); + + it('updateCustomProfileAttributes - base case', async () => { + mockClient.updateCustomProfileAttributeValues = jest.fn().mockResolvedValue({}); + + const attributes = { + field1: { + id: 'field1', + name: 'Field 1', + value: 'new value 1', + }, + field2: { + id: 'field2', + name: 'Field 2', + value: 'new value 2', + }, + }; + + const result = await updateCustomProfileAttributes(serverUrl, 'user1', attributes); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(mockClient.updateCustomProfileAttributeValues).toHaveBeenCalledWith({ + field1: 'new value 1', + field2: 'new value 2', + }); + }); + + it('updateCustomProfileAttributes - error', async () => { + const error = new Error('Test Error'); + + mockClient.updateCustomProfileAttributeValues = jest.fn().mockRejectedValue(error); + + const attributes = { + field1: { + id: 'field1', + name: 'Field 1', + value: 'new value 1', + }, + }; + + const result = await updateCustomProfileAttributes(serverUrl, 'user1', attributes); + expect(result.error).toBeDefined(); + expect(result.success).toBe(false); + }); +}); diff --git a/app/actions/remote/custom_profile.ts b/app/actions/remote/custom_profile.ts new file mode 100644 index 000000000..c56589784 --- /dev/null +++ b/app/actions/remote/custom_profile.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import {customProfileAttributeId} from '@utils/custom_profile_attribute'; +import {getFullErrorMessage} from '@utils/errors'; +import {logDebug} from '@utils/log'; + +import type Model from '@nozbe/watermelondb/Model'; +import type {CustomProfileField, CustomAttribute, CustomAttributeSet, CustomProfileAttribute, UserCustomProfileAttributeSimple} from '@typings/api/custom_profile_attributes'; + +/** + * Fetches custom profile attribute values for a user + * @param serverUrl - The server URL + * @param userId - The user ID + * @param filterEmpty - Whether to filter out empty values + * @returns Promise with the custom profile attributes or error + */ +export const fetchCustomProfileAttributes = async (serverUrl: string, userId: string, filterEmpty = false): Promise<{attributes: CustomAttributeSet; error: unknown}> => { + const attributes: Record = {}; + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + let fields: CustomProfileField[] = []; + let attrValues: Record = {}; + + try { + [fields, attrValues] = await Promise.all([ + client.getCustomProfileAttributeFields(), + client.getCustomProfileAttributeValues(userId), + ]); + } catch (err) { + logDebug('error on fetchCustomProfileAttributes get fields and attr values', getFullErrorMessage(err)); + return {attributes, error: err}; + } + + if (fields.length === 0) { + return {attributes, error: undefined}; + } + + try { + // Process each field to build attributes and collect promises + const attributeModelPromises: Array> = []; + const fieldModelPromises: Array> = []; + for (const field of fields) { + const value = attrValues[field.id] || ''; + if (!filterEmpty || value) { + attributes[field.id] = { + id: field.id, + name: field.name, + value, + sort_order: field.attrs?.sort_order, + }; + + attributeModelPromises.push(operator.handleCustomProfileAttributes({ + attributes: [{ + id: customProfileAttributeId(field.id, userId), + field_id: field.id, + user_id: userId, + value, + }], + prepareRecordsOnly: true, + })); + fieldModelPromises.push(operator.handleCustomProfileFields({ + fields: [field], + prepareRecordsOnly: true, + })); + } + } + + const [fieldResults, attributeResults] = await Promise.all([ + Promise.all(fieldModelPromises), + Promise.all(attributeModelPromises), + ]); + const fieldBatch = fieldResults.flat(); + const attributeBatch = attributeResults.flat(); + + if (fieldBatch.length > 0) { + await operator.batchRecords(fieldBatch, 'updateCustomProfileFields'); + } + + if (attributeBatch.length > 0) { + await operator.batchRecords(attributeBatch, 'updateCustomProfileAttributes'); + } + + } catch (err) { + logDebug('error on fetchCustomProfileAttributes field iteration', getFullErrorMessage(err)); + return {attributes, error: err}; + } + + return {attributes, error: undefined}; + } catch (error) { + logDebug('error on fetchCustomProfileAttributes', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {attributes, error}; + } +}; + +/** + * Updates custom profile attributes for the current user + * @param serverUrl - The server URL + * @param userId - The user ID + * @param attributes - The attributes to update + * @returns Promise with success status or error + */ +export const updateCustomProfileAttributes = async (serverUrl: string, userId: string, attributes: CustomAttributeSet): Promise<{success: boolean; error: unknown}> => { + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const attributeValues: UserCustomProfileAttributeSimple = {}; + + // Convert attributes to the format expected by the API + Object.entries(attributes).forEach(([id, attr]) => { + attributeValues[id] = attr.value; + }); + + // Update on the server + await client.updateCustomProfileAttributeValues(attributeValues); + + // Store in the database + const attributesForDatabase: CustomProfileAttribute[] = Object.entries(attributes).map(([fieldId, attr]) => ({ + id: customProfileAttributeId(fieldId, userId), + field_id: fieldId, + user_id: userId, + value: attr.value, + })); + + if (attributesForDatabase.length > 0) { + await operator.handleCustomProfileAttributes({ + attributes: attributesForDatabase, + prepareRecordsOnly: false, + }); + } + + return {success: true, error: undefined}; + } catch (error) { + logDebug('error on updateCustomProfileAttributes', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {success: false, error}; + } +}; diff --git a/app/actions/remote/user.test.ts b/app/actions/remote/user.test.ts index d7209cda6..8664f6a75 100644 --- a/app/actions/remote/user.test.ts +++ b/app/actions/remote/user.test.ts @@ -1,8 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -/* eslint-disable max-lines */ - import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; @@ -37,8 +35,6 @@ import { autoUpdateTimezone, fetchTeamAndChannelMembership, getAllSupportedTimezones, - fetchCustomAttributes, - updateCustomAttributes, } from './user'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -98,7 +94,6 @@ const mockClient = { }; beforeAll(() => { - // eslint-disable-next-line // @ts-ignore NetworkManager.getClient = () => mockClient; }); @@ -363,151 +358,6 @@ describe('get users', () => { }); }); -describe('Custom Profile Attributes', () => { - it('fetchCustomAttributes - base case without filter', async () => { - mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([ - {id: 'field1', name: 'Field 1', attrs: {sort_order: 1}}, - {id: 'field2', name: 'Field 2', attrs: {sort_order: 2}}, - {id: 'field3', name: 'Field 3', attrs: {sort_order: 3}}, - ]); - mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({ - field1: 'value1', - field2: '', - field3: 'value3', - }); - - const result = await fetchCustomAttributes(serverUrl, 'user1'); - expect(result.error).toBeUndefined(); - expect(result.attributes).toBeDefined(); - expect(Object.keys(result.attributes!)).toHaveLength(3); - expect(result.attributes!.field1).toEqual({ - id: 'field1', - name: 'Field 1', - value: 'value1', - sort_order: 1, - }); - expect(result.attributes!.field2).toEqual({ - id: 'field2', - name: 'Field 2', - value: '', - sort_order: 2, - }); - expect(result.attributes!.field3).toEqual({ - id: 'field3', - name: 'Field 3', - value: 'value3', - sort_order: 3, - }); - }); - - it('fetchCustomAttributes - with filter empty values', async () => { - mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([ - {id: 'field1', name: 'Field 1'}, - {id: 'field2', name: 'Field 2'}, - {id: 'field3', name: 'Field 3'}, - ]); - mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({ - field1: 'value1', - field2: '', - field3: 'value3', - }); - - const result = await fetchCustomAttributes(serverUrl, 'user1', true); - expect(result.error).toBeUndefined(); - expect(result.attributes).toBeDefined(); - expect(Object.keys(result.attributes!)).toHaveLength(2); - expect(result.attributes!.field1).toEqual({ - id: 'field1', - name: 'Field 1', - value: 'value1', - }); - expect(result.attributes!.field2).toBeUndefined(); - expect(result.attributes!.field3).toEqual({ - id: 'field3', - name: 'Field 3', - value: 'value3', - }); - }); - - it('fetchCustomAttributes - no fields', async () => { - mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([]); - mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({}); - - const result = await fetchCustomAttributes(serverUrl, 'user1'); - expect(result.error).toBeUndefined(); - expect(result.attributes).toEqual({}); - }); - - it('fetchCustomAttributes - error on fields', async () => { - const error = new Error('Sample error'); - - mockClient.getCustomProfileAttributeFields = jest.fn().mockRejectedValue(error); - mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({ - field1: 'value1', - field2: 'value2', - }); - - const result = await fetchCustomAttributes(serverUrl, 'user1'); - expect(result.error).toBeDefined(); - }); - - it('fetchCustomAttributes - error on values', async () => { - const error = new Error('Sample error'); - - mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([ - {id: 'field1', name: 'Field 1'}, - {id: 'field2', name: 'Field 2'}, - ]); - mockClient.getCustomProfileAttributeValues = jest.fn().mockRejectedValue(error); - - const result = await fetchCustomAttributes(serverUrl, 'user1'); - expect(result.error).toBeDefined(); - }); - - it('updateCustomAttributes - base case', async () => { - mockClient.updateCustomProfileAttributeValues = jest.fn().mockResolvedValue({}); - - const attributes = { - field1: { - id: 'field1', - name: 'Field 1', - value: 'new value 1', - }, - field2: { - id: 'field2', - name: 'Field 2', - value: 'new value 2', - }, - }; - - const result = await updateCustomAttributes(serverUrl, attributes); - expect(result.error).toBeUndefined(); - expect(result.success).toBe(true); - expect(mockClient.updateCustomProfileAttributeValues).toHaveBeenCalledWith({ - field1: 'new value 1', - field2: 'new value 2', - }); - }); - - it('updateCustomAttributes - error', async () => { - const error = new Error('Test Error'); - - mockClient.updateCustomProfileAttributeValues = jest.fn().mockRejectedValue(error); - - const attributes = { - field1: { - id: 'field1', - name: 'Field 1', - value: 'new value 1', - }, - }; - - const result = await updateCustomAttributes(serverUrl, attributes); - expect(result.error).toBeDefined(); - expect(result.success).toBe(false); - }); -}); - describe('update users', () => { it('updateMe - handle not found database', async () => { const result = await updateMe('foo', {}); diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index 19665e690..49a78f20f 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -17,7 +17,7 @@ import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRole import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; import * as teams from './teams'; import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; -import {handleUserUpdatedEvent, handleUserTypingEvent, handleStatusChangedEvent} from './users'; +import {handleUserUpdatedEvent, handleUserTypingEvent, handleStatusChangedEvent, handleCustomProfileAttributesValuesUpdatedEvent, handleCustomProfileAttributesFieldUpdatedEvent, handleCustomProfileAttributesFieldDeletedEvent} from './users'; export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMessage) { switch (msg.event) { @@ -288,5 +288,17 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess case WebsocketEvents.SCHEDULED_POST_DELETED: scheduledPost.handleDeleteScheduledPost(serverUrl, msg); break; + case WebsocketEvents.CUSTOM_PROFILE_ATTRIBUTES_VALUES_UPDATED: + handleCustomProfileAttributesValuesUpdatedEvent(serverUrl, msg); + break; + + case WebsocketEvents.CUSTOM_PROFILE_ATTRIBUTES_FIELD_UPDATED: + case WebsocketEvents.CUSTOM_PROFILE_ATTRIBUTES_FIELD_CREATED: + handleCustomProfileAttributesFieldUpdatedEvent(serverUrl, msg); + break; + + case WebsocketEvents.CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED: + handleCustomProfileAttributesFieldDeletedEvent(serverUrl, msg); + break; } } diff --git a/app/actions/websocket/users.test.ts b/app/actions/websocket/users.test.ts index 4dc39858d..0a60e0479 100644 --- a/app/actions/websocket/users.test.ts +++ b/app/actions/websocket/users.test.ts @@ -10,12 +10,22 @@ import {Events} from '@constants'; import DatabaseManager from '@database/manager'; import WebsocketManager from '@managers/websocket_manager'; import {queryChannelsByTypes, queryUserChannelsByTypes} from '@queries/servers/channel'; +import {deleteCustomProfileAttributesByFieldId} from '@queries/servers/custom_profile'; import {queryDisplayNamePreferences} from '@queries/servers/preference'; import {getConfig, getLicense} from '@queries/servers/system'; import {getCurrentUser} from '@queries/servers/user'; import TestHelper from '@test/test_helper'; +import * as logUtils from '@utils/log'; -import {handleUserUpdatedEvent, handleUserTypingEvent, handleStatusChangedEvent, userTyping} from './users'; +import { + handleUserUpdatedEvent, + handleUserTypingEvent, + handleStatusChangedEvent, + userTyping, + handleCustomProfileAttributesValuesUpdatedEvent, + handleCustomProfileAttributesFieldUpdatedEvent, + handleCustomProfileAttributesFieldDeletedEvent, +} from './users'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -26,6 +36,7 @@ jest.mock('@database/manager'); jest.mock('@helpers/api/preference'); jest.mock('@managers/websocket_manager'); jest.mock('@queries/servers/channel'); +jest.mock('@queries/servers/custom_profile'); jest.mock('@queries/servers/preference'); jest.mock('@queries/servers/system'); jest.mock('@queries/servers/user'); @@ -206,13 +217,22 @@ describe('WebSocket Users Actions', () => { jest.mocked(getConfig).mockResolvedValue(mockConfig as any); jest.mocked(fetchUsersByIds).mockResolvedValue({ - users: [TestHelper.fakeUser({id: otherUserId, username: 'other-user'})], + users: [ + TestHelper.fakeUser({ + id: otherUserId, + username: 'other-user', + }), + ], existingUsers: [], }); - jest.mocked(queryDisplayNamePreferences).mockReturnValue(TestHelper.fakeQuery([TestHelper.fakePreferenceModel({ - value: 'full_name', - })])); + jest.mocked(queryDisplayNamePreferences).mockReturnValue( + TestHelper.fakeQuery([ + TestHelper.fakePreferenceModel({ + value: 'full_name', + }), + ]), + ); jest.mocked(getLicense).mockResolvedValue({} as ClientLicense); @@ -272,4 +292,244 @@ describe('WebSocket Users Actions', () => { jest.useRealTimers(); }); }); + + describe('handleCustomProfileAttributesValuesUpdatedEvent', () => { + it('should handle missing operator', async () => { + jest.spyOn(logUtils, 'logError').mockImplementation(() => {}); + DatabaseManager.serverDatabases = {}; + const msg = { + data: { + user_id: 'user1', + values: {field1: 'value1'}, + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesValuesUpdatedEvent( + serverUrl, + msg, + ); + + expect(logUtils.logError).toHaveBeenCalled(); + }); + + it('should handle custom profile attributes values update', async () => { + const mockHandleCustomProfileAttributes = jest. + fn(). + mockResolvedValue([]); + operator.handleCustomProfileAttributes = + mockHandleCustomProfileAttributes; + + const msg = { + data: { + user_id: 'user1', + values: { + field1: 'value1', + field2: 'value2', + }, + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesValuesUpdatedEvent( + serverUrl, + msg, + ); + + expect(mockHandleCustomProfileAttributes).toHaveBeenCalledWith({ + attributes: [ + { + id: 'field1-user1', + field_id: 'field1', + user_id: 'user1', + value: 'value1', + }, + { + id: 'field2-user1', + field_id: 'field2', + user_id: 'user1', + value: 'value2', + }, + ], + prepareRecordsOnly: false, + }); + }); + + it('should handle errors during attributes update', async () => { + jest.spyOn(logUtils, 'logError').mockImplementation(() => {}); + operator.handleCustomProfileAttributes = jest. + fn(). + mockRejectedValue(new Error('test error')); + + const msg = { + data: { + user_id: 'user1', + values: {field1: 'value1'}, + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesValuesUpdatedEvent( + serverUrl, + msg, + ); + + expect(logUtils.logError).toHaveBeenCalled(); + }); + }); + + describe('handleCustomProfileAttributesFieldUpdatedEvent', () => { + it('should handle missing operator', async () => { + jest.spyOn(logUtils, 'logError').mockImplementation(() => {}); + DatabaseManager.serverDatabases = {}; + const msg = { + data: { + field: {id: 'field1'}, + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesFieldUpdatedEvent( + serverUrl, + msg, + ); + + expect(logUtils.logError).toHaveBeenCalled(); + }); + + it('should handle custom profile field update', async () => { + const mockHandleCustomProfileFields = jest. + fn(). + mockResolvedValue([]); + operator.handleCustomProfileFields = mockHandleCustomProfileFields; + + const mockField = { + id: 'field1', + group_id: 'group1', + name: 'Field 1', + type: 'text', + target_id: 'target1', + target_type: 'user', + create_at: 1000, + update_at: 2000, + delete_at: 0, + attrs: {required: true}, + }; + + const msg = { + data: { + field: mockField, + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesFieldUpdatedEvent( + serverUrl, + msg, + ); + + expect(mockHandleCustomProfileFields).toHaveBeenCalledWith({ + fields: [mockField], + prepareRecordsOnly: false, + }); + }); + + it('should handle errors during field update', async () => { + jest.spyOn(logUtils, 'logError').mockImplementation(() => {}); + operator.handleCustomProfileFields = jest. + fn(). + mockRejectedValue(new Error('test error')); + + const msg = { + data: { + field: {id: 'field1'}, + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesFieldUpdatedEvent( + serverUrl, + msg, + ); + + expect(logUtils.logError).toHaveBeenCalled(); + }); + }); + + describe('handleCustomProfileAttributesFieldDeletedEvent', () => { + it('should handle missing operator', async () => { + jest.spyOn(logUtils, 'logError').mockImplementation(() => {}); + DatabaseManager.serverDatabases = {}; + const msg = { + data: { + field_id: 'field1', + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesFieldDeletedEvent( + serverUrl, + msg, + ); + + expect(logUtils.logError).toHaveBeenCalled(); + }); + + it('should handle custom profile field deletion', async () => { + + // Add explicit mock setup for deleteCustomProfileAttributesByFieldId + jest.mocked(deleteCustomProfileAttributesByFieldId).mockResolvedValue(); + + const mockDate = 1635812400000; // Nov 1, 2021 + jest.spyOn(Date, 'now').mockReturnValue(mockDate); + + const msg = { + data: { + field_id: 'field1', + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesFieldDeletedEvent( + serverUrl, + msg, + ); + + expect(deleteCustomProfileAttributesByFieldId).toHaveBeenCalledWith( + operator.database, + 'field1', + ); + }); + + it('should handle errors during field model deletion', async () => { + jest.spyOn(logUtils, 'logError').mockImplementation(() => {}); + operator.handleCustomProfileFields = jest.fn().mockRejectedValue(new Error('test error')); + + jest.mocked(deleteCustomProfileAttributesByFieldId).mockResolvedValue(); + + const msg = { + data: { + field_id: 'field1', + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesFieldDeletedEvent(serverUrl, msg); + + expect(logUtils.logError).toHaveBeenCalled(); + + expect(deleteCustomProfileAttributesByFieldId).not.toHaveBeenCalled(); + }); + + it('should handle errors during attributes deletion', async () => { + jest.spyOn(logUtils, 'logError').mockImplementation(() => {}); + + jest.mocked(deleteCustomProfileAttributesByFieldId).mockRejectedValue(new Error('test error')); + + const msg = { + data: { + field_id: 'field1', + }, + } as WebSocketMessage; + + await handleCustomProfileAttributesFieldDeletedEvent( + serverUrl, + msg, + ); + + expect(logUtils.logError).toHaveBeenCalled(); + expect(deleteCustomProfileAttributesByFieldId).toHaveBeenCalled(); + }); + }); }); diff --git a/app/actions/websocket/users.ts b/app/actions/websocket/users.ts index fa86bf38c..32b8239eb 100644 --- a/app/actions/websocket/users.ts +++ b/app/actions/websocket/users.ts @@ -14,9 +14,12 @@ import {queryChannelsByTypes, queryUserChannelsByTypes} from '@queries/servers/c import {queryDisplayNamePreferences} from '@queries/servers/preference'; import {getConfig, getLicense} from '@queries/servers/system'; import {getCurrentUser} from '@queries/servers/user'; +import {customProfileAttributeId} from '@utils/custom_profile_attribute'; +import {logError} from '@utils/log'; import {displayUsername} from '@utils/user'; import type {Model} from '@nozbe/watermelondb'; +import type {CustomProfileField} from '@typings/api/custom_profile_attributes'; export async function handleUserUpdatedEvent(serverUrl: string, msg: WebSocketMessage) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -120,3 +123,82 @@ export async function handleStatusChangedEvent(serverUrl: string, msg: WebSocket const newStatus = msg.data.status; setCurrentUserStatus(serverUrl, newStatus); } + +export async function handleCustomProfileAttributesValuesUpdatedEvent(serverUrl: string, msg: WebSocketMessage) { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const {user_id, values} = msg.data; + const attributesForDatabase = Object.entries(values).map(([fieldId, value]) => ({ + id: customProfileAttributeId(fieldId, user_id), + field_id: fieldId, + user_id, + value: value as string, + })); + + try { + await operator.handleCustomProfileAttributes({ + attributes: attributesForDatabase, + prepareRecordsOnly: false, + }); + } catch (error) { + logError('Error handling custom profile attributes values updated event', error); + } + } catch (error) { + logError('Error getting the operator for the custom profile attributes values updated event', error); + } + +} + +export async function handleCustomProfileAttributesFieldUpdatedEvent(serverUrl: string, msg: WebSocketMessage) { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const {field} = msg.data; + + try { + await operator.handleCustomProfileFields({ + fields: [field], + prepareRecordsOnly: false, + }); + } catch (error) { + logError('Error handling custom profile attributes field updated event', error); + } + } catch (error) { + logError('Error getting the operator for the custom profile field updated event', error); + } + +} + +export async function handleCustomProfileAttributesFieldDeletedEvent(serverUrl: string, msg: WebSocketMessage) { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const {field_id} = msg.data; + + try { + // Delete the field from the database + const fieldForDatabase: CustomProfileField = { + id: field_id, + group_id: '', + name: '', + type: '', + target_id: '', + target_type: '', + create_at: 0, + update_at: 0, + delete_at: Date.now(), + attrs: {}, + }; + + await operator.handleCustomProfileFields({ + fields: [fieldForDatabase], + prepareRecordsOnly: false, + }); + } catch (error) { + logError('Error handling custom profile field deleted event', error); + } + } catch (error) { + logError('Error getting the operator for the custom profile field deleted event', error); + } +} diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 7a55eacbf..1fa27300e 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -101,5 +101,9 @@ const WebsocketEvents = { SCHEDULED_POST_CREATED: 'scheduled_post_created', SCHEDULED_POST_UPDATED: 'scheduled_post_updated', SCHEDULED_POST_DELETED: 'scheduled_post_deleted', + CUSTOM_PROFILE_ATTRIBUTES_VALUES_UPDATED: 'custom_profile_attributes_values_updated', + CUSTOM_PROFILE_ATTRIBUTES_FIELD_UPDATED: 'custom_profile_attributes_field_updated', + CUSTOM_PROFILE_ATTRIBUTES_FIELD_CREATED: 'custom_profile_attributes_field_created', + CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED: 'custom_profile_attributes_field_deleted', }; export default WebsocketEvents; diff --git a/app/database/operator/server_data_operator/handlers/custom_profile.test.ts b/app/database/operator/server_data_operator/handlers/custom_profile.test.ts index 452a207f6..0b28b1282 100644 --- a/app/database/operator/server_data_operator/handlers/custom_profile.test.ts +++ b/app/database/operator/server_data_operator/handlers/custom_profile.test.ts @@ -7,10 +7,17 @@ import { transformCustomProfileFieldRecord, transformCustomProfileAttributeRecord, } from '@database/operator/server_data_operator/transformers/custom_profile'; +import * as CustomProfileQueries from '@queries/servers/custom_profile'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type {CustomProfileField, CustomProfileAttribute} from '@typings/api/custom_profile_attributes'; +// Mock the deleteCustomProfileAttributesByFieldId function +jest.mock('@queries/servers/custom_profile', () => ({ + ...jest.requireActual('@queries/servers/custom_profile'), + deleteCustomProfileAttributesByFieldId: jest.fn().mockResolvedValue(undefined), +})); + describe('*** Operator: Custom Profile Handlers tests ***', () => { let operator: ServerDataOperator; const serverUrl = 'baseHandler.test.com'; @@ -19,6 +26,11 @@ describe('*** Operator: Custom Profile Handlers tests ***', () => { await DatabaseManager.init([serverUrl]); operator = DatabaseManager.serverDatabases[serverUrl]!.operator; }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('=> handleCustomProfileFields', () => { it('should write to CustomProfileField table', async () => { expect.assertions(2); @@ -54,6 +66,61 @@ describe('*** Operator: Custom Profile Handlers tests ***', () => { }, 'handleCustomProfileFields'); }); + it('should delete custom profile attributes for fields with delete_at !== 0', async () => { + expect.assertions(3); + + const spyOnDeleteAttributes = jest.spyOn(CustomProfileQueries, 'deleteCustomProfileAttributesByFieldId'); + + const fields: CustomProfileField[] = [ + { + id: 'field1', + name: 'Test Field 1', + type: 'text', + create_at: 1607683720173, + update_at: 1607683720173, + delete_at: 0, // Not deleted + group_id: 'group1', + target_id: 'target1', + target_type: 'user', + attrs: {}, + }, + { + id: 'field2', + name: 'Test Field 2', + type: 'text', + create_at: 1607683720173, + update_at: 1607683720173, + delete_at: 1607683820173, // Deleted field + group_id: 'group1', + target_id: 'target1', + target_type: 'user', + attrs: {}, + }, + { + id: 'field3', + name: 'Test Field 3', + type: 'text', + create_at: 1607683720173, + update_at: 1607683720173, + delete_at: 1607683920173, // Deleted field + group_id: 'group1', + target_id: 'target1', + target_type: 'user', + attrs: {}, + }, + ]; + + await operator.handleCustomProfileFields({ + fields, + prepareRecordsOnly: false, + }); + + // Verify that deleteCustomProfileAttributesByFieldId was called for the deleted fields + expect(spyOnDeleteAttributes).toHaveBeenCalledTimes(2); + expect(spyOnDeleteAttributes).toHaveBeenCalledWith(operator.database, 'field2'); + expect(spyOnDeleteAttributes).toHaveBeenCalledWith(operator.database, 'field3'); + }); + it('should properly pass prepareRecordsOnly when set to true', async () => { expect.assertions(2); diff --git a/app/database/operator/server_data_operator/handlers/custom_profile.ts b/app/database/operator/server_data_operator/handlers/custom_profile.ts index 7dd57f7d1..dad6ea46a 100644 --- a/app/database/operator/server_data_operator/handlers/custom_profile.ts +++ b/app/database/operator/server_data_operator/handlers/custom_profile.ts @@ -7,7 +7,8 @@ import { transformCustomProfileAttributeRecord, } from '@database/operator/server_data_operator/transformers/custom_profile'; import {getUniqueRawsBy} from '@database/operator/utils/general'; -import {logWarning} from '@utils/log'; +import {deleteCustomProfileAttributesByFieldId} from '@queries/servers/custom_profile'; +import {logError, logWarning} from '@utils/log'; import type ServerDataOperatorBase from '.'; import type Model from '@nozbe/watermelondb/Model'; @@ -22,7 +23,6 @@ export interface CustomProfileHandlerMix { handleCustomProfileFields: ({fields, prepareRecordsOnly}: HandleCustomProfileFieldsArgs) => Promise; handleCustomProfileAttributes: ({attributes, prepareRecordsOnly}: HandleCustomProfileAttributesArgs) => Promise; } - const CustomProfileHandler = >(superclass: TBase) => class extends superclass { /** * handleCustomProfileFields: Handler responsible for the Create/Update operations occurring on the CUSTOM_PROFILE_FIELD table from the 'Server' schema @@ -40,6 +40,12 @@ const CustomProfileHandler = > } const createOrUpdateRawValues = getUniqueRawsBy({raws: fields, key: 'id'}); + try { + const deletedFields = fields.filter((field) => field.delete_at !== 0).map((field) => deleteCustomProfileAttributesByFieldId(this.database, field.id)); + await Promise.all(deletedFields); + } catch (error) { + logError('Error deleting custom profile attributes by field id', error); + } return this.handleRecords({ fieldName: 'id', diff --git a/app/queries/servers/custom_profile.test.ts b/app/queries/servers/custom_profile.test.ts index cb0094ca8..806cd3f49 100644 --- a/app/queries/servers/custom_profile.test.ts +++ b/app/queries/servers/custom_profile.test.ts @@ -2,10 +2,7 @@ // See LICENSE.txt for license information. /* eslint-disable max-lines */ -import {random} from 'lodash'; - import DatabaseManager from '@database/manager'; -import {customProfileAttributeId} from '@utils/custom_profile_attribute'; import { getCustomProfileFieldById, @@ -13,7 +10,6 @@ import { getCustomProfileAttribute, observeCustomProfileAttribute, observeCustomProfileAttributesByUserId, - convertProfileAttributesToCustomAttributes, queryCustomProfileFields, queryCustomProfileAttributesByUserId, queryCustomProfileAttributesByFieldId, @@ -22,7 +18,6 @@ import { import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Database} from '@nozbe/watermelondb'; -import type {CustomAttribute} from '@typings/api/custom_profile_attributes'; describe('Custom Profile Queries', () => { const serverUrl = 'custom-profile.test.com'; @@ -285,165 +280,6 @@ describe('Custom Profile Queries', () => { }); }); - describe('convertProfileAttributesToCustomAttributes', () => { - it('should convert profile attributes to custom attributes', async () => { - const fieldId1 = 'field1'; - const fieldId2 = 'field2'; - const userId = 'user1'; - - await operator.handleCustomProfileFields({ - fields: [ - { - id: fieldId1, - name: 'Test Field 1', - type: 'text', - delete_at: 0, - group_id: '', - target_id: '', - target_type: 'user', - create_at: 1000, - update_at: 1000, - attrs: {sort_order: 1}, - }, - { - id: fieldId2, - name: 'Test Field 2', - type: 'select', - delete_at: 0, - group_id: '', - target_id: '', - target_type: 'user', - create_at: 1000, - update_at: 1000, - attrs: {sort_order: 0}, - }, - ], - prepareRecordsOnly: false, - }); - - await operator.handleCustomProfileAttributes({ - attributes: [ - { - id: `${fieldId1}_${userId}`, - field_id: fieldId1, - user_id: userId, - value: 'Value 1', - }, - { - id: `${fieldId2}_${userId}`, - field_id: fieldId2, - user_id: userId, - value: 'Value 2', - }, - ], - prepareRecordsOnly: false, - }); - - const attributes = await queryCustomProfileAttributesByUserId(database, userId).fetch(); - const customAttributes = await convertProfileAttributesToCustomAttributes(database, attributes); - - expect(customAttributes.length).toBe(2); - if (customAttributes[0].id === fieldId1) { - expect(customAttributes[0].id).toBe(fieldId1); - expect(customAttributes[0].name).toBe('Test Field 1'); - expect(customAttributes[0].value).toBe('Value 1'); - expect(customAttributes[0].sort_order).toBe(1); - - expect(customAttributes[1].id).toBe(fieldId2); - expect(customAttributes[1].name).toBe('Test Field 2'); - expect(customAttributes[1].value).toBe('Value 2'); - expect(customAttributes[1].sort_order).toBe(0); - } else { - expect(customAttributes[0].id).toBe(fieldId2); - expect(customAttributes[0].name).toBe('Test Field 2'); - expect(customAttributes[0].value).toBe('Value 2'); - expect(customAttributes[0].sort_order).toBe(0); - - expect(customAttributes[1].id).toBe(fieldId1); - expect(customAttributes[1].name).toBe('Test Field 1'); - expect(customAttributes[1].value).toBe('Value 1'); - expect(customAttributes[1].sort_order).toBe(1); - } - }); - - it('should sort custom attributes by custom sort function', async () => { - const fieldId1 = 'field1'; - const fieldId2 = 'field2'; - const userId = 'user1'; - - await operator.handleCustomProfileFields({ - fields: [ - { - id: fieldId1, - name: 'Test Field 1', - type: 'text', - delete_at: 0, - group_id: '', - target_id: '', - target_type: 'user', - create_at: 1000, - update_at: 1000, - attrs: {sort_order: 1}, - }, - { - id: fieldId2, - name: 'Test Field 2', - type: 'select', - delete_at: 0, - group_id: '', - target_id: '', - target_type: 'user', - create_at: 1000, - update_at: 1000, - attrs: {sort_order: 0}, - }, - ], - prepareRecordsOnly: false, - }); - - await operator.handleCustomProfileAttributes({ - attributes: [ - { - id: `${fieldId1}_${userId}`, - field_id: fieldId1, - user_id: userId, - value: 'Value 1', - }, - { - id: `${fieldId2}_${userId}`, - field_id: fieldId2, - user_id: userId, - value: 'Value 2', - }, - ], - prepareRecordsOnly: false, - }); - - const attributes = await queryCustomProfileAttributesByUserId(database, userId).fetch(); - - // Sort by sort_order in ascending order - const customAttributes = await convertProfileAttributesToCustomAttributes( - database, - attributes, - (a, b) => (a.sort_order || 0) - (b.sort_order || 0), - ); - - expect(customAttributes.length).toBe(2); - expect(customAttributes[0].id).toBe(fieldId2); // This has sort_order 0 - expect(customAttributes[1].id).toBe(fieldId1); // This has sort_order 1 - }); - - it('should handle empty attributes array', async () => { - const customAttributes = await convertProfileAttributesToCustomAttributes(database, []); - expect(customAttributes).toEqual([]); - }); - - it('should handle null attributes', async () => { - const customAttributes = await convertProfileAttributesToCustomAttributes(database, null); - expect(customAttributes).toEqual([]); - }); - }); - describe('queryCustomProfileFields', () => { it('should query all custom profile fields', async () => { await operator.handleCustomProfileFields({ @@ -722,69 +558,4 @@ describe('Custom Profile Queries', () => { expect(true).toBe(true); }); }); - - describe('Performance Tests', () => { - it('should profile convertProfileAttributesToCustomAttributes performance', async () => { - jest.setTimeout(30000); // Increase timeout for this test - - // Create a larger dataset to test performance - const fieldCount = 200; - const userCount = 5; - - // Create fields - const fields = Array.from({length: fieldCount}, (_, i) => ({ - id: `field${i}`, - name: `Test Field ${i}`, - type: 'text', - delete_at: 0, - group_id: '', - target_id: '', - target_type: 'user', - create_at: 1000, - update_at: 1000, - attrs: {sort_order: i + random(0, 1000)}, - })); - - await operator.handleCustomProfileFields({ - fields, - prepareRecordsOnly: false, - }); - - // Create attributes (10 attributes per user) - const attributes = []; - for (let u = 0; u < userCount; u++) { - const userId = `user${u}`; - for (let f = 0; f < fieldCount; f++) { - const fieldId = `field${f}`; - attributes.push({ - id: customProfileAttributeId(fieldId, userId), - field_id: fieldId, - user_id: userId, - value: `Value for user ${u} field ${f}`, - }); - } - } - - await operator.handleCustomProfileAttributes({ - attributes, - prepareRecordsOnly: false, - }); - - // Profile the function for one specific user - const userId = 'user0'; - const userAttributes = await queryCustomProfileAttributesByUserId(database, userId).fetch(); - - console.log(`Testing conversion of ${userAttributes.length} attributes`); - - // Run the function once to get performance data - const sortFn = (a: CustomAttribute, b: CustomAttribute) => (a.sort_order || 0) - (b.sort_order || 0); - - const startTime = performance.now(); - await convertProfileAttributesToCustomAttributes(database, userAttributes, sortFn); - const endTime = performance.now(); - console.log(`Time with sorting: ${(endTime - startTime).toFixed(2)}ms`); - - expect(true).toBe(true); // No assertions needed for profiling - }); - }); }); diff --git a/app/queries/servers/custom_profile.ts b/app/queries/servers/custom_profile.ts index 15b211d1f..eb9ba1513 100644 --- a/app/queries/servers/custom_profile.ts +++ b/app/queries/servers/custom_profile.ts @@ -8,7 +8,6 @@ import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import {MM_TABLES} from '@constants/database'; import {customProfileAttributeId} from '@utils/custom_profile_attribute'; -import type {CustomAttribute} from '@typings/api/custom_profile_attributes'; import type CustomProfileAttributeModel from 'app/database/models/server/custom_profile_attribute'; import type CustomProfileFieldModel from 'app/database/models/server/custom_profile_field'; @@ -85,48 +84,6 @@ export const observeCustomProfileAttributesByUserId = (database: Database, userI return queryCustomProfileAttributesByUserId(database, userId).observeWithColumns(['value']).pipe(distinctUntilChanged()); }; -/** - * Convert custom profile attributes to the UI-ready CustomAttribute format - * @param database - The database instance - * @param attributes - Array of custom profile attribute models - * @returns Promise resolving to array of formatted CustomAttribute objects - */ -export const convertProfileAttributesToCustomAttributes = async ( - database: Database, - attributes: CustomProfileAttributeModel[] | null | undefined, - sortFn?: (a: CustomAttribute, b: CustomAttribute) => number, -): Promise => { - if (!attributes?.length) { - return []; - } - - // We need to fetch field details for names and sorting - const fieldIds = attributes.map((attr) => attr.fieldId); - const fieldsQuery = await database.get(CUSTOM_PROFILE_FIELD).query( - Q.where('id', Q.oneOf(fieldIds)), - ).fetch(); - - // Create a map of field IDs to names - const fieldsMap = new Map(); - fieldsQuery.forEach((field) => { - fieldsMap.set(field.id, { - name: field.name, - sort_order: field.attrs?.sort_order || 0, - }); - }); - - // Convert DB attributes to CustomAttribute format - const customAttrs = attributes.map((attr) => ({ - id: attr.fieldId, - name: fieldsMap.get(attr.fieldId)?.name || attr.fieldId, - value: attr.value, - sort_order: fieldsMap.get(attr.fieldId)?.sort_order || 0, - })); - - // Sort if a sort function is provided - return sortFn ? customAttrs.sort(sortFn) : customAttrs; -}; - /** * Query to fetch all custom profile fields * @param database - The database instance @@ -168,7 +125,7 @@ export const queryCustomProfileAttributesByFieldId = (database: Database, fieldI * @returns Promise that resolves when the deletion is complete */ export const deleteCustomProfileAttributesByFieldId = async (database: Database, fieldId: string, batchSize = 100) => { - const attributes = await queryCustomProfileAttributesByFieldId(database, fieldId).fetch(); + const attributes = await prepareCustomProfileAttributesForDeletionByFieldId(database, fieldId); if (!attributes.length) { return; @@ -177,11 +134,8 @@ export const deleteCustomProfileAttributesByFieldId = async (database: Database, // Process attributes in batches to avoid performance issues with large datasets const promises = []; for (let i = 0; i < attributes.length; i += batchSize) { - const batch = attributes.slice(i, i + batchSize); - const preparedModels = batch.map((attribute) => attribute.prepareDestroyPermanently()); - const batchPromise = database.write(async () => { - await database.batch(...preparedModels); + await database.batch(...(attributes.slice(i, i + batchSize))); }, `deleteCustomProfileAttributesByFieldId:${fieldId}:batch:${i}`); promises.push(batchPromise); @@ -190,3 +144,14 @@ export const deleteCustomProfileAttributesByFieldId = async (database: Database, await Promise.all(promises); }; +export const prepareCustomProfileAttributesForDeletionByFieldId = async (database: Database, fieldId: string) => { + const field = await getCustomProfileFieldById(database, fieldId); + if (!field) { + return []; + } + const attributes = await field.customProfileAttributes.fetch(); + if (attributes.length === 0) { + return []; + } + return attributes.map((attribute) => attribute.prepareDestroyPermanently()); +}; diff --git a/app/screens/edit_profile/edit_profile.test.tsx b/app/screens/edit_profile/edit_profile.test.tsx index 6a817d128..e967a2efa 100644 --- a/app/screens/edit_profile/edit_profile.test.tsx +++ b/app/screens/edit_profile/edit_profile.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {act} from '@testing-library/react-hooks'; -import {fireEvent, waitFor} from '@testing-library/react-native'; +import {fireEvent} from '@testing-library/react-native'; import React from 'react'; import AvailableScreens from '@constants/screens'; @@ -10,6 +10,44 @@ import {renderWithIntlAndTheme} from '@test/intl-test-helper'; import EditProfile from './edit_profile'; import type {UserModel} from '@database/models/server'; +import type {CustomAttributeSet} from '@typings/api/custom_profile_attributes'; + +// Create server and database attribute sets for testing +const serverAttributesSet: CustomAttributeSet = { + attr1: { + id: 'attr1', + name: 'Custom Attribute 1', + value: 'server value 1', + sort_order: 1, + }, + attr2: { + id: 'attr2', + name: 'Custom Attribute 2', + value: 'server value 2', + sort_order: 2, + }, + attr3: { + id: 'attr3', + name: 'Custom Attribute 3', + value: 'server value 3', + sort_order: 3, + }, +}; + +const dbAttributesSet: CustomAttributeSet = { + attr1: { + id: 'attr1', + name: 'Custom Attribute 1', + value: 'db value 1', + sort_order: 1, + }, + attr2: { + id: 'attr2', + name: 'Custom Attribute 2', + value: 'db value 2', + sort_order: 2, + }, +}; jest.mock('@components/compass_icon', () => { function CompassIcon() { @@ -26,37 +64,78 @@ jest.mock('@context/server', () => ({ useServerUrl: jest.fn().mockReturnValue('http://localhost:8065'), })); +// Mock for custom profile attributes API +const mockFetchCustomProfileAttributes = jest.fn(); + +jest.mock('@actions/remote/custom_profile', () => ({ + fetchCustomProfileAttributes: (...args: any[]) => mockFetchCustomProfileAttributes(...args), + updateCustomProfileAttributes: jest.fn().mockResolvedValue({success: true, error: undefined}), +})); + jest.mock('@actions/remote/user', () => ({ - fetchCustomAttributes: jest.fn().mockResolvedValue({ - attributes: { - attr1: { - id: 'attr1', - name: 'Custom Attribute 1', - value: 'original value 1', - sort_order: 1, - }, - attr3: { - id: 'attr3', - name: 'Custom Attribute 3', - value: 'original value 3', - sort_order: 3, - }, - attr2: { - id: 'attr2', - name: 'Custom Attribute 2', - value: 'original value 2', - sort_order: 2, - }, - }, - error: undefined, - }), - updateCustomAttributes: jest.fn().mockResolvedValue({}), - updateMe: jest.fn().mockResolvedValue({}), - uploadUserProfileImage: jest.fn().mockResolvedValue({}), + updateMe: jest.fn().mockResolvedValue({error: undefined}), + uploadUserProfileImage: jest.fn().mockResolvedValue({error: undefined}), setDefaultProfileImage: jest.fn().mockResolvedValue({}), buildProfileImageUrlFromUser: jest.fn().mockReturnValue('http://example.com/profile.jpg'), })); +// Mock WatermelonDB functionality +jest.mock('@database/manager', () => { + const databaseMock = { + get: jest.fn().mockReturnValue({ + query: jest.fn().mockReturnValue({ + fetch: jest.fn().mockResolvedValue([]), + }), + }), + }; + + return { + getServerDatabaseAndOperator: jest.fn().mockReturnValue({ + database: databaseMock, + operator: { + handleCustomProfileAttributes: jest.fn().mockResolvedValue({}), + handleCustomProfileFields: jest.fn().mockResolvedValue({}), + }, + }), + }; +}); + +jest.mock('@queries/servers/custom_profile', () => { + const mockSubscribeFunction = jest.fn().mockImplementation((callback) => { + // Simulate an immediate callback with empty data + setTimeout(() => callback([]), 0); + return { + unsubscribe: jest.fn(), + }; + }); + + const mockObservable = { + subscribe: mockSubscribeFunction, + }; + + return { + observeCustomProfileAttributesByUserId: jest.fn().mockReturnValue(mockObservable), + observeCustomProfileFields: jest.fn().mockReturnValue(mockObservable), + queryCustomProfileAttributesByUserId: jest.fn().mockReturnValue({ + fetch: jest.fn().mockResolvedValue([]), + }), + convertProfileAttributesToCustomAttributes: jest.fn().mockResolvedValue([ + { + id: 'attr1', + name: 'Custom Attribute 1', + value: 'db value 1', + sort_order: 1, + }, + { + id: 'attr2', + name: 'Custom Attribute 2', + value: 'db value 2', + sort_order: 2, + }, + ]), + }; +}); + describe('EditProfile', () => { const mockCurrentUser = { id: 'user1', @@ -70,6 +149,12 @@ describe('EditProfile', () => { beforeEach(() => { jest.clearAllMocks(); + + // Reset the mock to its default success response + mockFetchCustomProfileAttributes.mockImplementation(() => Promise.resolve({ + attributes: serverAttributesSet, + error: undefined, + })); }); it('should update custom attribute value while preserving name and sort order', async () => { @@ -85,20 +170,18 @@ describe('EditProfile', () => { lockedPosition={false} lockedPicture={false} enableCustomAttributes={true} + userCustomAttributes={[]} + customFields={[]} + customAttributesSet={serverAttributesSet} />, ); - await waitFor(() => { - const {fetchCustomAttributes} = require('@actions/remote/user'); - expect(fetchCustomAttributes).toHaveBeenCalledWith('http://localhost:8065', 'user1'); - }); - const customAttributeItems = await findAllByTestId(new RegExp('^edit_profile_form.customAttributes.attr[0-9]+.input$')); expect(customAttributeItems.length).toBe(3); - expect(customAttributeItems[0].props.value).toBe('original value 1'); - expect(customAttributeItems[1].props.value).toBe('original value 2'); - expect(customAttributeItems[2].props.value).toBe('original value 3'); + expect(customAttributeItems[0].props.value).toBe('server value 1'); + expect(customAttributeItems[1].props.value).toBe('server value 2'); + expect(customAttributeItems[2].props.value).toBe('server value 3'); await act(async () => { fireEvent.changeText(customAttributeItems[1], 'new value'); @@ -106,8 +189,203 @@ describe('EditProfile', () => { const newCustomAttributeItems = await findAllByTestId(new RegExp('^edit_profile_form.customAttributes.attr[0-9]+.input$')); expect(newCustomAttributeItems.length).toBe(3); - expect(newCustomAttributeItems[0].props.value).toBe('original value 1'); + expect(newCustomAttributeItems[0].props.value).toBe('server value 1'); expect(newCustomAttributeItems[1].props.value).toBe('new value'); - expect(newCustomAttributeItems[2].props.value).toBe('original value 3'); + expect(newCustomAttributeItems[2].props.value).toBe('server value 3'); + }); + + it('should load custom attributes from database before fetching from server', async () => { + // Mock server fetch to return an error so database values remain + mockFetchCustomProfileAttributes.mockReset(); + mockFetchCustomProfileAttributes.mockImplementation(() => { + // Return a failed fetch + return Promise.resolve({ + attributes: null, + error: new Error('Server fetch failed'), + }); + }); + + // Setup component with database values from customAttributesSet prop + const {findAllByTestId, rerender} = renderWithIntlAndTheme( + , + ); + + // Wait for component to mount and useEffect to run + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Verify database values are shown + const initialAttributeItems = await findAllByTestId(new RegExp('^edit_profile_form.customAttributes.attr[0-9]+.input$')); + expect(initialAttributeItems.length).toBe(2); + expect(initialAttributeItems[0].props.value).toBe('db value 1'); + expect(initialAttributeItems[1].props.value).toBe('db value 2'); + + // Verify fetch was called with user1 + expect(mockFetchCustomProfileAttributes).toHaveBeenCalledWith('http://localhost:8065', 'user1'); + + // Now reset the mock to return success and simulate server fetch completing + mockFetchCustomProfileAttributes.mockReset(); + mockFetchCustomProfileAttributes.mockImplementation(() => Promise.resolve({ + attributes: serverAttributesSet, + error: undefined, + })); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Rerender with server values + rerender( + , + ); + }); + + // Verify server values update the UI + const serverAttributeItems = await findAllByTestId(new RegExp('^edit_profile_form.customAttributes.attr[0-9]+.input$')); + expect(serverAttributeItems.length).toBe(3); + expect(serverAttributeItems[0].props.value).toBe('server value 1'); + expect(serverAttributeItems[1].props.value).toBe('server value 2'); + expect(serverAttributeItems[2].props.value).toBe('server value 3'); + }); + + it('should update UI when database data changes via observables', async () => { + // Mock server fetch to return an error so it doesn't affect the test + mockFetchCustomProfileAttributes.mockReset(); + mockFetchCustomProfileAttributes.mockImplementation(() => { + return Promise.resolve({ + attributes: null, + error: new Error('Server fetch disabled for this test'), + }); + }); + + // Setup component with empty attributes initially + const {findAllByTestId, queryAllByTestId, rerender} = renderWithIntlAndTheme( + , + ); + + // Initially there should be no custom attributes (using queryAllByTestId which doesn't throw) + const initialItems = queryAllByTestId(new RegExp('^edit_profile_form.customAttributes.attr[0-9]+.input$')); + expect(initialItems.length).toBe(0); + + // Simulate database update by changing props + await act(async () => { + rerender( + , + ); + }); + + // Verify UI updated with database values + const dbAttributeItems = await findAllByTestId(new RegExp('^edit_profile_form.customAttributes.attr[0-9]+.input$')); + expect(dbAttributeItems.length).toBe(2); + expect(dbAttributeItems[0].props.value).toBe('db value 1'); + expect(dbAttributeItems[1].props.value).toBe('db value 2'); + + // Simulate a new database update + const updatedDbAttributesSet: CustomAttributeSet = { + attr1: { + id: 'attr1', + name: 'Custom Attribute 1', + value: 'updated db value 1', + sort_order: 1, + }, + attr2: { + id: 'attr2', + name: 'Custom Attribute 2', + value: 'updated db value 2', + sort_order: 2, + }, + attr4: { + id: 'attr4', + name: 'New Attribute', + value: 'new db value', + sort_order: 4, + }, + }; + + await act(async () => { + rerender( + , + ); + }); + + // Verify UI updated with new database values + const updatedAttributeItems = await findAllByTestId(new RegExp('^edit_profile_form.customAttributes.attr[0-9]+.input$')); + expect(updatedAttributeItems.length).toBe(3); + expect(updatedAttributeItems[0].props.value).toBe('updated db value 1'); + expect(updatedAttributeItems[1].props.value).toBe('updated db value 2'); + + // Check for the new attribute that was added + const newAttributeItem = await findAllByTestId('edit_profile_form.customAttributes.attr4.input'); + expect(newAttributeItem[0].props.value).toBe('new db value'); }); }); diff --git a/app/screens/edit_profile/edit_profile.tsx b/app/screens/edit_profile/edit_profile.tsx index 9c5172760..16924f58a 100644 --- a/app/screens/edit_profile/edit_profile.tsx +++ b/app/screens/edit_profile/edit_profile.tsx @@ -8,7 +8,8 @@ import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; import {type Edge, SafeAreaView} from 'react-native-safe-area-context'; import {updateLocalUser} from '@actions/local/user'; -import {setDefaultProfileImage, updateMe, uploadUserProfileImage, fetchCustomAttributes, updateCustomAttributes} from '@actions/remote/user'; +import {fetchCustomProfileAttributes, updateCustomProfileAttributes} from '@actions/remote/custom_profile'; +import {setDefaultProfileImage, updateMe, uploadUserProfileImage} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; import TabletTitle from '@components/tablet_title'; import {Events} from '@constants'; @@ -47,6 +48,7 @@ const CUSTOM_ATTRS_PREFIX_NAME = `${CUSTOM_ATTRS_PREFIX}.`; const EditProfile = ({ componentId, currentUser, isModal, isTablet, lockedFirstName, lockedLastName, lockedNickname, lockedPosition, lockedPicture, enableCustomAttributes, + customAttributesSet, }: EditProfileProps) => { const intl = useIntl(); const serverUrl = useServerUrl(); @@ -67,6 +69,7 @@ const EditProfile = ({ const [error, setError] = useState(); const [usernameError, setUsernameError] = useState(); const [updating, setUpdating] = useState(false); + const lastRequest = useRef(0); const buttonText = intl.formatMessage({id: 'mobile.account.settings.save', defaultMessage: 'Save'}); const rightButton = useMemo(() => { @@ -125,14 +128,25 @@ const EditProfile = ({ if (!currentUser) { return; } - const {error: fetchError, attributes} = await fetchCustomAttributes(serverUrl, currentUser.id); - if (!fetchError && attributes) { - setUserInfo((prev: UserInfo) => ({...prev, customAttributes: attributes} as UserInfo)); + + if (enableCustomAttributes) { + setUserInfo((prev) => ({ + ...prev, + customAttributes: customAttributesSet || {}, + })); + } + + // Then fetch from server for latest data + const reqTime = Date.now(); + lastRequest.current = reqTime; + const {error: fetchError, attributes} = await fetchCustomProfileAttributes(serverUrl, currentUser.id); + if (!fetchError && attributes && lastRequest.current === reqTime) { + setUserInfo((prev) => ({...prev, customAttributes: attributes})); } }; loadCustomAttributes(); - }, [currentUser, serverUrl]); + }, [currentUser, serverUrl, enableCustomAttributes, customAttributesSet]); const submitUser = useCallback(preventDoubleTap(async () => { if (!currentUser) { @@ -173,7 +187,7 @@ const EditProfile = ({ // Update custom attributes if changed if (userInfo.customAttributes) { - const {error: attrError} = await updateCustomAttributes(serverUrl, userInfo.customAttributes); + const {error: attrError} = await updateCustomProfileAttributes(serverUrl, currentUser.id, userInfo.customAttributes); if (attrError) { resetScreen(attrError); return; diff --git a/app/screens/edit_profile/index.ts b/app/screens/edit_profile/index.ts index 85b88eba1..8904b9478 100644 --- a/app/screens/edit_profile/index.ts +++ b/app/screens/edit_profile/index.ts @@ -5,14 +5,18 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$, combineLatest} from 'rxjs'; import {switchMap} from 'rxjs/operators'; +import {observeCustomProfileAttributesByUserId, observeCustomProfileFields} from '@queries/servers/custom_profile'; import {observeConfigBooleanValue} from '@queries/servers/system'; import {observeCurrentUser} from '@queries/servers/user'; +import {sortCustomProfileAttributes, convertToAttributesMap, convertProfileAttributesToCustomAttributes} from '@utils/user'; import EditProfile from './edit_profile'; +import type {CustomAttribute} from '@typings/api/custom_profile_attributes'; import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const currentUser = observeCurrentUser(database); const ldapFirstNameAttributeSet = observeConfigBooleanValue(database, 'LdapFirstNameAttributeSet'); const ldapLastNameAttributeSet = observeConfigBooleanValue(database, 'LdapLastNameAttributeSet'); const ldapNicknameAttributeSet = observeConfigBooleanValue(database, 'LdapNicknameAttributeSet'); @@ -22,9 +26,31 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { const samlLastNameAttributeSet = observeConfigBooleanValue(database, 'SamlLastNameAttributeSet'); const samlNicknameAttributeSet = observeConfigBooleanValue(database, 'SamlNicknameAttributeSet'); const samlPositionAttributeSet = observeConfigBooleanValue(database, 'SamlPositionAttributeSet'); + const customAttributesEnabled = observeConfigBooleanValue(database, 'FeatureFlagCustomProfileAttributes'); + + const rawCustomAttributes = currentUser.pipe( + switchMap((u) => (u ? observeCustomProfileAttributesByUserId(database, u.id) : of$([]))), + ); + let formattedCustomAttributes; + let customFields; + if (customAttributesEnabled) { + customFields = observeCustomProfileFields(database); + + // Convert attributes to the format expected by the component + formattedCustomAttributes = combineLatest([rawCustomAttributes, customFields]).pipe( + switchMap(([attributes, fields]) => { + if (!attributes?.length) { + return of$([] as CustomAttribute[]); + } + + return of$(convertProfileAttributesToCustomAttributes(attributes, fields, sortCustomProfileAttributes)); + }), + switchMap((converted) => of$(convertToAttributesMap(converted))), + ); + } return { - currentUser: observeCurrentUser(database), + currentUser, lockedFirstName: combineLatest([ldapFirstNameAttributeSet, samlFirstNameAttributeSet]).pipe( switchMap(([ldap, saml]) => of$(ldap || saml)), ), @@ -39,6 +65,9 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { ), lockedPicture: observeConfigBooleanValue(database, 'LdapPictureAttributeSet'), enableCustomAttributes: observeConfigBooleanValue(database, 'FeatureFlagCustomProfileAttributes'), + userCustomAttributes: rawCustomAttributes, + customFields, + customAttributesSet: formattedCustomAttributes, }; }); diff --git a/app/screens/user_profile/index.ts b/app/screens/user_profile/index.ts index 84f968260..b0122ca0a 100644 --- a/app/screens/user_profile/index.ts +++ b/app/screens/user_profile/index.ts @@ -8,15 +8,17 @@ import {map, switchMap} from 'rxjs/operators'; import {General, Permissions, Preferences} from '@constants'; import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference'; import {observeChannel} from '@queries/servers/channel'; +import {observeCustomProfileAttributesByUserId, observeCustomProfileFields} from '@queries/servers/custom_profile'; import {queryDisplayNamePreferences} from '@queries/servers/preference'; import {observeCanManageChannelMembers, observePermissionForChannel} from '@queries/servers/role'; import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; import {observeTeammateNameDisplay, observeCurrentUser, observeUser, observeUserIsChannelAdmin, observeUserIsTeamAdmin} from '@queries/servers/user'; import {isDefaultChannel} from '@utils/channel'; -import {isSystemAdmin} from '@utils/user'; +import {isSystemAdmin, sortCustomProfileAttributes, convertToAttributesMap, convertProfileAttributesToCustomAttributes} from '@utils/user'; import UserProfile from './user_profile'; +import type {CustomAttribute} from '@typings/api/custom_profile_attributes'; import type {WithDatabaseArgs} from '@typings/database/database'; type EnhancedProps = WithDatabaseArgs & { @@ -46,6 +48,23 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro observeWithColumns(['value']); const isMilitaryTime = preferences.pipe(map((prefs) => getDisplayNamePreferenceAsBool(prefs, Preferences.USE_MILITARY_TIME))); const isCustomStatusEnabled = observeConfigBooleanValue(database, 'EnableCustomUserStatuses'); + const enableCustomAttributes = observeConfigBooleanValue(database, 'FeatureFlagCustomProfileAttributes'); + + // Custom profile attributes + const rawCustomAttributes = observeCustomProfileAttributesByUserId(database, userId); + + const customFields = observeCustomProfileFields(database); + + // Convert attributes to the format expected by the component + const formattedCustomAttributes = combineLatest([rawCustomAttributes, customFields, enableCustomAttributes]).pipe( + switchMap(([attributes, fields, enabled]) => { + if (!enabled || !attributes?.length) { + return of$([] as CustomAttribute[]); + } + return of$(convertProfileAttributesToCustomAttributes(attributes, fields, sortCustomProfileAttributes)); + }), + switchMap((converted) => of$(convertToAttributesMap(converted))), + ); // can remove member const canManageAndRemoveMembers = combineLatest([channel, currentUser]).pipe( @@ -71,7 +90,8 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro user, canChangeMemberRoles, hideGuestTags: observeConfigBooleanValue(database, 'HideGuestTags'), - enableCustomAttributes: observeConfigBooleanValue(database, 'FeatureFlagCustomProfileAttributes'), + enableCustomAttributes, + customAttributesSet: formattedCustomAttributes, }; }); diff --git a/app/screens/user_profile/user_info.test.tsx b/app/screens/user_profile/user_info.test.tsx index 4dfc53de2..00e628e7c 100644 --- a/app/screens/user_profile/user_info.test.tsx +++ b/app/screens/user_profile/user_info.test.tsx @@ -3,38 +3,141 @@ import React from 'react'; -import {fetchCustomAttributes} from '@actions/remote/user'; -import {renderWithIntlAndTheme, screen, waitFor} from '@test/intl-test-helper'; -import TestHelper from '@test/test_helper'; +import * as CustomProfileActions from '@actions/remote/custom_profile'; +import * as CustomProfileQueries from '@queries/servers/custom_profile'; +import {renderWithIntlAndTheme, waitFor} from '@test/intl-test-helper'; import UserInfo from './user_info'; +import type UserModel from '@database/models/server/user'; +import type {CustomAttributeSet} from '@typings/api/custom_profile_attributes'; + const localhost = 'http://localhost:8065'; -jest.mock('@actions/remote/user', () => ({ - fetchCustomAttributes: jest.fn().mockResolvedValue({}), +// Create test attribute sets +const serverAttributes = { + attr1: { + id: 'attr1', + name: 'Department', + value: 'Engineering', + sort_order: 1, + }, + attr2: { + id: 'attr2', + name: 'Location', + value: 'Remote', + sort_order: 0, + }, +} as CustomAttributeSet; + +const updatedAttributes = { + attr1: { + id: 'attr1', + name: 'Department', + value: 'Engineering Updated', + sort_order: 1, + }, + attr2: { + id: 'attr2', + name: 'Location', + value: 'Office', + sort_order: 0, + }, + attr3: { + id: 'attr3', + name: 'Team', + value: 'Mobile', + sort_order: 2, + }, +} as CustomAttributeSet; + +jest.mock('@actions/remote/custom_profile', () => ({ + fetchCustomProfileAttributes: jest.fn().mockResolvedValue({ + attributes: {}, + error: undefined, + }), })); jest.mock('@context/server', () => ({ useServerUrl: jest.fn().mockReturnValue(localhost), })); +jest.mock('@database/manager', () => { + const mockDatabase = { + get: jest.fn().mockReturnValue({ + query: jest.fn().mockReturnValue({ + fetch: jest.fn().mockResolvedValue([]), + }), + }), + }; + + return { + getServerDatabaseAndOperator: jest.fn().mockReturnValue({ + database: mockDatabase, + operator: {}, + }), + }; +}); + +const mockQueryFetch = jest.fn().mockResolvedValue([]); +const mockQueryAttributesByUserId = jest.fn().mockReturnValue({ + fetch: mockQueryFetch, +}); + +const mockAttributesCallback = jest.fn(); +const mockFieldsCallback = jest.fn(); + +const mockAttributesObservable = { + subscribe: jest.fn().mockImplementation((callback) => { + mockAttributesCallback.mockImplementation(callback); + return {unsubscribe: jest.fn()}; + }), +}; + +const mockFieldsObservable = { + subscribe: jest.fn().mockImplementation((callback) => { + mockFieldsCallback.mockImplementation(callback); + return {unsubscribe: jest.fn()}; + }), +}; + +const mockConvertAttributes = jest.fn().mockResolvedValue([]); + +jest.mock('@queries/servers/custom_profile', () => { + return { + queryCustomProfileAttributesByUserId: jest.fn().mockImplementation((...args) => + mockQueryAttributesByUserId(...args), + ), + observeCustomProfileAttributesByUserId: jest.fn().mockReturnValue(mockAttributesObservable), + observeCustomProfileFields: jest.fn().mockReturnValue(mockFieldsObservable), + convertProfileAttributesToCustomAttributes: jest.fn().mockImplementation((...args) => + mockConvertAttributes(...args), + ), + }; +}); + describe('screens/user_profile/UserInfo', () => { beforeEach(() => { - // Reset mock before each test - jest.mocked(fetchCustomAttributes).mockReset(); + jest.clearAllMocks(); + + (CustomProfileActions.fetchCustomProfileAttributes as jest.Mock).mockResolvedValue({ + attributes: {}, + error: undefined, + }); + + mockQueryFetch.mockResolvedValue([]); + mockConvertAttributes.mockResolvedValue([]); }); const baseProps = { - user: TestHelper.fakeUserModel({ + user: { id: 'user1', firstName: 'First', lastName: 'Last', username: 'username', nickname: 'nick', position: 'Developer', - }), - isMyUser: false, + } as UserModel, showCustomStatus: false, showLocalTime: true, showNickname: true, @@ -42,146 +145,103 @@ describe('screens/user_profile/UserInfo', () => { enableCustomAttributes: true, }; - const baseCustomAttributes = { - attr1: { - id: 'attr1', - name: 'Department', - value: 'Engineering', - sort_order: 1, - }, - attr2: { - id: 'attr2', - name: 'Location', - value: 'Remote', - sort_order: 0, - }, - attr3: { - id: 'attr3', - name: 'Start Date', - value: '2023', - sort_order: 2, - }, - }; + test('should load attributes from database before fetching from server', async () => { + // Mock fetch to return server attributes + const fetchMock = CustomProfileActions.fetchCustomProfileAttributes as jest.Mock; + fetchMock.mockResolvedValue({ + attributes: serverAttributes, + error: undefined, + }); - test('should display custom attributes in sort order', async () => { - jest.mocked(fetchCustomAttributes).mockResolvedValue({attributes: baseCustomAttributes}); - - renderWithIntlAndTheme( - , + // Render with initial customAttributesSet + const {queryByText, rerender} = renderWithIntlAndTheme( + , ); - // Wait for the fetch to be called + // Initially there should be no custom attributes displayed + expect(queryByText('Engineering')).toBeNull(); + expect(queryByText('Remote')).toBeNull(); + + // Verify fetch was called await waitFor(() => { - expect(fetchCustomAttributes).toHaveBeenCalledWith( - localhost, - 'user1', - true, - ); + expect(fetchMock).toHaveBeenCalledWith(localhost, 'user1', true); }); - // Wait for all items to be rendered + // Simulate receiving custom attributes from props + rerender( + , + ); + + // Custom attributes should now be displayed await waitFor(() => { - // Standard attributes - expect(screen.getByText('Nickname')).toBeTruthy(); - expect(screen.getByText('Position')).toBeTruthy(); - expect(screen.getByText('nick')).toBeTruthy(); - expect(screen.getByText('Developer')).toBeTruthy(); - - // Custom attributes (sorted by sort_order) - expect(screen.getByText('Location')).toBeTruthy(); // sort_order: 0 - expect(screen.getByText('Remote')).toBeTruthy(); - expect(screen.getByText('Department')).toBeTruthy(); // sort_order: 1 - expect(screen.getByText('Engineering')).toBeTruthy(); - expect(screen.getByText('Start Date')).toBeTruthy(); // sort_order: 2 - expect(screen.getByText('2023')).toBeTruthy(); + expect(queryByText('Engineering')).not.toBeNull(); + expect(queryByText('Remote')).not.toBeNull(); }); - - // Verify the order of elements - const titles = screen.getAllByTestId(/.*\.title$/); - expect(titles.map((el) => el.props.children)).toEqual([ - 'Nickname', - 'Position', - 'Location', // sort_order: 0 - 'Department', // sort_order: 1 - 'Start Date', // sort_order: 2 - ]); - - const descriptions = screen.getAllByTestId(/.*\.description$/); - expect(descriptions.map((el) => el.props.children)).toEqual([ - 'nick', - 'Developer', - 'Remote', // sort_order: 0 - 'Engineering', // sort_order: 1 - '2023', // sort_order: 2 - ]); }); - it('should display custom attributes in order when some have no order', async () => { - const withEmptyCustomAttributes = { - attr1: { - id: 'attr1', - name: 'Department', - value: 'Engineering', - }, - attr2: { - id: 'attr2', - name: 'Location', - value: 'Remote', - sort_order: 0, - }, - attr3: { - id: 'attr3', - name: 'Start Date', - value: '2023', - sort_order: 2, - }, - }; - jest.mocked(fetchCustomAttributes).mockResolvedValue({attributes: withEmptyCustomAttributes}); - renderWithIntlAndTheme( - , + test('should update UI when database changes via observables', async () => { + // Set up component with initial empty attributes + const {queryByText, rerender} = renderWithIntlAndTheme( + , ); + // Initially there should be no custom attributes + expect(queryByText('Engineering')).toBeNull(); + + // Simulate receiving attributes from props (as would happen from parent observable) + rerender( + , + ); + + // Now the attributes should be displayed await waitFor(() => { - expect(fetchCustomAttributes).toHaveBeenCalledWith( - localhost, - 'user1', - true, - ); + expect(queryByText('Engineering')).not.toBeNull(); + expect(queryByText('Remote')).not.toBeNull(); }); + // Simulate a database update via changed props + rerender( + , + ); + + // Updated values should be displayed await waitFor(() => { - // Standard attributes remain - expect(screen.getByText('Nickname')).toBeTruthy(); - expect(screen.getByText('Position')).toBeTruthy(); - expect(screen.getByText('nick')).toBeTruthy(); - expect(screen.getByText('Developer')).toBeTruthy(); - - // Custom attributes (sorted by sort_order) - expect(screen.getByText('Location')).toBeTruthy(); // sort_order: 0 - expect(screen.getByText('Remote')).toBeTruthy(); - expect(screen.getByText('Start Date')).toBeTruthy(); // sort_order: 2 - expect(screen.getByText('2023')).toBeTruthy(); - expect(screen.queryByText('Department')).toBeTruthy(); // sort_order: undefined - expect(screen.queryByText('Engineering')).toBeTruthy(); + expect(queryByText('Engineering Updated')).not.toBeNull(); + expect(queryByText('Office')).not.toBeNull(); + expect(queryByText('Mobile')).not.toBeNull(); }); + }); - // Verify the order of elements - const titles = screen.getAllByTestId(/.*\.title$/); - expect(titles.map((el) => el.props.children)).toEqual([ - 'Nickname', - 'Position', - 'Location', // sort_order: 0 - 'Start Date', // sort_order: 2 - 'Department', // sort_order: undefined - ]); + test('should not fetch data if custom attributes are disabled', async () => { + const fetchMock = CustomProfileActions.fetchCustomProfileAttributes as jest.Mock; - const descriptions = screen.getAllByTestId(/.*\.description$/); - expect(descriptions.map((el) => el.props.children)).toEqual([ - 'nick', - 'Developer', - 'Remote', // sort_order: 0 - '2023', // sort_order: 2 - 'Engineering', // sort_order: undefined - ]); + renderWithIntlAndTheme( + , + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(mockQueryAttributesByUserId).not.toHaveBeenCalled(); + expect(CustomProfileQueries.observeCustomProfileAttributesByUserId).not.toHaveBeenCalled(); + expect(CustomProfileQueries.observeCustomProfileFields).not.toHaveBeenCalled(); }); }); diff --git a/app/screens/user_profile/user_info.tsx b/app/screens/user_profile/user_info.tsx index d080f8f0e..68dea5b99 100644 --- a/app/screens/user_profile/user_info.tsx +++ b/app/screens/user_profile/user_info.tsx @@ -3,7 +3,7 @@ import React, {useEffect, useState, useRef} from 'react'; -import {fetchCustomAttributes} from '@actions/remote/user'; +import {fetchCustomProfileAttributes} from '@actions/remote/custom_profile'; import {useServerUrl} from '@context/server'; import {getUserCustomStatus, sortCustomProfileAttributes} from '@utils/user'; @@ -11,7 +11,7 @@ import CustomAttributes from './custom_attributes'; import UserProfileCustomStatus from './custom_status'; import type {UserModel} from '@database/models/server'; -import type {CustomAttribute} from '@typings/api/custom_profile_attributes'; +import type {CustomAttribute, CustomAttributeSet} from '@typings/api/custom_profile_attributes'; type Props = { localTime?: string; @@ -21,36 +21,49 @@ type Props = { showPosition: boolean; user: UserModel; enableCustomAttributes?: boolean; + customAttributesSet?: CustomAttributeSet; } const emptyList: CustomAttribute[] = []; /** avoid re-renders **/ -const UserInfo = ({localTime, showCustomStatus, showLocalTime, showNickname, showPosition, user, enableCustomAttributes}: Props) => { +const UserInfo = ({ + localTime, + showCustomStatus, + showLocalTime, + showNickname, + showPosition, + user, + enableCustomAttributes, + customAttributesSet, +}: Props) => { const customStatus = getUserCustomStatus(user); const serverUrl = useServerUrl(); const [customAttributes, setCustomAttributes] = useState(emptyList); - const lastRequest = useRef(0); + // Initial load from database and server if customAttributesSet is not provided useEffect(() => { if (enableCustomAttributes) { - const fetchData = async () => { + // If customAttributesSet is provided by the parent, use it + if (customAttributesSet && Object.keys(customAttributesSet).length > 0) { + setCustomAttributes(Object.values(customAttributesSet).sort(sortCustomProfileAttributes)); + } + + const fetchFromServer = async () => { const reqTime = Date.now(); lastRequest.current = reqTime; - const {attributes, error} = await fetchCustomAttributes(serverUrl, user.id, true); - if (!error && lastRequest.current === reqTime) { + const {attributes, error} = await fetchCustomProfileAttributes(serverUrl, user.id, true); + if (!error && lastRequest.current === reqTime && Object.keys(attributes).length > 0) { const attributesList = Object.values(attributes).sort(sortCustomProfileAttributes); setCustomAttributes(attributesList); - } else { - setCustomAttributes(emptyList); } }; - fetchData(); + fetchFromServer(); } else { setCustomAttributes(emptyList); } - }, [enableCustomAttributes, serverUrl, user.id]); + }, [enableCustomAttributes, serverUrl, user.id, customAttributesSet]); return ( <> diff --git a/app/screens/user_profile/user_profile.tsx b/app/screens/user_profile/user_profile.tsx index 3defa4646..bd8e3fa17 100644 --- a/app/screens/user_profile/user_profile.tsx +++ b/app/screens/user_profile/user_profile.tsx @@ -20,6 +20,7 @@ import UserProfileOptions, {type OptionsType} from './options'; import UserProfileTitle, {HEADER_TEXT_HEIGHT} from './title'; import UserInfo from './user_info'; +import type {CustomAttributeSet} from '@typings/api/custom_profile_attributes'; import type UserModel from '@typings/database/models/servers/user'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -47,6 +48,7 @@ type Props = { usernameOverride?: string; hideGuestTags: boolean; enableCustomAttributes: boolean; + customAttributesSet?: CustomAttributeSet; } const TITLE_HEIGHT = 118; @@ -88,6 +90,7 @@ const UserProfile = ({ usernameOverride, hideGuestTags, enableCustomAttributes, + customAttributesSet, }: Props) => { const {formatMessage, locale} = useIntl(); const serverUrl = useServerUrl(); @@ -202,6 +205,7 @@ const UserProfile = ({ showLocalTime={showLocalTime} user={user} enableCustomAttributes={enableCustomAttributes} + customAttributesSet={customAttributesSet} /> )} {manageMode && channelId && (canManageAndRemoveMembers || canChangeMemberRoles) && diff --git a/app/utils/user/index.test.ts b/app/utils/user/index.test.ts index ef7bbf434..70a278c36 100644 --- a/app/utils/user/index.test.ts +++ b/app/utils/user/index.test.ts @@ -11,6 +11,8 @@ import TestHelper from '@test/test_helper'; import { confirmOutOfOfficeDisabled, + convertProfileAttributesToCustomAttributes, + convertToAttributesMap, displayGroupMessageName, displayUsername, filterProfilesMatchingTerm, @@ -39,6 +41,7 @@ import { removeUserFromList, } from './index'; +import type {CustomAttribute, CustomAttributeSet} from '@typings/api/custom_profile_attributes'; import type {IntlShape} from 'react-intl'; describe('displayUsername', () => { @@ -677,3 +680,201 @@ describe('confirmOutOfOfficeDisabled', () => { expect(updateStatus).toHaveBeenCalledWith('online'); }); }); + +describe('convertToAttributesMap', () => { + it('should convert an array of custom attributes to a map', () => { + const attributes: CustomAttribute[] = [ + {id: 'attr1', name: 'Attribute 1', value: 'value1', sort_order: 1}, + {id: 'attr2', name: 'Attribute 2', value: 'value2', sort_order: 2}, + ]; + const result = convertToAttributesMap(attributes); + expect(result).toEqual({ + attr1: {id: 'attr1', name: 'Attribute 1', value: 'value1', sort_order: 1}, + attr2: {id: 'attr2', name: 'Attribute 2', value: 'value2', sort_order: 2}, + }); + }); + + it('should return the input if it is already a map', () => { + const attributesMap: CustomAttributeSet = { + attr1: {id: 'attr1', name: 'Attribute 1', value: 'value1', sort_order: 1}, + attr2: {id: 'attr2', name: 'Attribute 2', value: 'value2', sort_order: 2}, + }; + const result = convertToAttributesMap(attributesMap); + expect(result).toBe(attributesMap); + }); + + it('should handle empty array input', () => { + const result = convertToAttributesMap([]); + expect(result).toEqual({}); + }); + + it('should handle array with single attribute', () => { + const attributes: CustomAttribute[] = [{id: 'attr1', name: 'Attribute 1', value: 'value1', sort_order: 1}]; + const result = convertToAttributesMap(attributes); + expect(result).toEqual({ + attr1: {id: 'attr1', name: 'Attribute 1', value: 'value1', sort_order: 1}, + }); + }); + + it('should handle attributes with missing optional fields', () => { + const attributes: CustomAttribute[] = [ + {id: 'attr1', name: 'Attribute 1', value: ''}, + {id: 'attr2', name: 'Attribute 2', value: 'value2'}, + ]; + const result = convertToAttributesMap(attributes); + expect(result).toEqual({ + attr1: {id: 'attr1', name: 'Attribute 1', value: ''}, + attr2: {id: 'attr2', name: 'Attribute 2', value: 'value2'}, + }); + }); +}); + +describe('convertProfileAttributesToCustomAttributes', () => { + const mockFields = [ + TestHelper.fakeCustomProfileFieldModel({ + id: 'field1', + name: 'Field 1', + type: 'text', + attrs: {sort_order: 1}, + }), + TestHelper.fakeCustomProfileFieldModel({ + id: 'field2', + name: 'Field 2', + type: 'text', + attrs: {sort_order: 2}, + }), + ]; + + const mockAttributes = [ + TestHelper.fakeCustomProfileAttributeModel({ + fieldId: 'field1', + value: 'value1', + }), + TestHelper.fakeCustomProfileAttributeModel({ + fieldId: 'field2', + value: 'value2', + }), + ]; + + it('should convert profile attributes to custom attributes', () => { + const result = convertProfileAttributesToCustomAttributes(mockAttributes, mockFields); + expect(result).toEqual([ + { + id: 'field1', + name: 'Field 1', + value: 'value1', + sort_order: 1, + }, + { + id: 'field2', + name: 'Field 2', + value: 'value2', + sort_order: 2, + }, + ]); + }); + + it('should handle missing fields', () => { + const attributes = [TestHelper.fakeCustomProfileAttributeModel({ + fieldId: 'unknown_field', + value: 'value', + })]; + const result = convertProfileAttributesToCustomAttributes(attributes, mockFields); + expect(result).toEqual([{ + id: 'unknown_field', + name: 'unknown_field', + value: 'value', + sort_order: Number.MAX_SAFE_INTEGER, + }]); + }); + + it('should handle missing sort_order in field attrs', () => { + const fields = [TestHelper.fakeCustomProfileFieldModel({ + id: 'field1', + name: 'Field 1', + type: 'text', + attrs: {}, + })]; + const attributes = [TestHelper.fakeCustomProfileAttributeModel({ + fieldId: 'field1', + value: 'value1', + })]; + const result = convertProfileAttributesToCustomAttributes(attributes, fields); + expect(result).toEqual([{ + id: 'field1', + name: 'Field 1', + value: 'value1', + sort_order: Number.MAX_SAFE_INTEGER, + }]); + }); + + it('should handle empty attributes array', () => { + const result = convertProfileAttributesToCustomAttributes([], mockFields); + expect(result).toEqual([]); + }); + + it('should handle null attributes', () => { + const result = convertProfileAttributesToCustomAttributes(null, mockFields); + expect(result).toEqual([]); + }); + + it('should handle undefined attributes', () => { + const result = convertProfileAttributesToCustomAttributes(undefined, mockFields); + expect(result).toEqual([]); + }); + + it('should handle null fields', () => { + const result = convertProfileAttributesToCustomAttributes(mockAttributes, null); + expect(result).toEqual([ + { + id: 'field1', + name: 'field1', + value: 'value1', + sort_order: Number.MAX_SAFE_INTEGER, + }, + { + id: 'field2', + name: 'field2', + value: 'value2', + sort_order: Number.MAX_SAFE_INTEGER, + }, + ]); + }); + + it('should handle undefined fields', () => { + const result = convertProfileAttributesToCustomAttributes(mockAttributes, undefined); + expect(result).toEqual([ + { + id: 'field1', + name: 'field1', + value: 'value1', + sort_order: Number.MAX_SAFE_INTEGER, + }, + { + id: 'field2', + name: 'field2', + value: 'value2', + sort_order: Number.MAX_SAFE_INTEGER, + }, + ]); + }); + + it('should sort attributes when sort function is provided', () => { + const customSort = (a: CustomAttribute, b: CustomAttribute) => (b.sort_order ?? 0) - (a.sort_order ?? 0); + const result = convertProfileAttributesToCustomAttributes(mockAttributes, mockFields, customSort); + expect(result).toEqual([ + { + id: 'field2', + name: 'Field 2', + value: 'value2', + sort_order: 2, + }, + { + id: 'field1', + name: 'Field 1', + value: 'value1', + sort_order: 1, + }, + ]); + }); +}); diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index 64cf90bfb..2a78eb59e 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -10,7 +10,8 @@ import {CustomStatusDurationEnum} from '@constants/custom_status'; import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n'; import {toTitleCase} from '@utils/helpers'; -import type {CustomAttribute} from '@typings/api/custom_profile_attributes'; +import type {CustomProfileFieldModel, CustomProfileAttributeModel} from '@database/models/server'; +import type {CustomAttribute, CustomAttributeSet} from '@typings/api/custom_profile_attributes'; import type UserModel from '@typings/database/models/servers/user'; import type {IntlShape} from 'react-intl'; @@ -407,3 +408,54 @@ export const sortCustomProfileAttributes = (a: CustomAttribute, b: CustomAttribu const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER; return orderA === orderB ? a.name.localeCompare(b.name) : orderA - orderB; }; + +/** + * Converts an array of custom profile attributes to a map of attributes by their id. + * @param attributesToConvert - The array of custom profile attributes to convert + * @returns A map of attributes by their id + */ +export const convertToAttributesMap = (attributesToConvert: CustomAttributeSet | CustomAttribute[]): CustomAttributeSet => { + if (!Array.isArray(attributesToConvert)) { + return attributesToConvert as CustomAttributeSet; + } + const attributesMap: CustomAttributeSet = {}; + attributesToConvert.forEach((attr) => { + attributesMap[attr.id] = attr; + }); + return attributesMap; +}; + +/** + * Convert custom profile attributes to the UI-ready CustomAttribute format + * @param attributes - Array of custom profile attribute models + * @param fields - Array of custom profile field models + * @param sortFn - Optional sort function + * @returns Array of formatted CustomAttribute objects + */ +export const convertProfileAttributesToCustomAttributes = ( + attributes: CustomProfileAttributeModel[] | null | undefined, + fields: CustomProfileFieldModel[] | null | undefined, + sortFn?: (a: CustomAttribute, b: CustomAttribute) => number, +): CustomAttribute[] => { + if (!attributes?.length) { + return []; + } + + const fieldsMap = new Map(); + fields?.forEach((field) => { + fieldsMap.set(field.id, { + name: field.name, + sort_order: field.attrs?.sort_order || Number.MAX_SAFE_INTEGER, + }); + }); + + const customAttrs = attributes.map((attr) => ({ + id: attr.fieldId, + name: fieldsMap.get(attr.fieldId)?.name || attr.fieldId, + value: attr.value, + sort_order: fieldsMap.get(attr.fieldId)?.sort_order || Number.MAX_SAFE_INTEGER, + })); + + // Sort if a sort function is provided + return sortFn ? customAttrs.sort(sortFn) : customAttrs; +}; diff --git a/test/test_helper.ts b/test/test_helper.ts index 2c50d2846..4fc2ed89f 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -25,6 +25,8 @@ import type ChannelModel from '@typings/database/models/servers/channel'; import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; +import type CustomProfileAttributeModel from '@typings/database/models/servers/custom_profile_attribute'; +import type CustomProfileFieldModel from '@typings/database/models/servers/custom_profile_field'; import type DraftModel from '@typings/database/models/servers/draft'; import type FileModel from '@typings/database/models/servers/file'; import type GroupModel from '@typings/database/models/servers/group'; @@ -865,6 +867,35 @@ class TestHelperSingleton { }; }; + fakeCustomProfileFieldModel = (overwrite?: Partial): CustomProfileFieldModel => { + return { + ...this.fakeModel(), + groupId: '', + name: `field_${this.generateId()}`, + type: 'text', + targetId: '', + targetType: 'user', + createAt: Date.now(), + updateAt: Date.now(), + deleteAt: 0, + attrs: {sort_order: 1}, + customProfileAttributes: this.fakeQuery([]), + ...overwrite, + }; + }; + + fakeCustomProfileAttributeModel = (overwrite?: Partial): CustomProfileAttributeModel => { + return { + ...this.fakeModel(), + fieldId: this.generateId(), + userId: this.generateId(), + value: '', + field: this.fakeRelation(), + user: this.fakeRelation(), + ...overwrite, + }; + }; + fakeRole = (overwrite?: Partial): Role => { return { id: this.generateId(), diff --git a/types/screens/edit_profile.ts b/types/screens/edit_profile.ts index e1f0c2213..372cfd75d 100644 --- a/types/screens/edit_profile.ts +++ b/types/screens/edit_profile.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import type {AvailableScreens} from './navigation'; +import type {CustomProfileAttributeModel, CustomProfileFieldModel} from '@database/models/server'; import type {FieldProps} from '@screens/edit_profile/components/field'; import type {CustomAttributeSet} from '@typings/api/custom_profile_attributes'; import type UserModel from '@typings/database/models/servers/user'; @@ -27,6 +28,9 @@ export type EditProfileProps = { lockedPosition: boolean; lockedPicture: boolean; enableCustomAttributes: boolean; + userCustomAttributes: CustomProfileAttributeModel[]; + customFields: CustomProfileFieldModel[]; + customAttributesSet?: CustomAttributeSet; }; export type NewProfileImage = { localPath?: string; isRemoved?: boolean };