[MM-62700] User Attribute types (#8903)

* add basic types to user_profile

* fix storing multiselect in DB

* fix storing multi-select on db

* add tests

* add tests for select-multiselect

* imporove testing

* improve intl, add tests

* address comments and tests

* review comments

* more test refactoring

* remove styling tests

* remove styling tests

* small improvements

* nitpick

* improve messaging

* safer stringify

* fix tests
This commit is contained in:
Guillermo Vayá 2025-06-12 10:59:13 +02:00 committed by GitHub
parent efeabb48a2
commit 972bd34da6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3238 additions and 86 deletions

View file

@ -50,18 +50,21 @@ describe('Custom Profile Attributes', () => {
expect(result.attributes!.field1).toEqual({
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
sort_order: 1,
});
expect(result.attributes!.field2).toEqual({
id: 'field2',
name: 'Field 2',
type: 'text',
value: '',
sort_order: 2,
});
expect(result.attributes!.field3).toEqual({
id: 'field3',
name: 'Field 3',
type: 'text',
value: 'value3',
sort_order: 3,
});
@ -86,13 +89,17 @@ describe('Custom Profile Attributes', () => {
expect(result.attributes!.field1).toEqual({
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
sort_order: undefined,
});
expect(result.attributes!.field2).toBeUndefined();
expect(result.attributes!.field3).toEqual({
id: 'field3',
name: 'Field 3',
type: 'text',
value: 'value3',
sort_order: undefined,
});
});
@ -138,11 +145,13 @@ describe('Custom Profile Attributes', () => {
field1: {
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'new value 1',
},
field2: {
id: 'field2',
name: 'Field 2',
type: 'text',
value: 'new value 2',
},
};
@ -165,6 +174,7 @@ describe('Custom Profile Attributes', () => {
field1: {
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'new value 1',
},
};
@ -173,4 +183,99 @@ describe('Custom Profile Attributes', () => {
expect(result.error).toBeDefined();
expect(result.success).toBe(false);
});
// New tests for array handling functionality
it('fetchCustomProfileAttributes - handles multiselect array values correctly', async () => {
mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([
{id: 'multiselect1', name: 'Multiselect 1', type: 'multiselect', attrs: {sort_order: 1}},
{id: 'select1', name: 'Select 1', type: 'select', attrs: {sort_order: 2}},
]);
mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({
multiselect1: ['opt1', 'opt2', 'opt3'],
select1: 'single_opt',
});
const result = await fetchCustomProfileAttributes(serverUrl, 'user1');
expect(result.error).toBeUndefined();
expect(result.attributes).toBeDefined();
expect(Object.keys(result.attributes!)).toHaveLength(2);
// Array should be serialized to JSON string
expect(result.attributes!.multiselect1).toEqual({
id: 'multiselect1',
name: 'Multiselect 1',
type: 'multiselect',
value: '["opt1","opt2","opt3"]',
sort_order: 1,
});
// Single value should remain as string
expect(result.attributes!.select1).toEqual({
id: 'select1',
name: 'Select 1',
type: 'select',
value: 'single_opt',
sort_order: 2,
});
});
it('fetchCustomProfileAttributes - handles empty arrays correctly', async () => {
mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([
{id: 'multiselect1', name: 'Multiselect 1', type: 'multiselect'},
]);
mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({
multiselect1: [],
});
const result = await fetchCustomProfileAttributes(serverUrl, 'user1');
expect(result.error).toBeUndefined();
expect(result.attributes).toBeDefined();
expect(result.attributes!.multiselect1).toEqual({
id: 'multiselect1',
name: 'Multiselect 1',
type: 'multiselect',
value: '[]',
});
});
it('fetchCustomProfileAttributes - handles null and undefined values correctly', async () => {
mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([
{id: 'field1', name: 'Field 1', type: 'text'},
{id: 'field2', name: 'Field 2', type: 'multiselect'},
]);
mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({
field1: null,
field2: undefined,
});
const result = await fetchCustomProfileAttributes(serverUrl, 'user1');
expect(result.error).toBeUndefined();
expect(result.attributes).toBeDefined();
expect(result.attributes!.field1.value).toBe('');
expect(result.attributes!.field2.value).toBe('');
});
it('fetchCustomProfileAttributes - handles mixed data types correctly', async () => {
mockClient.getCustomProfileAttributeFields = jest.fn().mockResolvedValue([
{id: 'text_field', name: 'Text Field', type: 'text'},
{id: 'number_field', name: 'Number Field', type: 'number'},
{id: 'multiselect_field', name: 'Multiselect Field', type: 'multiselect'},
{id: 'select_field', name: 'Select Field', type: 'select'},
]);
mockClient.getCustomProfileAttributeValues = jest.fn().mockResolvedValue({
text_field: 'text value',
number_field: 42,
multiselect_field: ['opt1', 'opt2'],
select_field: 'single_opt',
});
const result = await fetchCustomProfileAttributes(serverUrl, 'user1');
expect(result.error).toBeUndefined();
expect(result.attributes).toBeDefined();
expect(result.attributes!.text_field.value).toBe('text value');
expect(result.attributes!.number_field.value).toBe('42');
expect(result.attributes!.multiselect_field.value).toBe('["opt1","opt2"]');
expect(result.attributes!.select_field.value).toBe('single_opt');
});
});

View file

@ -6,7 +6,8 @@ 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 {logError} from '@utils/log';
import {convertValueForServer} from '@utils/user';
import type Model from '@nozbe/watermelondb/Model';
import type {CustomProfileField, CustomAttribute, CustomAttributeSet, CustomProfileAttribute, UserCustomProfileAttributeSimple} from '@typings/api/custom_profile_attributes';
@ -25,15 +26,16 @@ export const fetchCustomProfileAttributes = async (serverUrl: string, userId: st
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
let fields: CustomProfileField[] = [];
let attrValues: Record<string, string> = {};
let attrValues: Record<string, 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));
logError('error on fetchCustomProfileAttributes get fields and attr values', getFullErrorMessage(err));
return {attributes, error: err};
}
@ -46,11 +48,25 @@ export const fetchCustomProfileAttributes = async (serverUrl: string, userId: st
const attributeModelPromises: Array<Promise<Model[]>> = [];
const fieldModelPromises: Array<Promise<Model[]>> = [];
for (const field of fields) {
const value = attrValues[field.id] || '';
const rawValue = attrValues[field.id];
let value = '';
// Handle different value types properly
if (rawValue !== undefined && rawValue !== null) {
if (Array.isArray(rawValue)) {
// For arrays (multiselect), serialize to JSON string
value = JSON.stringify(rawValue);
} else {
// For other types, convert to string
value = String(rawValue);
}
}
if (!filterEmpty || value) {
attributes[field.id] = {
id: field.id,
name: field.name,
type: field.type || 'text',
value,
sort_order: field.attrs?.sort_order,
};
@ -87,13 +103,13 @@ export const fetchCustomProfileAttributes = async (serverUrl: string, userId: st
}
} catch (err) {
logDebug('error on fetchCustomProfileAttributes field iteration', getFullErrorMessage(err));
logError('error on fetchCustomProfileAttributes field iteration', getFullErrorMessage(err));
return {attributes, error: err};
}
return {attributes, error: undefined};
} catch (error) {
logDebug('error on fetchCustomProfileAttributes', getFullErrorMessage(error));
logError('error on fetchCustomProfileAttributes', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {attributes, error};
}
@ -114,7 +130,7 @@ export const updateCustomProfileAttributes = async (serverUrl: string, userId: s
// Convert attributes to the format expected by the API
Object.entries(attributes).forEach(([id, attr]) => {
attributeValues[id] = attr.value;
attributeValues[id] = convertValueForServer(attr.value, attr.type);
});
// Update on the server
@ -137,7 +153,7 @@ export const updateCustomProfileAttributes = async (serverUrl: string, userId: s
return {success: true, error: undefined};
} catch (error) {
logDebug('error on updateCustomProfileAttributes', getFullErrorMessage(error));
logError('error on updateCustomProfileAttributes', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {success: false, error};
}

View file

@ -1,6 +1,8 @@
// 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';
@ -35,6 +37,8 @@ import {
autoUpdateTimezone,
fetchTeamAndChannelMembership,
getAllSupportedTimezones,
fetchCustomAttributes,
updateCustomAttributes,
} from './user';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -492,3 +496,316 @@ describe('user status', () => {
expect(result.error).toBeUndefined();
});
});
describe('fetchCustomAttributes', () => {
beforeEach(() => {
// Reset mocks before each test
mockClient.getCustomProfileAttributeFields.mockReset();
mockClient.getCustomProfileAttributeValues.mockReset();
});
it('fetchCustomAttributes - handle not found database', async () => {
// Mock NetworkManager.getClient to throw an error for invalid server URL
const originalGetClient = NetworkManager.getClient;
NetworkManager.getClient = jest.fn((url: string) => {
if (url === 'foo') {
throw new Error('foo client not found');
}
return originalGetClient(url);
});
const result = await fetchCustomAttributes('foo', user1.id);
expect(result?.error).toBeDefined();
// Restore original implementation
NetworkManager.getClient = originalGetClient;
});
it('fetchCustomAttributes - base case with fields and values', async () => {
const mockFields = [
{
id: 'field1',
name: 'Field 1',
type: 'text',
attrs: {sort_order: 1},
},
{
id: 'field2',
name: 'Field 2',
type: 'select',
attrs: {sort_order: 2},
},
];
const mockValues = {
field1: 'value1',
field2: 'value2',
};
mockClient.getCustomProfileAttributeFields.mockResolvedValue(mockFields);
mockClient.getCustomProfileAttributeValues.mockResolvedValue(mockValues);
const result = await fetchCustomAttributes(serverUrl, user1.id);
expect(result).toBeDefined();
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',
type: 'text',
value: 'value1',
sort_order: 1,
});
expect(result.attributes.field2).toEqual({
id: 'field2',
name: 'Field 2',
type: 'select',
value: 'value2',
sort_order: 2,
});
});
it('fetchCustomAttributes - no fields', async () => {
mockClient.getCustomProfileAttributeFields.mockResolvedValue([]);
mockClient.getCustomProfileAttributeValues.mockResolvedValue({});
const result = await fetchCustomAttributes(serverUrl, user1.id);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.attributes).toEqual({});
});
it('fetchCustomAttributes - filterEmpty true with empty values', async () => {
const mockFields = [
{
id: 'field1',
name: 'Field 1',
type: 'text',
attrs: {sort_order: 1},
},
{
id: 'field2',
name: 'Field 2',
type: 'text',
attrs: {sort_order: 2},
},
{
id: 'field3',
name: 'Field 3',
type: 'text',
attrs: {sort_order: 3},
},
];
const mockValues = {
field1: 'value1',
field2: '',
// field3 is missing entirely
};
mockClient.getCustomProfileAttributeFields.mockResolvedValue(mockFields);
mockClient.getCustomProfileAttributeValues.mockResolvedValue(mockValues);
const result = await fetchCustomAttributes(serverUrl, user1.id, true);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.attributes).toBeDefined();
expect(Object.keys(result.attributes)).toHaveLength(1);
expect(result.attributes.field1).toBeDefined();
expect(result.attributes.field2).toBeUndefined();
expect(result.attributes.field3).toBeUndefined();
});
it('fetchCustomAttributes - filterEmpty false includes empty values', async () => {
const mockFields = [
{
id: 'field1',
name: 'Field 1',
type: 'text',
attrs: {sort_order: 1},
},
{
id: 'field2',
name: 'Field 2',
type: 'text',
attrs: {sort_order: 2},
},
];
const mockValues = {
field1: 'value1',
field2: '',
};
mockClient.getCustomProfileAttributeFields.mockResolvedValue(mockFields);
mockClient.getCustomProfileAttributeValues.mockResolvedValue(mockValues);
const result = await fetchCustomAttributes(serverUrl, user1.id, false);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.attributes).toBeDefined();
expect(Object.keys(result.attributes)).toHaveLength(2);
expect(result.attributes.field1).toBeDefined();
expect(result.attributes.field2).toBeDefined();
expect(result.attributes.field2.value).toBe('');
});
it('fetchCustomAttributes - handles array values', async () => {
const mockFields = [
{
id: 'field1',
name: 'Field 1',
type: 'multiselect',
attrs: {sort_order: 1},
},
{
id: 'field2',
name: 'Field 2',
type: 'text',
attrs: {sort_order: 2},
},
];
const mockValues = {
field1: ['option1', 'option2', 'option3'],
field2: 'text value',
};
mockClient.getCustomProfileAttributeFields.mockResolvedValue(mockFields);
mockClient.getCustomProfileAttributeValues.mockResolvedValue(mockValues);
const result = await fetchCustomAttributes(serverUrl, user1.id);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.attributes).toBeDefined();
expect(result.attributes.field1.value).toBe('["option1","option2","option3"]');
expect(result.attributes.field2.value).toBe('text value');
});
it('fetchCustomAttributes - handles missing attrs', async () => {
const mockFields = [
{
id: 'field1',
name: 'Field 1',
type: 'text',
// No attrs property
},
];
const mockValues = {
field1: 'value1',
};
mockClient.getCustomProfileAttributeFields.mockResolvedValue(mockFields);
mockClient.getCustomProfileAttributeValues.mockResolvedValue(mockValues);
const result = await fetchCustomAttributes(serverUrl, user1.id);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.attributes).toBeDefined();
expect(result.attributes.field1.sort_order).toBeUndefined();
});
it('fetchCustomAttributes - handles API error', async () => {
mockClient.getCustomProfileAttributeFields.mockRejectedValue(new Error('API Error'));
const result = await fetchCustomAttributes(serverUrl, user1.id);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(result.attributes).toEqual({});
});
});
describe('updateCustomAttributes', () => {
beforeEach(() => {
// Reset mocks before each test
mockClient.updateCustomProfileAttributeValues.mockReset();
});
it('updateCustomAttributes - handle not found database', async () => {
// Mock NetworkManager.getClient to throw an error for invalid server URL
const originalGetClient = NetworkManager.getClient;
NetworkManager.getClient = jest.fn((url: string) => {
if (url === 'foo') {
throw new Error('foo client not found');
}
return originalGetClient(url);
});
const attributes = {
field1: {
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
sort_order: 1,
},
};
const result = await updateCustomAttributes('foo', attributes);
expect(result?.error).toBeDefined();
expect(result.success).toBe(false);
// Restore original implementation
NetworkManager.getClient = originalGetClient;
});
it('updateCustomAttributes - base case', async () => {
const attributes = {
field1: {
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
sort_order: 1,
},
field2: {
id: 'field2',
name: 'Field 2',
type: 'select',
value: 'value2',
sort_order: 2,
},
};
mockClient.updateCustomProfileAttributeValues.mockResolvedValue(undefined);
const result = await updateCustomAttributes(serverUrl, attributes);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.success).toBe(true);
expect(mockClient.updateCustomProfileAttributeValues).toHaveBeenCalledWith({
field1: 'value1',
field2: 'value2',
});
});
it('updateCustomAttributes - handles API error', async () => {
const attributes = {
field1: {
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
sort_order: 1,
},
};
mockClient.updateCustomProfileAttributeValues.mockRejectedValue(new Error('API Error'));
const result = await updateCustomAttributes(serverUrl, attributes);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(result.success).toBe(false);
});
it('updateCustomAttributes - empty attributes', async () => {
const attributes = {};
mockClient.updateCustomProfileAttributeValues.mockResolvedValue(undefined);
const result = await updateCustomAttributes(serverUrl, attributes);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.success).toBe(true);
expect(mockClient.updateCustomProfileAttributeValues).toHaveBeenCalledWith({});
});
});

View file

@ -896,7 +896,8 @@ export const fetchCustomAttributes = async (serverUrl: string, userId: string, f
attributes[field.id] = {
id: field.id,
name: field.name,
value,
type: field.type,
value: Array.isArray(value) ? JSON.stringify(value) : value,
sort_order: field.attrs?.sort_order,
};
}

View file

@ -182,7 +182,7 @@ function AutoCompleteSelector({
if (onSelected) {
onSelected(selectedOptions);
}
}, [teammateNameDisplay, intl, dataSource]);
}, [teammateNameDisplay, intl, dataSource, onSelected]);
// Handle the text for the default value.
useEffect(() => {
@ -202,7 +202,7 @@ function AutoCompleteSelector({
Promise.all(namePromises).then((names) => {
setItemText(names.join(', '));
});
}, []);
}, [dataSource, teammateNameDisplay, intl, options, selected, serverUrl]);
return (
<View style={style.container}>

View file

@ -53,6 +53,8 @@ export default class CustomProfileFieldModel extends Model implements CustomProf
/** attrs : Any extra properties of the field */
@json('attrs', safeParseJSON) attrs!: {
sort_order?: number;
value_type?: string;
options?: Array<{id: string; name: string; color?: string}>;
[key: string]: unknown;
} | null;

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent} from '@testing-library/react-native';
import {fireEvent, screen} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {renderWithIntl} from '@test/intl-test-helper';
@ -11,6 +11,17 @@ import ProfileForm from './form';
import type {CustomAttributeSet} from '@typings/api/custom_profile_attributes';
// Mock AutocompleteSelector to avoid database dependency
jest.mock('@components/autocomplete_selector', () => ({
__esModule: true,
default: jest.fn(),
}));
const MockAutocompleteSelector = jest.requireMock('@components/autocomplete_selector').default;
MockAutocompleteSelector.mockImplementation((props: any) =>
React.createElement('AutocompleteSelector', {...props}),
);
describe('ProfileForm', () => {
const baseProps: ComponentProps<typeof ProfileForm> = {
canSave: false,
@ -62,11 +73,13 @@ describe('ProfileForm', () => {
field1: {
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
},
field2: {
id: 'field2',
name: 'Field 2',
type: 'text',
value: 'value2',
},
},
@ -94,6 +107,7 @@ describe('ProfileForm', () => {
field1: {
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
},
},
@ -134,18 +148,21 @@ describe('ProfileForm', () => {
name: 'Department',
value: 'Engineering',
sort_order: 1,
type: 'text',
},
attr2: {
id: 'attr2',
name: 'Location',
value: 'Remote',
sort_order: 0,
type: 'text',
},
attr3: {
id: 'attr3',
name: 'Start Date',
value: '2023',
sort_order: 2,
type: 'text',
},
};
@ -171,4 +188,252 @@ describe('ProfileForm', () => {
expect(attributeFields[1].props.testID).toBe('edit_profile_form.customAttributes.attr1'); // sort_order: 1
expect(attributeFields[2].props.testID).toBe('edit_profile_form.customAttributes.attr3'); // sort_order: 2
});
it('should render SelectField for select type custom attributes', () => {
const customFields = [
TestHelper.fakeCustomProfileFieldModel({
id: 'department',
name: 'Department',
type: 'select',
attrs: {
options: [
{id: 'eng', name: 'Engineering'},
{id: 'mkt', name: 'Marketing'},
{id: 'sales', name: 'Sales'},
],
},
}),
];
const props = {
...baseProps,
enableCustomAttributes: true,
customFields,
userInfo: {
...baseProps.userInfo,
customAttributes: {
department: {
id: 'department',
name: 'Department',
type: 'select',
value: 'eng',
},
},
},
};
renderWithIntl(
<ProfileForm {...props}/>,
);
expect(screen.getByTestId('edit_profile_form.customAttributes.department')).toBeTruthy();
});
it('should render SelectField for multiselect type custom attributes', () => {
const customFields = [
TestHelper.fakeCustomProfileFieldModel({
id: 'skills',
name: 'Skills',
type: 'multiselect',
attrs: {
options: [
{id: 'js', name: 'JavaScript'},
{id: 'react', name: 'React'},
{id: 'ts', name: 'TypeScript'},
],
},
}),
];
const props = {
...baseProps,
enableCustomAttributes: true,
customFields,
userInfo: {
...baseProps.userInfo,
customAttributes: {
skills: {
id: 'skills',
name: 'Skills',
type: 'multiselect',
value: 'js,react',
},
},
},
};
renderWithIntl(
<ProfileForm {...props}/>,
);
expect(screen.getByTestId('edit_profile_form.customAttributes.skills')).toBeTruthy();
});
it('should render SelectField for select type custom attributes with correct value', () => {
const customFields = [
TestHelper.fakeCustomProfileFieldModel({
id: 'department',
name: 'Department',
type: 'select',
attrs: {
options: [
{id: 'eng', name: 'Engineering'},
{id: 'mkt', name: 'Marketing'},
],
},
}),
];
const props = {
...baseProps,
enableCustomAttributes: true,
customFields,
userInfo: {
...baseProps.userInfo,
customAttributes: {
department: {
id: 'department',
name: 'Department',
type: 'select',
value: 'eng',
},
},
},
};
renderWithIntl(
<ProfileForm {...props}/>,
);
// Verify that the SelectField is rendered with the correct testID
expect(screen.getByTestId('edit_profile_form.customAttributes.department')).toBeTruthy();
});
it('should render SelectField for multiselect type custom attributes with correct value', () => {
const customFields = [
TestHelper.fakeCustomProfileFieldModel({
id: 'skills',
name: 'Skills',
type: 'multiselect',
attrs: {
options: [
{id: 'js', name: 'JavaScript'},
{id: 'react', name: 'React'},
{id: 'ts', name: 'TypeScript'},
],
},
}),
];
const props = {
...baseProps,
enableCustomAttributes: true,
customFields,
userInfo: {
...baseProps.userInfo,
customAttributes: {
skills: {
id: 'skills',
name: 'Skills',
type: 'multiselect',
value: 'js',
},
},
},
};
renderWithIntl(
<ProfileForm {...props}/>,
);
// Verify that the SelectField is rendered with the correct testID
expect(screen.getByTestId('edit_profile_form.customAttributes.skills')).toBeTruthy();
});
it('should render text field for custom attributes without field definition', () => {
const props = {
...baseProps,
enableCustomAttributes: true,
customFields: [], // No field definitions
userInfo: {
...baseProps.userInfo,
customAttributes: {
unknownField: {
id: 'unknownField',
name: 'Unknown Field',
type: 'text',
value: 'some value',
},
},
},
};
renderWithIntl(
<ProfileForm {...props}/>,
);
// Should render as text field since no field definition exists
expect(screen.getByTestId('edit_profile_form.customAttributes.unknownField')).toBeTruthy();
});
it('should render text field for custom attributes with unsupported type', () => {
const customFields = [
TestHelper.fakeCustomProfileFieldModel({
id: 'customField',
name: 'Custom Field',
type: 'unsupported_type',
attrs: {},
}),
];
const props = {
...baseProps,
enableCustomAttributes: true,
customFields,
userInfo: {
...baseProps.userInfo,
customAttributes: {
customField: {
id: 'customField',
name: 'Custom Field',
type: 'unsupported_type',
value: 'some value',
},
},
},
};
renderWithIntl(
<ProfileForm {...props}/>,
);
// Should render as text field for unsupported types
expect(screen.getByTestId('edit_profile_form.customAttributes.customField')).toBeTruthy();
});
it('should render ProfileForm without errors when custom fields have no field definitions', () => {
const props = {
...baseProps,
enableCustomAttributes: true,
customFields: [], // No field definitions
userInfo: {
...baseProps.userInfo,
customAttributes: {
unknownField: {
id: 'unknownField',
name: 'Unknown Field',
type: 'text',
value: 'some value',
},
},
},
};
const {getByTestId} = renderWithIntl(
<ProfileForm {...props}/>,
);
// Should render as a text field since no field definition exists
expect(getByTestId('edit_profile_form.customAttributes.unknownField')).toBeTruthy();
});
});

View file

@ -10,12 +10,14 @@ import useFieldRefs from '@hooks/field_refs';
import {t} from '@i18n';
import {getErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {sortCustomProfileAttributes} from '@utils/user';
import {sortCustomProfileAttributes, formatOptionsForSelector} from '@utils/user';
import DisabledFields from './disabled_fields';
import EmailField from './email_field';
import Field from './field';
import SelectField from './select_field';
import type {CustomProfileFieldModel} from '@database/models/server';
import type UserModel from '@typings/database/models/servers/user';
import type {FieldConfig, FieldSequence, UserInfo} from '@typings/screens/edit_profile';
@ -32,6 +34,7 @@ type Props = {
userInfo: UserInfo;
submitUser: () => void;
enableCustomAttributes?: boolean;
customFields?: CustomProfileFieldModel[];
}
const includesSsoService = (sso: string) => ['gitlab', 'google', 'office365'].includes(sso);
@ -87,7 +90,7 @@ const profileKeys = [FIRST_NAME_FIELD, LAST_NAME_FIELD, USERNAME_FIELD, EMAIL_FI
const ProfileForm = ({
canSave, currentUser, isTablet,
lockedFirstName, lockedLastName, lockedNickname, lockedPosition,
onUpdateField, userInfo, submitUser, error, enableCustomAttributes,
onUpdateField, userInfo, submitUser, error, enableCustomAttributes, customFields,
}: Props) => {
const theme = useTheme();
const intl = useIntl();
@ -109,6 +112,15 @@ const ProfileForm = ({
return total_custom_attrs === 0 ? profileKeys : [...profileKeys, ...newKeys];
}, [userInfo.customAttributes, total_custom_attrs]);
// Create a map of field definitions for quick lookup
const customFieldsMap = useMemo(() => {
const map = new Map<string, CustomProfileFieldModel>();
customFields?.forEach((field) => {
map.set(field.id, field);
});
return map;
}, [customFields]);
const userProfileFields: FieldSequence = useMemo(() => {
const service = currentUser.authService;
const fields: FieldSequence = {};
@ -250,13 +262,35 @@ const ProfileForm = ({
const renderCustomAttribute = (key: string, notLast: boolean) => {
const fieldID = getFieldID(key);
const customAttribute = userInfo.customAttributes[fieldID];
const fieldDefinition = customFieldsMap.get(fieldID);
// Check if this is a select or multiselect field
if (fieldDefinition && (fieldDefinition.type === 'select' || fieldDefinition.type === 'multiselect')) {
const options = formatOptionsForSelector(fieldDefinition);
return (
<SelectField
fieldKey={key}
label={customAttribute.name}
value={getValue(key)}
options={options}
isDisabled={userProfileFields[key].isDisabled}
onValueChange={onUpdateField}
onFocusNextField={onFocusNextField}
testID={`edit_profile_form.${key}`}
isMultiselect={fieldDefinition.type === 'multiselect'}
/>
);
}
// Default to text field for other types
return (
<Field
fieldKey={key}
isDisabled={userProfileFields[key].isDisabled}
fieldRef={setRef(key)}
label={userInfo.customAttributes[fieldID].name}
label={customAttribute.name}
maxLength={128}
testID={`edit_profile_form.${key}`}
{...fieldConfig}
@ -264,6 +298,7 @@ const ProfileForm = ({
value={getValue(key)}
/>);
};
const renderAttribute = (key: string, notLast: boolean) => {
if (key.startsWith(CUSTOM_ATTRS_PREFIX)) {
return renderCustomAttribute(key, notLast);

View file

@ -0,0 +1,260 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react-native';
import React from 'react';
import {renderWithIntl} from '@test/intl-test-helper';
import SelectField from './select_field';
import type {SelectFieldProps} from '@typings/screens/edit_profile';
// Mock the AutocompleteSelector component to avoid complex dependencies
jest.mock('@components/autocomplete_selector', () => ({
__esModule: true,
default: jest.fn(),
}));
const MockAutocompleteSelector = jest.requireMock('@components/autocomplete_selector').default;
MockAutocompleteSelector.mockImplementation((props: any) =>
React.createElement('AutocompleteSelector', {...props}),
);
// Mock the useIsTablet hook
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn(() => false),
}));
describe('SelectField', () => {
const mockOptions: DialogOption[] = [
{value: 'option1', text: 'Option 1'},
{value: 'option2', text: 'Option 2'},
{value: 'option3', text: 'Option 3'},
];
const baseProps: SelectFieldProps = {
fieldKey: 'test_field',
label: 'Test Field',
value: '',
options: mockOptions,
isDisabled: false,
onValueChange: jest.fn(),
onFocusNextField: jest.fn(),
testID: 'test_field',
isOptional: false,
isMultiselect: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('Basic Rendering', () => {
it('should render correctly with basic props', () => {
renderWithIntl(<SelectField {...baseProps}/>);
expect(screen.getByTestId('test_field')).toBeTruthy();
expect(screen.getByTestId('test_field.selector')).toBeTruthy();
});
});
describe('Single Select Mode', () => {
it('should pass correct props for single select mode', () => {
const props = {...baseProps, value: 'option1'};
renderWithIntl(<SelectField {...props}/>);
const selector = screen.getByTestId('test_field.selector');
expect(selector.props.selected).toBe('option1');
expect(selector.props.isMultiselect).toBe(false);
});
it('should pass value as selected prop to AutocompleteSelector', () => {
const props = {...baseProps, value: 'test_value'};
renderWithIntl(<SelectField {...props}/>);
const selector = screen.getByTestId('test_field.selector');
expect(selector.props.selected).toBe('test_value');
});
});
describe('Multiselect Mode', () => {
it('should pass correct props for multiselect mode', () => {
const props = {
...baseProps,
isMultiselect: true,
value: '["option1", "option2"]',
};
renderWithIntl(<SelectField {...props}/>);
const selector = screen.getByTestId('test_field.selector');
expect(selector.props.isMultiselect).toBe(true);
expect(selector.props.selected).toEqual(['option1', 'option2']);
});
it('should handle empty multiselect value', () => {
const props = {...baseProps, isMultiselect: true, value: ''};
renderWithIntl(<SelectField {...props}/>);
const selector = screen.getByTestId('test_field.selector');
expect(selector.props.selected).toEqual([]);
});
});
describe('Disabled State', () => {
it('should pass disabled prop to AutocompleteSelector', () => {
const props = {
...baseProps,
isDisabled: true,
};
renderWithIntl(<SelectField {...props}/>);
const selector = screen.getByTestId('test_field.selector');
expect(selector.props.disabled).toBe(true);
});
});
describe('Tablet Layout', () => {
it('should apply tablet styles when on tablet', () => {
const {useIsTablet} = require('@hooks/device');
useIsTablet.mockReturnValue(true);
renderWithIntl(<SelectField {...baseProps}/>);
const container = screen.getByTestId('test_field');
expect(container.props.style).toEqual(
expect.arrayContaining([
expect.objectContaining({
paddingHorizontal: 20,
}),
expect.objectContaining({
paddingHorizontal: 42,
}),
]),
);
});
it('should not apply tablet styles when not on tablet', () => {
const {useIsTablet} = require('@hooks/device');
useIsTablet.mockReturnValue(false);
renderWithIntl(<SelectField {...baseProps}/>);
const container = screen.getByTestId('test_field');
expect(container.props.style).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
paddingHorizontal: 42,
}),
]),
);
});
});
describe('Edge Cases', () => {
it('should handle empty options array', () => {
const props = {...baseProps, options: []};
renderWithIntl(<SelectField {...props}/>);
expect(screen.getByTestId('test_field')).toBeTruthy();
});
it('should handle undefined value', () => {
const props = {...baseProps, value: undefined as any};
renderWithIntl(<SelectField {...props}/>);
expect(screen.getByTestId('test_field')).toBeTruthy();
});
it('should handle null value', () => {
const props = {...baseProps, value: null as any};
renderWithIntl(<SelectField {...props}/>);
expect(screen.getByTestId('test_field')).toBeTruthy();
});
});
describe('Props Forwarding', () => {
it('should forward all props to AutocompleteSelector correctly', () => {
const onValueChange = jest.fn();
const onFocusNextField = jest.fn();
const props = {
...baseProps,
isDisabled: true,
isMultiselect: true,
isOptional: true,
onValueChange,
onFocusNextField,
};
renderWithIntl(<SelectField {...props}/>);
const selector = screen.getByTestId('test_field.selector');
// Verify basic props
expect(selector.props.testID).toBe('test_field.selector');
expect(selector.props.label).toBe('Test Field (optional)');
expect(selector.props.placeholder).toBe('Select one or more options');
expect(selector.props.disabled).toBe(true);
expect(selector.props.isMultiselect).toBe(true);
expect(selector.props.options).toBe(mockOptions);
// Verify callback props are functions
expect(typeof selector.props.onSelected).toBe('function');
});
it('should handle onSelected callback for single select', () => {
const onValueChange = jest.fn();
const onFocusNextField = jest.fn();
const props = {...baseProps, onValueChange, onFocusNextField};
renderWithIntl(<SelectField {...props}/>);
const selector = screen.getByTestId('test_field.selector');
// Simulate AutocompleteSelector calling onSelected
selector.props.onSelected({value: 'test_value', text: 'Test Option'});
expect(onValueChange).toHaveBeenCalledWith('test_field', 'test_value');
expect(onFocusNextField).toHaveBeenCalledWith('test_field');
});
it('should handle onSelected callback for multiselect', () => {
const onValueChange = jest.fn();
const onFocusNextField = jest.fn();
const props = {
...baseProps,
isMultiselect: true,
onValueChange,
onFocusNextField,
};
renderWithIntl(<SelectField {...props}/>);
const selector = screen.getByTestId('test_field.selector');
// Simulate AutocompleteSelector calling onSelected with array
const selectedOptions = [{value: 'option1', text: 'Option 1'}, {value: 'option2', text: 'Option 2'}];
selector.props.onSelected(selectedOptions);
expect(onValueChange).toHaveBeenCalledWith('test_field', '["option1","option2"]');
expect(onFocusNextField).toHaveBeenCalledWith('test_field');
});
it('should handle onSelected callback with null (clear selection)', () => {
const onValueChange = jest.fn();
const props = {...baseProps, onValueChange};
renderWithIntl(<SelectField {...props}/>);
const selector = screen.getByTestId('test_field.selector');
// Simulate AutocompleteSelector calling onSelected with null
selector.props.onSelected(null);
expect(onValueChange).toHaveBeenCalledWith('test_field', '');
});
});
});

View file

@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import AutocompleteSelector from '@components/autocomplete_selector';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {logError} from '@utils/log';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {getSelectedOptionIds} from '@utils/user';
import type {SelectFieldProps} from '@typings/screens/edit_profile';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginVertical: 8,
paddingHorizontal: 20,
width: '100%',
backgroundColor: theme.centerChannelBg,
},
tabletContainer: {
paddingHorizontal: 42,
},
};
});
const SelectField = ({
fieldKey,
label,
value,
options,
isDisabled = false,
onValueChange,
onFocusNextField,
testID,
isOptional = false,
isMultiselect = false,
}: SelectFieldProps) => {
const theme = useTheme();
const intl = useIntl();
const isTablet = useIsTablet();
const styles = getStyleSheet(theme);
let formattedLabel = label;
if (isOptional) {
formattedLabel = intl.formatMessage(
{
id: 'select_field.optional',
defaultMessage: '{labelName} (optional)',
},
{labelName: label},
);
}
// Convert stored value to selected format for AutocompleteSelector
const selectedValue = useMemo(() => {
if (!value) {
return isMultiselect ? [] : undefined;
}
const selectedIds = getSelectedOptionIds(value, isMultiselect ? 'multiselect' : 'select');
if (isMultiselect) {
return selectedIds;
}
return selectedIds[0] || undefined;
}, [value, isMultiselect]);
const handleSelect = useCallback((newSelection?: SelectedDialogOption) => {
if (!newSelection) {
onValueChange(fieldKey, '');
return;
}
if (Array.isArray(newSelection)) {
// Multiselect: convert array of selections to JSON string of IDs
const selectedIds = newSelection.map((option) => option.value);
let stringifiedIds;
try {
stringifiedIds = JSON.stringify(selectedIds);
} catch (e) {
logError('Error serializing selected IDs', e);
stringifiedIds = '';
}
onValueChange(fieldKey, stringifiedIds);
} else {
// Single select: store the option ID
onValueChange(fieldKey, newSelection.value);
}
// Focus next field after selection
onFocusNextField(fieldKey);
}, [fieldKey, onValueChange, onFocusNextField]);
const containerStyle = useMemo(() => [
styles.container,
isTablet && styles.tabletContainer,
], [isTablet, styles]);
const placeholder = useMemo(() => {
if (isMultiselect) {
return intl.formatMessage({
id: 'mobile.action_menu.select_multiple',
defaultMessage: 'Select one or more options',
});
}
return intl.formatMessage({
id: 'mobile.action_menu.select',
defaultMessage: 'Select an option',
});
}, [intl, isMultiselect]);
return (
<View
style={containerStyle}
testID={testID}
>
<AutocompleteSelector
label={formattedLabel}
options={options}
selected={selectedValue}
onSelected={handleSelect}
disabled={isDisabled}
isMultiselect={isMultiselect}
testID={`${testID}.selector`}
location={Screens.EDIT_PROFILE}
placeholder={placeholder}
/>
</View>
);
};
export default SelectField;

View file

@ -19,18 +19,21 @@ const serverAttributesSet: CustomAttributeSet = {
name: 'Custom Attribute 1',
value: 'server value 1',
sort_order: 1,
type: 'text',
},
attr2: {
id: 'attr2',
name: 'Custom Attribute 2',
value: 'server value 2',
sort_order: 2,
type: 'text',
},
attr3: {
id: 'attr3',
name: 'Custom Attribute 3',
value: 'server value 3',
sort_order: 3,
type: 'text',
},
};
@ -40,12 +43,14 @@ const dbAttributesSet: CustomAttributeSet = {
name: 'Custom Attribute 1',
value: 'db value 1',
sort_order: 1,
type: 'text',
},
attr2: {
id: 'attr2',
name: 'Custom Attribute 2',
value: 'db value 2',
sort_order: 2,
type: 'text',
},
};
@ -186,7 +191,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={serverAttributesSet}
/>,
@ -234,7 +238,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={dbAttributesSet}
/>,
@ -277,7 +280,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={serverAttributesSet}
/>,
@ -315,7 +317,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={{}}
/>,
@ -339,7 +340,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={dbAttributesSet}
/>,
@ -359,18 +359,21 @@ describe('EditProfile', () => {
name: 'Custom Attribute 1',
value: 'updated db value 1',
sort_order: 1,
type: 'text',
},
attr2: {
id: 'attr2',
name: 'Custom Attribute 2',
value: 'updated db value 2',
sort_order: 2,
type: 'text',
},
attr4: {
id: 'attr4',
name: 'New Attribute',
value: 'new db value',
sort_order: 4,
type: 'text',
},
};
@ -387,7 +390,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={updatedDbAttributesSet}
/>,
@ -419,7 +421,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={serverAttributesSet}
/>,
@ -468,7 +469,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={false}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={serverAttributesSet}
/>,
@ -514,7 +514,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={{}}
/>,
@ -564,7 +563,6 @@ describe('EditProfile', () => {
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
userCustomAttributes={[]}
customFields={[]}
customAttributesSet={serverAttributesSet}
/>,
@ -601,4 +599,27 @@ describe('EditProfile', () => {
});
});
});
it('should pass customFields prop to ProfileForm component', async () => {
renderWithIntlAndTheme(
<EditProfile
componentId={AvailableScreens.EDIT_PROFILE}
currentUser={mockCurrentUser}
isModal={false}
isTablet={false}
lockedFirstName={false}
lockedLastName={false}
lockedNickname={false}
lockedPosition={false}
lockedPicture={false}
enableCustomAttributes={true}
customFields={[]}
customAttributesSet={{}}
/>,
);
// Verify the ProfileForm component is rendered (which means customFields was passed)
const scrollView = screen.getByTestId('edit_profile.scroll_view');
expect(scrollView).toBeTruthy();
});
});

View file

@ -49,7 +49,7 @@ const CUSTOM_ATTRS_PREFIX_NAME = `${CUSTOM_ATTRS_PREFIX}.`;
const EditProfile = ({
componentId, currentUser, isModal, isTablet,
lockedFirstName, lockedLastName, lockedNickname, lockedPosition, lockedPicture, enableCustomAttributes,
customAttributesSet,
customAttributesSet, customFields,
}: EditProfileProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
@ -216,7 +216,7 @@ const EditProfile = ({
const update = {...userInfo};
if (fieldKey.startsWith(CUSTOM_ATTRS_PREFIX_NAME)) {
const attrKey = fieldKey.slice(CUSTOM_ATTRS_PREFIX_NAME.length);
update.customAttributes = {...update.customAttributes, [attrKey]: {id: attrKey, name: userInfo.customAttributes[attrKey].name, value, sort_order: userInfo.customAttributes[attrKey].sort_order}};
update.customAttributes = {...update.customAttributes, [attrKey]: {id: attrKey, name: userInfo.customAttributes[attrKey].name, value, type: userInfo.customAttributes[attrKey].type, sort_order: userInfo.customAttributes[attrKey].sort_order}};
} else {
switch (fieldKey) {
// typescript doesn't like to do update[fieldkey] as it might containg a customAttribute case
@ -307,6 +307,7 @@ const EditProfile = ({
userInfo={userInfo}
submitUser={submitUser}
enableCustomAttributes={enableCustomAttributes}
customFields={customFields}
/>
</KeyboardAwareScrollView>
) : null;

View file

@ -37,13 +37,14 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
customFields = observeCustomProfileFields(database);
// Convert attributes to the format expected by the component
// NOTE: useDisplayType = false to keep raw option IDs for editing
formattedCustomAttributes = combineLatest([rawCustomAttributes, customFields]).pipe(
switchMap(([attributes, fields]) => {
if (!attributes?.length) {
return of$([] as CustomAttribute[]);
}
return of$(convertProfileAttributesToCustomAttributes(attributes, fields, sortCustomProfileAttributes));
return of$(convertProfileAttributesToCustomAttributes(attributes, fields, sortCustomProfileAttributes, false));
}),
switchMap((converted) => of$(convertToAttributesMap(converted))),
);

View file

@ -154,7 +154,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
...typography('Body', 600, 'Regular'),
},
searchBarInput: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
},

View file

@ -0,0 +1,258 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react-native';
import React from 'react';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import CustomAttributes from './custom_attributes';
import type {CustomAttribute} from '@typings/api/custom_profile_attributes';
describe('CustomAttributes', () => {
const defaultProps = {
nickname: undefined,
position: undefined,
localTime: undefined,
customAttributes: undefined,
};
it('renders empty component when no props are provided', () => {
renderWithIntlAndTheme(
<CustomAttributes {...defaultProps}/>,
);
// Should not display any attributes when all props are undefined
expect(screen.queryByText('Nickname')).toBeNull();
expect(screen.queryByText('Position')).toBeNull();
expect(screen.queryByText('Local Time')).toBeNull();
});
it('renders nickname attribute when provided', () => {
renderWithIntlAndTheme(
<CustomAttributes
{...defaultProps}
nickname='TestNickname'
/>,
);
expect(screen.getByText('Nickname')).toBeVisible();
expect(screen.getByText('TestNickname')).toBeVisible();
expect(screen.getByTestId('custom_attribute.nickname.title')).toBeVisible();
expect(screen.getByTestId('custom_attribute.nickname.text')).toBeVisible();
});
it('renders position attribute when provided', () => {
renderWithIntlAndTheme(
<CustomAttributes
{...defaultProps}
position='Software Engineer'
/>,
);
expect(screen.getByText('Position')).toBeVisible();
expect(screen.getByText('Software Engineer')).toBeVisible();
expect(screen.getByTestId('custom_attribute.position.title')).toBeVisible();
expect(screen.getByTestId('custom_attribute.position.text')).toBeVisible();
});
it('renders local time attribute when provided', () => {
renderWithIntlAndTheme(
<CustomAttributes
{...defaultProps}
localTime='2:00 PM'
/>,
);
expect(screen.getByText('Local Time')).toBeVisible();
expect(screen.getByText('2:00 PM')).toBeVisible();
expect(screen.getByTestId('custom_attribute.local_time.title')).toBeVisible();
expect(screen.getByTestId('custom_attribute.local_time.text')).toBeVisible();
});
it('renders custom attributes when provided', () => {
const customAttributes: CustomAttribute[] = [
{
id: 'custom1',
name: 'Department',
type: 'text',
value: 'Engineering',
},
{
id: 'custom2',
name: 'Office Location',
type: 'text',
value: 'New York',
},
];
renderWithIntlAndTheme(
<CustomAttributes
{...defaultProps}
customAttributes={customAttributes}
/>,
);
expect(screen.getByText('Department')).toBeVisible();
expect(screen.getByText('Engineering')).toBeVisible();
expect(screen.getByTestId('custom_attribute.custom1.title')).toBeVisible();
expect(screen.getByTestId('custom_attribute.custom1.text')).toBeVisible();
expect(screen.getByText('Office Location')).toBeVisible();
expect(screen.getByText('New York')).toBeVisible();
expect(screen.getByTestId('custom_attribute.custom2.title')).toBeVisible();
expect(screen.getByTestId('custom_attribute.custom2.text')).toBeVisible();
});
it('renders custom attributes with different types correctly', () => {
const customAttributes: CustomAttribute[] = [
{
id: 'url_field',
name: 'Website',
type: 'url',
value: 'https://example.com',
},
{
id: 'phone_field',
name: 'Phone',
type: 'phone',
value: '+1234567890',
},
{
id: 'select_field',
name: 'Team',
type: 'select',
value: 'Frontend',
},
];
renderWithIntlAndTheme(
<CustomAttributes
{...defaultProps}
customAttributes={customAttributes}
/>,
);
expect(screen.getByText('Website')).toBeVisible();
expect(screen.getByText('https://example.com')).toBeVisible();
expect(screen.getByTestId('custom_attribute.url_field.title')).toBeVisible();
expect(screen.getByTestId('custom_attribute.url_field.url')).toBeVisible();
expect(screen.getByText('Phone')).toBeVisible();
expect(screen.getByText('+1234567890')).toBeVisible();
expect(screen.getByTestId('custom_attribute.phone_field.title')).toBeVisible();
expect(screen.getByTestId('custom_attribute.phone_field.phone')).toBeVisible();
expect(screen.getByText('Team')).toBeVisible();
expect(screen.getByText('Frontend')).toBeVisible();
expect(screen.getByTestId('custom_attribute.select_field.title')).toBeVisible();
expect(screen.getByTestId('custom_attribute.select_field.select')).toBeVisible();
});
it('renders all standard and custom attributes together', () => {
const customAttributes: CustomAttribute[] = [
{
id: 'custom_field',
name: 'Skills',
type: 'text',
value: 'React, TypeScript',
},
];
renderWithIntlAndTheme(
<CustomAttributes
nickname='JohnDoe'
position='Senior Developer'
localTime='3:30 PM'
customAttributes={customAttributes}
/>,
);
// Standard attributes
expect(screen.getByText('Nickname')).toBeVisible();
expect(screen.getByText('JohnDoe')).toBeVisible();
expect(screen.getByText('Position')).toBeVisible();
expect(screen.getByText('Senior Developer')).toBeVisible();
expect(screen.getByText('Local Time')).toBeVisible();
expect(screen.getByText('3:30 PM')).toBeVisible();
// Custom attributes
expect(screen.getByText('Skills')).toBeVisible();
expect(screen.getByText('React, TypeScript')).toBeVisible();
});
it('handles empty custom attributes array', () => {
renderWithIntlAndTheme(
<CustomAttributes
{...defaultProps}
customAttributes={[]}
/>,
);
// Should not display any attributes when custom attributes is empty array
expect(screen.queryByText('Department')).toBeNull();
expect(screen.queryByText('Skills')).toBeNull();
});
it('filters out empty standard attributes correctly', () => {
renderWithIntlAndTheme(
<CustomAttributes
nickname=''
position='Developer'
localTime=''
customAttributes={undefined}
/>,
);
// Only position should be visible since nickname and localTime are empty strings
expect(screen.getByText('Position')).toBeVisible();
expect(screen.getByText('Developer')).toBeVisible();
// Empty strings should not create attributes
expect(screen.queryByText('Nickname')).toBeNull();
expect(screen.queryByText('Local Time')).toBeNull();
});
it('renders FlatList with correct props', () => {
const customAttributes: CustomAttribute[] = [
{
id: 'test1',
name: 'Test Field',
type: 'text',
value: 'Test Value',
},
];
renderWithIntlAndTheme(
<CustomAttributes
{...defaultProps}
customAttributes={customAttributes}
/>,
);
// Check that the FlatList is rendered (by checking if the data is displayed)
expect(screen.getByText('Test Field')).toBeVisible();
expect(screen.getByText('Test Value')).toBeVisible();
});
it('handles partial standard attributes correctly', () => {
renderWithIntlAndTheme(
<CustomAttributes
nickname='TestUser'
position={undefined}
localTime='4:00 PM'
customAttributes={undefined}
/>,
);
// Only nickname and localTime should be visible
expect(screen.getByText('Nickname')).toBeVisible();
expect(screen.getByText('TestUser')).toBeVisible();
expect(screen.getByText('Local Time')).toBeVisible();
expect(screen.getByText('4:00 PM')).toBeVisible();
// Position should not be visible since it's undefined
expect(screen.queryByText('Position')).toBeNull();
});
});

View file

@ -22,6 +22,7 @@ const renderAttribute: ListRenderItem<CustomAttribute> = ({item}) => (
title={item.name}
description={item.value}
testID={`custom_attribute.${item.id}`}
type={item.type}
/>
);
@ -29,29 +30,35 @@ const CustomAttributes = ({nickname, position, localTime, customAttributes}: Pro
const {formatMessage} = useIntl();
// Combine standard and custom attributes
const mergeAttributes = [
(nickname ? {
const mergeAttributes: CustomAttribute[] = [];
if (nickname) {
mergeAttributes.push({
id: 'nickname',
name: formatMessage({id: 'channel_info.nickname', defaultMessage: 'Nickname'}),
type: 'text',
value: nickname,
} : {}),
(position ? {
});
}
if (position) {
mergeAttributes.push({
id: 'position',
name: formatMessage({id: 'channel_info.position', defaultMessage: 'Position'}),
type: 'text',
value: position,
} : {}),
(localTime ? {
});
}
if (localTime) {
mergeAttributes.push({
id: 'local_time',
name: formatMessage({id: 'channel_info.local_time', defaultMessage: 'Local Time'}),
type: 'text',
value: localTime,
} : {}),
...(customAttributes ?? []),
];
});
}
mergeAttributes.push(...(customAttributes ?? []));
// remove any empty objects
const attributes = mergeAttributes.filter((v) => Object.entries(v).length !== 0);
const attributes: CustomAttribute[] = mergeAttributes.filter((v: CustomAttribute) => Object.entries(v).length !== 0);
return (
<View style={styles.container}>
<FlatList

View file

@ -3,7 +3,7 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$, combineLatest} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {map, mergeMap, switchMap} from 'rxjs/operators';
import {General, Permissions, Preferences} from '@constants';
import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference';
@ -57,13 +57,13 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro
// Convert attributes to the format expected by the component
const formattedCustomAttributes = combineLatest([rawCustomAttributes, customFields, enableCustomAttributes]).pipe(
switchMap(([attributes, fields, enabled]) => {
mergeMap(([attributes, fields, enabled]) => {
if (!enabled || !attributes?.length) {
return of$([] as CustomAttribute[]);
}
return of$(convertProfileAttributesToCustomAttributes(attributes, fields, sortCustomProfileAttributes));
return of$(convertProfileAttributesToCustomAttributes(attributes, fields, sortCustomProfileAttributes, true));
}),
switchMap((converted) => of$(convertToAttributesMap(converted))),
mergeMap((converted) => of$(convertToAttributesMap(converted))),
);
// can remove member

View file

@ -0,0 +1,401 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent} from '@testing-library/react-native';
import React from 'react';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import * as urlUtils from '@utils/url';
import UserProfileLabel from './label';
// Mock the URL utilities
jest.mock('@utils/url', () => ({
getScheme: jest.fn(),
tryOpenURL: jest.fn(),
}));
const mockGetScheme = jest.mocked(urlUtils.getScheme);
const mockTryOpenURL = jest.mocked(urlUtils.tryOpenURL);
describe('UserProfileLabel', () => {
const baseProps = {
title: 'Test Title',
description: 'Test Description',
testID: 'test-label',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('text type (default)', () => {
it('should render text type correctly', () => {
const {getByTestId, getByText} = renderWithIntlAndTheme(
<UserProfileLabel {...baseProps}/>,
);
expect(getByText('Test Title')).toBeTruthy();
expect(getByText('Test Description')).toBeTruthy();
expect(getByTestId('test-label.title')).toBeTruthy();
expect(getByTestId('test-label.text')).toBeTruthy();
});
it('should render text type when explicitly set', () => {
const {getByTestId, getByText} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
type='text'
/>,
);
expect(getByText('Test Title')).toBeTruthy();
expect(getByText('Test Description')).toBeTruthy();
expect(getByTestId('test-label.title')).toBeTruthy();
expect(getByTestId('test-label.text')).toBeTruthy();
});
it('should limit title to one line', () => {
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel {...baseProps}/>,
);
const titleElement = getByTestId('test-label.title');
expect(titleElement.props.numberOfLines).toBe(1);
});
});
describe('url type', () => {
it('should render url type as clickable link', () => {
const {getByTestId, getByText} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='example.com'
type='url'
/>,
);
expect(getByText('Test Title')).toBeTruthy();
expect(getByText('example.com')).toBeTruthy();
expect(getByTestId('test-label.title')).toBeTruthy();
expect(getByTestId('test-label.url')).toBeTruthy();
});
it('should add https:// prefix when no scheme is present', () => {
mockGetScheme.mockReturnValue(null);
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='example.com'
type='url'
/>,
);
const linkElement = getByTestId('test-label.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('https://example.com');
});
it('should not add prefix when scheme is already present', () => {
mockGetScheme.mockReturnValue('https');
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='https://example.com'
type='url'
/>,
);
const linkElement = getByTestId('test-label.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('https://example.com');
});
it('should handle http scheme correctly', () => {
mockGetScheme.mockReturnValue('http');
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='http://example.com'
type='url'
/>,
);
const linkElement = getByTestId('test-label.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('http://example.com');
});
});
describe('phone type', () => {
it('should render phone type as clickable link', () => {
const {getByTestId, getByText} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='+1234567890'
type='phone'
/>,
);
expect(getByText('Test Title')).toBeTruthy();
expect(getByText('+1234567890')).toBeTruthy();
expect(getByTestId('test-label.title')).toBeTruthy();
expect(getByTestId('test-label.phone')).toBeTruthy();
});
it('should add tel: prefix when no scheme is present', () => {
mockGetScheme.mockReturnValue(null);
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='+1234567890'
type='phone'
/>,
);
const linkElement = getByTestId('test-label.phone');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:+1234567890');
});
it('should not add prefix when tel scheme is already present', () => {
mockGetScheme.mockReturnValue('tel');
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='tel:+1234567890'
type='phone'
/>,
);
const linkElement = getByTestId('test-label.phone');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:+1234567890');
});
it('should handle phone numbers without plus sign', () => {
mockGetScheme.mockReturnValue(null);
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='1234567890'
type='phone'
/>,
);
const linkElement = getByTestId('test-label.phone');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:1234567890');
});
});
describe('select type', () => {
it('should render select type as non-clickable text', () => {
const {getByTestId, getByText, queryByRole} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='Option 1'
type='select'
/>,
);
expect(getByText('Test Title')).toBeTruthy();
expect(getByText('Option 1')).toBeTruthy();
expect(getByTestId('test-label.title')).toBeTruthy();
expect(getByTestId('test-label.select')).toBeTruthy();
// Should not be a touchable element
expect(queryByRole('button')).toBeNull();
});
});
describe('multiselect type', () => {
it('should render multiselect type as non-clickable text', () => {
const {getByTestId, getByText, queryByRole} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description='Option 1, Option 2'
type='multiselect'
/>,
);
expect(getByText('Test Title')).toBeTruthy();
expect(getByText('Option 1, Option 2')).toBeTruthy();
expect(getByTestId('test-label.title')).toBeTruthy();
expect(getByTestId('test-label.select')).toBeTruthy();
// Should not be a touchable element
expect(queryByRole('button')).toBeNull();
});
});
describe('testID prop', () => {
it('should use provided testID for all elements', () => {
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
testID='custom-test-id'
/>,
);
expect(getByTestId('custom-test-id.title')).toBeTruthy();
expect(getByTestId('custom-test-id.text')).toBeTruthy();
});
it('should work with url type testID', () => {
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
testID='custom-test-id'
type='url'
/>,
);
expect(getByTestId('custom-test-id.title')).toBeTruthy();
expect(getByTestId('custom-test-id.url')).toBeTruthy();
});
it('should work with phone type testID', () => {
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
testID='custom-test-id'
type='phone'
/>,
);
expect(getByTestId('custom-test-id.title')).toBeTruthy();
expect(getByTestId('custom-test-id.phone')).toBeTruthy();
});
it('should work with select type testID', () => {
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
testID='custom-test-id'
type='select'
/>,
);
expect(getByTestId('custom-test-id.title')).toBeTruthy();
expect(getByTestId('custom-test-id.select')).toBeTruthy();
});
});
describe('edge cases', () => {
it('should handle empty description', () => {
const {getByText, getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description=''
/>,
);
expect(getByText('Test Title')).toBeTruthy();
expect(getByTestId('test-label.text')).toBeTruthy();
expect(getByTestId('test-label.text').props.children).toBe('');
});
it('should handle empty title', () => {
const {getByText, getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
title=''
/>,
);
expect(getByText('Test Description')).toBeTruthy();
expect(getByTestId('test-label.title')).toBeTruthy();
expect(getByTestId('test-label.title').props.children).toBe('');
});
it('should handle undefined testID gracefully', () => {
const {getByText} = renderWithIntlAndTheme(
<UserProfileLabel
title='Test Title'
description='Test Description'
/>,
);
expect(getByText('Test Title')).toBeTruthy();
expect(getByText('Test Description')).toBeTruthy();
});
it('should handle special characters in URLs', () => {
mockGetScheme.mockReturnValue(null);
const specialUrl = 'example.com/path?param=value&other=test';
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description={specialUrl}
type='url'
/>,
);
const linkElement = getByTestId('test-label.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith(`https://${specialUrl}`);
});
it('should handle special characters in phone numbers', () => {
mockGetScheme.mockReturnValue(null);
const phoneNumber = '+1 (234) 567-8900';
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
description={phoneNumber}
type='phone'
/>,
);
const linkElement = getByTestId('test-label.phone');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith(`tel:${phoneNumber}`);
});
});
describe('accessibility', () => {
it('should have proper accessibility for text type', () => {
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel {...baseProps}/>,
);
const titleElement = getByTestId('test-label.title');
const textElement = getByTestId('test-label.text');
expect(titleElement).toBeTruthy();
expect(textElement).toBeTruthy();
});
it('should have proper accessibility for link types', () => {
const {getByTestId} = renderWithIntlAndTheme(
<UserProfileLabel
{...baseProps}
type='url'
/>,
);
const linkElement = getByTestId('test-label.url');
expect(linkElement).toBeTruthy();
// TouchableOpacity should be accessible by default
expect(linkElement.type).toBe('Text');
});
});
});

View file

@ -8,10 +8,13 @@ import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import UserProfileLink from './label_link';
type Props = {
title: string;
description: string;
testID?: string;
type?: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@ -35,9 +38,43 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const UserProfileLabel = ({title, description, testID}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const UserProfileLabel = ({title, description, testID, type = 'text'}: Props) => {
const styles = getStyleSheet(useTheme());
let descriptionComponent;
switch (type) {
case 'url':
case 'phone':
descriptionComponent = (
<UserProfileLink
description={description}
linkType={type}
testID={testID}
/>
);
break;
case 'select':
case 'multiselect':
descriptionComponent = (
<Text
style={styles.description}
testID={`${testID}.select`}
>
{description}
</Text>
);
break;
case 'text':
default:
descriptionComponent = (
<Text
style={styles.description}
testID={`${testID}.text`}
>
{description}
</Text>
);
break;
}
return (
<View style={styles.container}>
@ -48,12 +85,7 @@ const UserProfileLabel = ({title, description, testID}: Props) => {
>
{title}
</Text>
<Text
style={styles.description}
testID={`${testID}.description`}
>
{description}
</Text>
{descriptionComponent}
</View>
);
};

View file

@ -0,0 +1,319 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen} from '@testing-library/react-native';
import React from 'react';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import UserProfileLink from './label_link';
// Mock the URL utilities
jest.mock('@utils/url', () => ({
getScheme: jest.fn(),
tryOpenURL: jest.fn(),
}));
const mockGetScheme = require('@utils/url').getScheme as jest.Mock;
const mockTryOpenURL = require('@utils/url').tryOpenURL as jest.Mock;
describe('UserProfileLink', () => {
const baseProps = {
description: 'example.com',
linkType: 'url',
testID: 'test-link',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('url type', () => {
it('should render URL link correctly', () => {
renderWithIntlAndTheme(
<UserProfileLink {...baseProps}/>,
);
expect(screen.getByText('example.com')).toBeTruthy();
expect(screen.getByTestId('test-link.url')).toBeTruthy();
});
it('should add https:// prefix when no scheme is present', () => {
mockGetScheme.mockReturnValue(null);
renderWithIntlAndTheme(
<UserProfileLink {...baseProps}/>,
);
const linkElement = screen.getByTestId('test-link.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('https://example.com');
});
it('should not add prefix when scheme is already present', () => {
mockGetScheme.mockReturnValue('https');
renderWithIntlAndTheme(
<UserProfileLink
{...baseProps}
description='https://example.com'
/>,
);
const linkElement = screen.getByTestId('test-link.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('https://example.com');
});
it('should handle http scheme correctly', () => {
mockGetScheme.mockReturnValue('http');
renderWithIntlAndTheme(
<UserProfileLink
{...baseProps}
description='http://example.com'
/>,
);
const linkElement = screen.getByTestId('test-link.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('http://example.com');
});
it('should handle complex URLs with parameters', () => {
mockGetScheme.mockReturnValue(null);
const complexUrl = 'example.com/path?param=value&other=test';
renderWithIntlAndTheme(
<UserProfileLink
{...baseProps}
description={complexUrl}
/>,
);
const linkElement = screen.getByTestId('test-link.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith(`https://${complexUrl}`);
});
});
describe('phone type', () => {
const phoneProps = {
description: '+1234567890',
linkType: 'phone',
testID: 'test-phone',
};
it('should render phone link correctly', () => {
renderWithIntlAndTheme(
<UserProfileLink {...phoneProps}/>,
);
expect(screen.getByText('+1234567890')).toBeTruthy();
expect(screen.getByTestId('test-phone.phone')).toBeTruthy();
});
it('should add tel: prefix when no scheme is present', () => {
mockGetScheme.mockReturnValue(null);
renderWithIntlAndTheme(
<UserProfileLink {...phoneProps}/>,
);
const linkElement = screen.getByTestId('test-phone.phone');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:+1234567890');
});
it('should not add prefix when tel scheme is already present', () => {
mockGetScheme.mockReturnValue('tel');
renderWithIntlAndTheme(
<UserProfileLink
{...phoneProps}
description='tel:+1234567890'
/>,
);
const linkElement = screen.getByTestId('test-phone.phone');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:+1234567890');
});
it('should handle phone numbers without plus sign', () => {
mockGetScheme.mockReturnValue(null);
renderWithIntlAndTheme(
<UserProfileLink
{...phoneProps}
description='1234567890'
/>,
);
const linkElement = screen.getByTestId('test-phone.phone');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:1234567890');
});
it('should handle formatted phone numbers', () => {
mockGetScheme.mockReturnValue(null);
const formattedPhone = '+1 (234) 567-8900';
renderWithIntlAndTheme(
<UserProfileLink
{...phoneProps}
description={formattedPhone}
/>,
);
const linkElement = screen.getByTestId('test-phone.phone');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith(`tel:${formattedPhone}`);
});
});
describe('testID handling', () => {
it('should generate correct testID for URL type', () => {
renderWithIntlAndTheme(
<UserProfileLink
description='example.com'
linkType='url'
testID='custom-test'
/>,
);
expect(screen.getByTestId('custom-test.url')).toBeTruthy();
});
it('should generate correct testID for phone type', () => {
renderWithIntlAndTheme(
<UserProfileLink
description='+1234567890'
linkType='phone'
testID='custom-test'
/>,
);
expect(screen.getByTestId('custom-test.phone')).toBeTruthy();
});
it('should handle undefined testID gracefully', () => {
renderWithIntlAndTheme(
<UserProfileLink
description='example.com'
linkType='url'
/>,
);
expect(screen.getByText('example.com')).toBeTruthy();
});
});
describe('edge cases', () => {
it('should handle empty description', () => {
renderWithIntlAndTheme(
<UserProfileLink
{...baseProps}
description=''
/>,
);
const linkElement = screen.getByTestId('test-link.url');
expect(linkElement.props.children).toBe('');
});
it('should handle whitespace in description', () => {
mockGetScheme.mockReturnValue(null);
renderWithIntlAndTheme(
<UserProfileLink
{...baseProps}
description=' example.com '
/>,
);
const linkElement = screen.getByTestId('test-link.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith('https:// example.com ');
});
it('should handle special characters in URLs', () => {
mockGetScheme.mockReturnValue(null);
const specialUrl = 'example.com/path#section';
renderWithIntlAndTheme(
<UserProfileLink
{...baseProps}
description={specialUrl}
/>,
);
const linkElement = screen.getByTestId('test-link.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledWith(`https://${specialUrl}`);
});
});
describe('accessibility', () => {
it('should be accessible as a touchable element', () => {
renderWithIntlAndTheme(
<UserProfileLink {...baseProps}/>,
);
const linkElement = screen.getByTestId('test-link.url');
expect(linkElement).toBeTruthy();
// The text element should be rendered and accessible
expect(linkElement.type).toBe('Text');
});
it('should have proper text content for screen readers', () => {
const {getByText} = renderWithIntlAndTheme(
<UserProfileLink {...baseProps}/>,
);
expect(getByText('example.com')).toBeTruthy();
});
});
describe('integration with URL utils', () => {
it('should call getScheme with the correct description', () => {
mockGetScheme.mockReturnValue(null);
renderWithIntlAndTheme(
<UserProfileLink {...baseProps}/>,
);
const linkElement = screen.getByTestId('test-link.url');
fireEvent.press(linkElement);
expect(mockGetScheme).toHaveBeenCalledWith('example.com');
});
it('should call tryOpenURL when pressed', () => {
mockGetScheme.mockReturnValue('https');
renderWithIntlAndTheme(
<UserProfileLink
{...baseProps}
description='https://example.com'
/>,
);
const linkElement = screen.getByTestId('test-link.url');
fireEvent.press(linkElement);
expect(mockTryOpenURL).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Text, TouchableOpacity} from 'react-native';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getScheme, tryOpenURL} from '@utils/url';
type Props = {
description: string;
linkType: string;
testID?: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
link: {
color: theme.linkColor,
overflow: 'hidden',
flex: 2,
...typography('Body', 200),
},
button: {
flex: 2,
},
}));
const UserProfileLink = ({description, linkType, testID}: Props) => {
const styles = getStyleSheet(useTheme());
// don't try to be smart, if there is already a scheme, don't add one, otherwise add the most likely one
let url = description;
if (!getScheme(description)) {
url = linkType === 'url' ? `https://${description}` : `tel:${description}`;
}
const openURL = useCallback(() => tryOpenURL(url), [url]);
return (
<TouchableOpacity
onPress={openURL}
style={styles.button}
>
<Text
style={styles.link}
numberOfLines={1}
testID={`${testID}.${linkType}`}
>
{description}
</Text>
</TouchableOpacity>
);
};
export default UserProfileLink;

View file

@ -15,41 +15,46 @@ import type {CustomAttributeSet} from '@typings/api/custom_profile_attributes';
const localhost = 'http://localhost:8065';
// Create test attribute sets
const serverAttributes = {
const serverAttributes: CustomAttributeSet = {
attr1: {
id: 'attr1',
name: 'Department',
value: 'Engineering',
type: 'text',
sort_order: 1,
},
attr2: {
id: 'attr2',
name: 'Location',
value: 'Remote',
type: 'text',
sort_order: 0,
},
} as CustomAttributeSet;
};
const updatedAttributes = {
const updatedAttributes: CustomAttributeSet = {
attr1: {
id: 'attr1',
name: 'Department',
value: 'Engineering Updated',
type: 'text',
sort_order: 1,
},
attr2: {
id: 'attr2',
name: 'Location',
value: 'Office',
type: 'text',
sort_order: 0,
},
attr3: {
id: 'attr3',
name: 'Team',
value: 'Mobile',
type: 'text',
sort_order: 2,
},
} as CustomAttributeSet;
};
jest.mock('@actions/remote/custom_profile', () => ({
fetchCustomProfileAttributes: jest.fn().mockResolvedValue({

View file

@ -52,11 +52,7 @@ const UserInfo = ({
const fetchFromServer = async () => {
const reqTime = Date.now();
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);
}
fetchCustomProfileAttributes(serverUrl, user.id, true);
};
fetchFromServer();

View file

@ -39,8 +39,10 @@ import {
isShared,
isSystemAdmin,
removeUserFromList,
getDisplayType,
} from './index';
import type {CustomProfileFieldModel} from '@database/models/server';
import type {CustomAttribute, CustomAttributeSet} from '@typings/api/custom_profile_attributes';
import type {IntlShape} from 'react-intl';
@ -684,20 +686,20 @@ describe('confirmOutOfOfficeDisabled', () => {
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},
{id: 'attr1', name: 'Attribute 1', type: 'text', value: 'value1', sort_order: 1},
{id: 'attr2', name: 'Attribute 2', type: 'text', 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},
attr1: {id: 'attr1', name: 'Attribute 1', type: 'text', value: 'value1', sort_order: 1},
attr2: {id: 'attr2', name: 'Attribute 2', type: 'text', 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},
attr1: {id: 'attr1', name: 'Attribute 1', type: 'text', value: 'value1', sort_order: 1},
attr2: {id: 'attr2', name: 'Attribute 2', type: 'text', value: 'value2', sort_order: 2},
};
const result = convertToAttributesMap(attributesMap);
expect(result).toBe(attributesMap);
@ -709,22 +711,22 @@ describe('convertToAttributesMap', () => {
});
it('should handle array with single attribute', () => {
const attributes: CustomAttribute[] = [{id: 'attr1', name: 'Attribute 1', value: 'value1', sort_order: 1}];
const attributes: CustomAttribute[] = [{id: 'attr1', name: 'Attribute 1', type: 'text', value: 'value1', sort_order: 1}];
const result = convertToAttributesMap(attributes);
expect(result).toEqual({
attr1: {id: 'attr1', name: 'Attribute 1', value: 'value1', sort_order: 1},
attr1: {id: 'attr1', name: 'Attribute 1', type: 'text', 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'},
{id: 'attr1', name: 'Attribute 1', type: 'text', value: ''},
{id: 'attr2', name: 'Attribute 2', type: 'text', value: 'value2'},
];
const result = convertToAttributesMap(attributes);
expect(result).toEqual({
attr1: {id: 'attr1', name: 'Attribute 1', value: ''},
attr2: {id: 'attr2', name: 'Attribute 2', value: 'value2'},
attr1: {id: 'attr1', name: 'Attribute 1', type: 'text', value: ''},
attr2: {id: 'attr2', name: 'Attribute 2', type: 'text', value: 'value2'},
});
});
});
@ -762,12 +764,14 @@ describe('convertProfileAttributesToCustomAttributes', () => {
{
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
sort_order: 1,
},
{
id: 'field2',
name: 'Field 2',
type: 'text',
value: 'value2',
sort_order: 2,
},
@ -783,6 +787,7 @@ describe('convertProfileAttributesToCustomAttributes', () => {
expect(result).toEqual([{
id: 'unknown_field',
name: 'unknown_field',
type: 'text',
value: 'value',
sort_order: Number.MAX_SAFE_INTEGER,
}]);
@ -803,6 +808,7 @@ describe('convertProfileAttributesToCustomAttributes', () => {
expect(result).toEqual([{
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
sort_order: Number.MAX_SAFE_INTEGER,
}]);
@ -829,12 +835,14 @@ describe('convertProfileAttributesToCustomAttributes', () => {
{
id: 'field1',
name: 'field1',
type: 'text',
value: 'value1',
sort_order: Number.MAX_SAFE_INTEGER,
},
{
id: 'field2',
name: 'field2',
type: 'text',
value: 'value2',
sort_order: Number.MAX_SAFE_INTEGER,
},
@ -847,12 +855,14 @@ describe('convertProfileAttributesToCustomAttributes', () => {
{
id: 'field1',
name: 'field1',
type: 'text',
value: 'value1',
sort_order: Number.MAX_SAFE_INTEGER,
},
{
id: 'field2',
name: 'field2',
type: 'text',
value: 'value2',
sort_order: Number.MAX_SAFE_INTEGER,
},
@ -866,15 +876,645 @@ describe('convertProfileAttributesToCustomAttributes', () => {
{
id: 'field2',
name: 'Field 2',
type: 'text',
value: 'value2',
sort_order: 2,
},
{
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
sort_order: 1,
},
]);
});
it('should not convert values for non-select/multiselect fields', () => {
const textField = TestHelper.fakeCustomProfileFieldModel({
id: 'text_field',
name: 'Text Field',
type: 'text',
attrs: {sort_order: 1},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'text_field',
value: 'some text value',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [textField]);
expect(result).toEqual([{
id: 'text_field',
name: 'Text Field',
type: 'text',
value: 'some text value',
sort_order: 1,
}]);
});
// Additional tests for edge cases and robustness
it('should handle multiselect fields with JSON array values containing spaces', () => {
const multiselectField = TestHelper.fakeCustomProfileFieldModel({
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
{id: 'opt2', name: 'Option 2'},
{id: 'opt3', name: 'Option 3'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'multiselect_field',
value: '["opt1", "opt2", "opt3"]', // JSON with spaces
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [multiselectField]);
expect(result).toEqual([{
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
value: 'Option 1, Option 2, Option 3',
sort_order: 1,
}]);
});
it('should handle multiselect fields with mixed valid and invalid option IDs', () => {
const multiselectField = TestHelper.fakeCustomProfileFieldModel({
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
{id: 'opt2', name: 'Option 2'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'multiselect_field',
value: '["opt1", "invalid_opt", "opt2"]',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [multiselectField]);
expect(result).toEqual([{
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
value: 'Option 1, invalid_opt, Option 2',
sort_order: 1,
}]);
});
it('should handle multiselect fields with empty JSON array', () => {
const multiselectField = TestHelper.fakeCustomProfileFieldModel({
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'multiselect_field',
value: '[]',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [multiselectField]);
expect(result).toEqual([{
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
value: '',
sort_order: 1,
}]);
});
it('should handle multiselect fields with malformed JSON gracefully', () => {
const multiselectField = TestHelper.fakeCustomProfileFieldModel({
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
{id: 'opt2', name: 'Option 2'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'multiselect_field',
value: '["opt1", "opt2"', // Malformed JSON (missing closing bracket)
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [multiselectField]);
// Should fallback to comma-separated parsing
expect(result).toEqual([{
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
value: '["opt1", "opt2"', // Should return original value since it can't be parsed
sort_order: 1,
}]);
});
it('should handle select/multiselect fields with no options defined', () => {
const selectFieldNoOptions = TestHelper.fakeCustomProfileFieldModel({
id: 'select_field',
name: 'Select Field',
type: 'select',
attrs: {
sort_order: 1,
// No options defined
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'select_field',
value: 'some_value',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [selectFieldNoOptions]);
expect(result).toEqual([{
id: 'select_field',
name: 'Select Field',
type: 'select',
value: 'some_value', // Should return original value
sort_order: 1,
}]);
});
it('should handle multiselect fields with comma-separated values containing spaces', () => {
const multiselectField = TestHelper.fakeCustomProfileFieldModel({
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
{id: 'opt2', name: 'Option 2'},
{id: 'opt3', name: 'Option 3'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'multiselect_field',
value: 'opt1, opt2 , opt3', // Comma-separated with spaces
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [multiselectField]);
expect(result).toEqual([{
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
value: 'Option 1, Option 2, Option 3',
sort_order: 1,
}]);
});
// Test single select fields with option conversion
it('should handle select fields with option ID conversion', () => {
const selectField = TestHelper.fakeCustomProfileFieldModel({
id: 'select_field',
name: 'Select Field',
type: 'select',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
{id: 'opt2', name: 'Option 2'},
{id: 'opt3', name: 'Option 3'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'select_field',
value: 'opt2',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [selectField]);
expect(result).toEqual([{
id: 'select_field',
name: 'Select Field',
type: 'select',
value: 'Option 2',
sort_order: 1,
}]);
});
it('should handle select fields with invalid option ID', () => {
const selectField = TestHelper.fakeCustomProfileFieldModel({
id: 'select_field',
name: 'Select Field',
type: 'select',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
{id: 'opt2', name: 'Option 2'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'select_field',
value: 'invalid_option',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [selectField]);
expect(result).toEqual([{
id: 'select_field',
name: 'Select Field',
type: 'select',
value: 'invalid_option', // Should return original value when ID not found
sort_order: 1,
}]);
});
it('should handle select fields with empty options array', () => {
const selectField = TestHelper.fakeCustomProfileFieldModel({
id: 'select_field',
name: 'Select Field',
type: 'select',
attrs: {
sort_order: 1,
options: [],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'select_field',
value: 'some_value',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [selectField]);
expect(result).toEqual([{
id: 'select_field',
name: 'Select Field',
type: 'select',
value: 'some_value', // Should return original value
sort_order: 1,
}]);
});
it('should handle select fields with null options', () => {
const selectField = TestHelper.fakeCustomProfileFieldModel({
id: 'select_field',
name: 'Select Field',
type: 'select',
attrs: {
sort_order: 1,
options: undefined,
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'select_field',
value: 'some_value',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [selectField]);
expect(result).toEqual([{
id: 'select_field',
name: 'Select Field',
type: 'select',
value: 'some_value', // Should return original value
sort_order: 1,
}]);
});
it('should handle options with missing id or name properties', () => {
const selectField = TestHelper.fakeCustomProfileFieldModel({
id: 'select_field',
name: 'Select Field',
type: 'select',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
{id: 'opt3', name: 'Option 3'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'select_field',
value: 'opt1',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [selectField]);
expect(result).toEqual([{
id: 'select_field',
name: 'Select Field',
type: 'select',
value: 'Option 1',
sort_order: 1,
}]);
});
// Test useDisplayType parameter
it('should use display type when useDisplayType is true', () => {
const textField = TestHelper.fakeCustomProfileFieldModel({
id: 'text_field',
name: 'Text Field',
type: 'text',
attrs: {
sort_order: 1,
value_type: 'email',
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'text_field',
value: 'test@example.com',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [textField], undefined, true);
expect(result).toEqual([{
id: 'text_field',
name: 'Text Field',
type: 'email',
value: 'test@example.com',
sort_order: 1,
}]);
});
it('should use field type when useDisplayType is false', () => {
const textField = TestHelper.fakeCustomProfileFieldModel({
id: 'text_field',
name: 'Text Field',
type: 'text',
attrs: {
sort_order: 1,
value_type: 'email',
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'text_field',
value: 'test@example.com',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [textField], undefined, false);
expect(result).toEqual([{
id: 'text_field',
name: 'Text Field',
type: 'text',
value: 'test@example.com',
sort_order: 1,
}]);
});
it('should handle empty values for select/multiselect fields', () => {
const selectField = TestHelper.fakeCustomProfileFieldModel({
id: 'select_field',
name: 'Select Field',
type: 'select',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'select_field',
value: '',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [selectField]);
expect(result).toEqual([{
id: 'select_field',
name: 'Select Field',
type: 'select',
value: '',
sort_order: 1,
}]);
});
it('should handle multiselect with single item JSON array', () => {
const multiselectField = TestHelper.fakeCustomProfileFieldModel({
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
{id: 'opt2', name: 'Option 2'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'multiselect_field',
value: '["opt1"]',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [multiselectField]);
expect(result).toEqual([{
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
value: 'Option 1',
sort_order: 1,
}]);
});
it('should handle multiselect with duplicate option IDs', () => {
const multiselectField = TestHelper.fakeCustomProfileFieldModel({
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
attrs: {
sort_order: 1,
options: [
{id: 'opt1', name: 'Option 1'},
{id: 'opt2', name: 'Option 2'},
],
},
});
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'multiselect_field',
value: '["opt1", "opt1", "opt2"]',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [multiselectField]);
expect(result).toEqual([{
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
value: 'Option 1, Option 1, Option 2',
sort_order: 1,
}]);
});
it('should handle fields with no attrs property', () => {
const field = TestHelper.fakeCustomProfileFieldModel({
id: 'field1',
name: 'Field 1',
type: 'text',
});
// Remove attrs property completely
delete (field as any).attrs;
const attributes = [TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'field1',
value: 'value1',
})];
const result = convertProfileAttributesToCustomAttributes(attributes, [field]);
expect(result).toEqual([{
id: 'field1',
name: 'Field 1',
type: 'text',
value: 'value1',
sort_order: Number.MAX_SAFE_INTEGER,
}]);
});
it('should handle mixed field types in same conversion', () => {
const fields = [
TestHelper.fakeCustomProfileFieldModel({
id: 'text_field',
name: 'Text Field',
type: 'text',
attrs: {sort_order: 1},
}),
TestHelper.fakeCustomProfileFieldModel({
id: 'select_field',
name: 'Select Field',
type: 'select',
attrs: {
sort_order: 2,
options: [{id: 'opt1', name: 'Option 1'}],
},
}),
TestHelper.fakeCustomProfileFieldModel({
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
attrs: {
sort_order: 3,
options: [{id: 'opt2', name: 'Option 2'}, {id: 'opt3', name: 'Option 3'}],
},
}),
];
const attributes = [
TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'text_field',
value: 'text value',
}),
TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'select_field',
value: 'opt1',
}),
TestHelper.fakeCustomProfileAttributeModel({
fieldId: 'multiselect_field',
value: '["opt2", "opt3"]',
}),
];
const result = convertProfileAttributesToCustomAttributes(attributes, fields);
expect(result).toEqual([
{
id: 'text_field',
name: 'Text Field',
type: 'text',
value: 'text value',
sort_order: 1,
},
{
id: 'select_field',
name: 'Select Field',
type: 'select',
value: 'Option 1',
sort_order: 2,
},
{
id: 'multiselect_field',
name: 'Multiselect Field',
type: 'multiselect',
value: 'Option 2, Option 3',
sort_order: 3,
},
]);
});
});
describe('getDisplayType', () => {
it('should return value_type when field type is text and value_type exists', () => {
const field = {
type: 'text',
attrs: {
value_type: 'email',
},
} as CustomProfileFieldModel;
expect(getDisplayType(field)).toBe('email');
});
it('should return field type when value_type is empty string', () => {
const field = {
type: 'text',
attrs: {
value_type: '',
},
} as CustomProfileFieldModel;
expect(getDisplayType(field)).toBe('text');
});
it('should return field type when value_type is undefined', () => {
const field = {
type: 'text',
attrs: {},
} as CustomProfileFieldModel;
expect(getDisplayType(field)).toBe('text');
});
it('should return field type when field type is not text', () => {
const field = {
type: 'select',
attrs: {
value_type: 'email',
},
} as CustomProfileFieldModel;
expect(getDisplayType(field)).toBe('select');
});
it('should return field type when attrs is undefined', () => {
const field = TestHelper.fakeCustomProfileFieldModel({
type: 'text',
});
// Manually set attrs to undefined to test edge case
(field as any).attrs = undefined;
expect(getDisplayType(field)).toBe('text');
});
});

View file

@ -8,7 +8,8 @@ import {General, Permissions, Preferences} from '@constants';
import {Ringtone} from '@constants/calls';
import {CustomStatusDurationEnum} from '@constants/custom_status';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import {toTitleCase} from '@utils/helpers';
import {safeParseJSON, toTitleCase} from '@utils/helpers';
import {logError} from '@utils/log';
import type {CustomProfileFieldModel, CustomProfileAttributeModel} from '@database/models/server';
import type {CustomAttribute, CustomAttributeSet} from '@typings/api/custom_profile_attributes';
@ -425,6 +426,84 @@ export const convertToAttributesMap = (attributesToConvert: CustomAttributeSet |
return attributesMap;
};
export const getDisplayType = (field: CustomProfileFieldModel): string => {
if (field.type === 'text' && field.attrs?.value_type !== undefined && field.attrs.value_type !== '') {
return field.attrs.value_type;
}
return field.type;
};
/**
* Get the options from a select or multiselect field as a map of option IDs to option names
* @param field - The custom profile field
* @returns A record where keys are option IDs and values are option names
*/
export const getFieldOptions = (field: CustomProfileFieldModel): Record<string, string> => {
if (!field.attrs || (field.type !== 'select' && field.type !== 'multiselect')) {
return {};
}
const options = field.attrs.options as Array<{id: string; name: string}> | undefined;
if (!options || !Array.isArray(options)) {
return {};
}
const optionsMap: Record<string, string> = {};
options.forEach((option) => {
if (option.id && option.name) {
optionsMap[option.id] = option.name;
}
});
return optionsMap;
};
/**
* Convert option IDs to display values for select/multiselect fields
* @param value - The stored value (option ID or comma-separated IDs)
* @param fieldType - The field type ('select' or 'multiselect')
* @param optionsMap - Map of option IDs to option names
* @returns The display value with option names
*/
const convertOptionsToDisplayValue = (value: string, fieldType: string, optionsMap: Record<string, string>): string => {
if (!value || !optionsMap) {
return value;
}
if (fieldType === 'select') {
// Single select: just return the option name for the ID
return optionsMap[value] || value;
}
if (fieldType === 'multiselect') {
// Multi-select: handle comma-separated IDs or JSON array
let optionIds: string[] = [];
// Try parsing as JSON array first
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
optionIds = parsed;
} else {
// Fallback to comma-separated string
optionIds = value.split(',').map((id) => id.trim());
}
} catch {
// Fallback to comma-separated string
optionIds = value.split(',').map((id) => id.trim());
}
// Convert IDs to names and filter out empty values
const optionNames = optionIds.
map((id) => optionsMap[id] || id).
filter((name) => name.trim() !== '');
return optionNames.join(', ');
}
return value;
};
/**
* Convert custom profile attributes to the UI-ready CustomAttribute format
* @param attributes - Array of custom profile attribute models
@ -436,26 +515,194 @@ export const convertProfileAttributesToCustomAttributes = (
attributes: CustomProfileAttributeModel[] | null | undefined,
fields: CustomProfileFieldModel[] | null | undefined,
sortFn?: (a: CustomAttribute, b: CustomAttribute) => number,
useDisplayType: boolean = false,
): CustomAttribute[] => {
if (!attributes?.length) {
return [];
}
const fieldsMap = new Map();
const fieldOptionsMap = new Map<string, Record<string, string>>();
fields?.forEach((field) => {
const customType = useDisplayType ? getDisplayType(field) : field.type;
fieldsMap.set(field.id, {
name: field.name,
type: customType,
sort_order: field.attrs?.sort_order || Number.MAX_SAFE_INTEGER,
originalField: field,
});
// Store options map for select/multiselect fields
if (field.type === 'select' || field.type === 'multiselect') {
fieldOptionsMap.set(field.id, getFieldOptions(field));
}
});
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,
}));
const customAttrs = attributes.map((attr) => {
const field = fieldsMap.get(attr.fieldId);
let displayValue = attr.value;
// Convert option IDs to names for select/multiselect fields
if (field && (field.type === 'select' || field.type === 'multiselect')) {
const optionsMap = fieldOptionsMap.get(attr.fieldId);
if (optionsMap && Object.keys(optionsMap).length > 0) {
displayValue = convertOptionsToDisplayValue(attr.value, field.type, optionsMap);
}
}
return ({
id: attr.fieldId,
name: field?.name || attr.fieldId,
type: field?.type || 'text',
value: displayValue,
sort_order: field?.sort_order || Number.MAX_SAFE_INTEGER,
});
});
// Sort if a sort function is provided
return sortFn ? customAttrs.sort(sortFn) : customAttrs;
};
/**
* Convert display values back to option IDs for select/multiselect fields
* @param displayValue - The display value (option names)
* @param fieldType - The field type ('select' or 'multiselect')
* @param optionsMap - Map of option IDs to option names
* @returns The option IDs as string (select) or JSON array string (multiselect)
*/
export const convertDisplayValueToOptionIds = (displayValue: string, fieldType: string, optionsMap: Record<string, string>): string => {
if (!displayValue || !optionsMap) {
return displayValue;
}
// Create reverse map (option names to IDs)
const reverseOptionsMap: Record<string, string> = {};
Object.entries(optionsMap).forEach(([id, name]) => {
reverseOptionsMap[name] = id;
});
if (fieldType === 'select') {
// Single select: return the option ID for the name
return reverseOptionsMap[displayValue] || displayValue;
}
if (fieldType === 'multiselect') {
// Multi-select: split display names and convert to IDs
const optionNames = displayValue.split(',').map((name) => name.trim()).filter((name) => name !== '');
const optionIds = optionNames.map((name) => reverseOptionsMap[name] || name);
return JSON.stringify(optionIds);
}
return displayValue;
};
/**
* Get selected option IDs from a custom attribute value
* @param value - The stored value (option ID or JSON array string)
* @param fieldType - The field type ('select' or 'multiselect')
* @returns Array of selected option IDs
*/
export const getSelectedOptionIds = (value: string, fieldType: string): string[] => {
if (!value) {
return [];
}
if (fieldType === 'select') {
return [value];
}
if (fieldType === 'multiselect') {
try {
const parsed: unknown = safeParseJSON(value);
if (Array.isArray(parsed)) {
return parsed;
}
// Fallback to comma-separated string
return value.split(',').map((id) => id.trim()).filter((id) => id !== '');
} catch (error) {
return value.split(',').map((id) => id.trim()).filter((id) => id !== '');
}
}
return [];
};
/**
* Format field options for use with AutocompleteSelector component
* @param field - The custom profile field
* @returns Array of DialogOption objects for the selector
*/
export const formatOptionsForSelector = (field: CustomProfileFieldModel): DialogOption[] => {
if (!field.attrs || (field.type !== 'select' && field.type !== 'multiselect')) {
return [];
}
const options = field.attrs.options as Array<{id: string; name: string}> | undefined;
if (!options || !Array.isArray(options)) {
return [];
}
return options.map((option) => ({
text: option.name,
value: option.id,
}));
};
/**
* Convert selected option IDs to the format expected by the server
* @param value - The stored value (option ID or JSON array string)
* @param fieldType - The field type ('select' or 'multiselect')
* @returns String for select, array for multiselect
*/
export const convertValueForServer = (value: string, fieldType: string): string|string[] => {
if (fieldType !== 'multiselect') {
return value;
}
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed;
}
} catch {
const parsed = value.split(',').map((id) => id.trim()).filter((id) => id !== '');
return parsed;
}
return [];
};
/**
* Convert server response values to the format expected by the UI
* @param value - The value from server (string or array)
* @param fieldType - The field type ('select' or 'multiselect')
* @returns String representation for UI storage
*/
export const convertValueFromServer = (value: string | string[], fieldType: string): string => {
if (value === null || value === undefined) {
return '';
}
if (fieldType === 'select') {
return Array.isArray(value) ? (value[0] || '') : String(value);
}
if (fieldType === 'multiselect') {
if (Array.isArray(value)) {
try {
return JSON.stringify(value);
} catch (error) {
logError('Error converting value from server to string', error);
return '';
}
}
return String(value);
}
return String(value);
};

View file

@ -493,6 +493,7 @@
"mobile.account.settings.save": "Save",
"mobile.acknowledgements.header": "Acknowledgements",
"mobile.action_menu.select": "Select an option",
"mobile.action_menu.select_multiple": "Select one or more options",
"mobile.add_team.create_team": "Create a new team",
"mobile.add_team.join_team": "Join Another Team",
"mobile.android.back_handler_exit": "Press back again to exit",
@ -1108,6 +1109,7 @@
"screens.channel_info.gm": "Group message info",
"search_bar.search": "Search",
"search_bar.search.placeholder": "Search timezone",
"select_field.optional": "{labelName} (optional)",
"select_team.description": "You are not yet a member of any teams. Select one below to get started.",
"select_team.no_team.description": "To join a team, ask a team admin for an invite, or create your own team. You may also want to check your email inbox for an invitation.",
"select_team.no_team.title": "No teams are available to join",

View file

@ -16,7 +16,7 @@ type CustomProfileField = {
/** name of the field **/
name: string;
/** type of values accepted. Currently only text is supported **/
/** type of values accepted. **/
type: string;
/** any extra properties of the field **/
@ -59,13 +59,14 @@ type CustomProfileAttribute = {
* @description simpler type to display a field id with its value, when we already know it all belongs to the same user
**/
type UserCustomProfileAttributeSimple = {
[field_id: string]: string;
[field_id: string]: string|string[];
}
export type CustomAttribute = {
id: string;
name: string;
value: string;
type: string;
sort_order?: number;
}

View file

@ -7,6 +7,8 @@ import type {Associations} from '@nozbe/watermelondb/Model';
export type CustomProfileFieldAttrs = {
sort_order?: number;
value_type?: string;
options?: Array<{id: string; name: string; color?: string}>;
[key: string]: unknown;
};

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import type {AvailableScreens} from './navigation';
import type {CustomProfileAttributeModel, CustomProfileFieldModel} from '@database/models/server';
import type {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';
@ -28,7 +28,6 @@ export type EditProfileProps = {
lockedPosition: boolean;
lockedPicture: boolean;
enableCustomAttributes: boolean;
userCustomAttributes: CustomProfileAttributeModel[];
customFields: CustomProfileFieldModel[];
customAttributesSet?: CustomAttributeSet;
};
@ -43,3 +42,28 @@ export type FieldSequence = Record<string, {
export type FieldConfig = Pick<FieldProps, 'blurOnSubmit' | 'enablesReturnKeyAutomatically' | 'onFocusNextField' | 'onTextChange' | 'returnKeyType'>
export type SelectFieldProps = {
fieldKey: string;
label: string;
value: string;
options: DialogOption[];
isDisabled?: boolean;
onValueChange: (fieldKey: string, value: string) => void;
onFocusNextField: (fieldKey: string) => void;
testID: string;
isOptional?: boolean;
isMultiselect?: boolean;
}
export type CustomFieldRenderProps = {
fieldKey: string;
field: CustomProfileFieldModel;
value: string;
isDisabled: boolean;
onUpdateField: (fieldKey: string, value: string) => void;
onFocusNextField: (fieldKey: string) => void;
testID: string;
isOptional?: boolean;
returnKeyType?: 'next' | 'done';
}