[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>
This commit is contained in:
Guillermo Vayá 2025-05-27 16:24:40 +02:00 committed by GitHub
parent e56423ce28
commit db569fe2c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1668 additions and 625 deletions

View file

@ -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);
});
});

View file

@ -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<string, CustomAttribute> = {};
try {
const client = NetworkManager.getClient(serverUrl);
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
let fields: CustomProfileField[] = [];
let attrValues: Record<string, string> = {};
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<Promise<Model[]>> = [];
const fieldModelPromises: Array<Promise<Model[]>> = [];
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};
}
};

View file

@ -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', {});

View file

@ -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;
}
}

View file

@ -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();
});
});
});

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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);

View file

@ -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<Model[]>;
handleCustomProfileAttributes: ({attributes, prepareRecordsOnly}: HandleCustomProfileAttributesArgs) => Promise<Model[]>;
}
const CustomProfileHandler = <TBase extends Constructor<ServerDataOperatorBase>>(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 = <TBase extends Constructor<ServerDataOperatorBase>>
}
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',

View file

@ -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
});
});
});

View file

@ -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<CustomAttribute[]> => {
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<CustomProfileFieldModel>(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());
};

View file

@ -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(
<EditProfile
componentId={AvailableScreens.EDIT_PROFILE}
currentUser={mockCurrentUser}
isModal={false}
isTablet={false}
lockedFirstName={false}
lockedLastName={false}
lockedNickname={false}
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={dbAttributesSet}
/>,
);
// 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(
<EditProfile
componentId={AvailableScreens.EDIT_PROFILE}
currentUser={mockCurrentUser}
isModal={false}
isTablet={false}
lockedFirstName={false}
lockedLastName={false}
lockedNickname={false}
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={serverAttributesSet}
/>,
);
});
// 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(
<EditProfile
componentId={AvailableScreens.EDIT_PROFILE}
currentUser={mockCurrentUser}
isModal={false}
isTablet={false}
lockedFirstName={false}
lockedLastName={false}
lockedNickname={false}
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={{}}
/>,
);
// 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(
<EditProfile
componentId={AvailableScreens.EDIT_PROFILE}
currentUser={mockCurrentUser}
isModal={false}
isTablet={false}
lockedFirstName={false}
lockedLastName={false}
lockedNickname={false}
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={dbAttributesSet}
/>,
);
});
// 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(
<EditProfile
componentId={AvailableScreens.EDIT_PROFILE}
currentUser={mockCurrentUser}
isModal={false}
isTablet={false}
lockedFirstName={false}
lockedLastName={false}
lockedNickname={false}
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={updatedDbAttributesSet}
/>,
);
});
// 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');
});
});

View file

@ -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<unknown>();
const [usernameError, setUsernameError] = useState<unknown>();
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;

View file

@ -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,
};
});

View file

@ -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,
};
});

View file

@ -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(
<UserInfo {...baseProps}/>,
// Render with initial customAttributesSet
const {queryByText, rerender} = renderWithIntlAndTheme(
<UserInfo
{...baseProps}
customAttributesSet={{}}
/>,
);
// 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(
<UserInfo
{...baseProps}
customAttributesSet={serverAttributes}
/>,
);
// 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(
<UserInfo {...baseProps}/>,
test('should update UI when database changes via observables', async () => {
// Set up component with initial empty attributes
const {queryByText, rerender} = renderWithIntlAndTheme(
<UserInfo
{...baseProps}
customAttributesSet={{}}
/>,
);
// Initially there should be no custom attributes
expect(queryByText('Engineering')).toBeNull();
// Simulate receiving attributes from props (as would happen from parent observable)
rerender(
<UserInfo
{...baseProps}
customAttributesSet={serverAttributes}
/>,
);
// 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(
<UserInfo
{...baseProps}
customAttributesSet={updatedAttributes}
/>,
);
// 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(
<UserInfo
{...baseProps}
enableCustomAttributes={false}
/>,
);
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();
});
});

View file

@ -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<CustomAttribute[]>(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 (
<>

View file

@ -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) &&

View file

@ -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,
},
]);
});
});

View file

@ -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;
};

View file

@ -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>): 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>): CustomProfileAttributeModel => {
return {
...this.fakeModel(),
fieldId: this.generateId(),
userId: this.generateId(),
value: '',
field: this.fakeRelation(),
user: this.fakeRelation(),
...overwrite,
};
};
fakeRole = (overwrite?: Partial<Role>): Role => {
return {
id: this.generateId(),

View file

@ -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 };