From 972bd34da65a19f28e6e3f5d3fbeda2ffee78306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillermo=20Vay=C3=A1?= Date: Thu, 12 Jun 2025 10:59:13 +0200 Subject: [PATCH] [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 --- app/actions/remote/custom_profile.test.ts | 105 +++ app/actions/remote/custom_profile.ts | 32 +- app/actions/remote/user.test.ts | 317 +++++++++ app/actions/remote/user.ts | 3 +- .../autocomplete_selector/index.tsx | 4 +- .../models/server/custom_profile_field.ts | 2 + .../edit_profile/components/form.test.tsx | 267 ++++++- app/screens/edit_profile/components/form.tsx | 41 +- .../components/select_field.test.tsx | 260 +++++++ .../edit_profile/components/select_field.tsx | 139 ++++ .../edit_profile/edit_profile.test.tsx | 41 +- app/screens/edit_profile/edit_profile.tsx | 5 +- app/screens/edit_profile/index.ts | 3 +- .../integration_selector.tsx | 1 - .../user_profile/custom_attributes.test.tsx | 258 +++++++ .../user_profile/custom_attributes.tsx | 31 +- app/screens/user_profile/index.ts | 8 +- app/screens/user_profile/label.test.tsx | 401 +++++++++++ app/screens/user_profile/label.tsx | 50 +- app/screens/user_profile/label_link.test.tsx | 319 +++++++++ app/screens/user_profile/label_link.tsx | 56 ++ app/screens/user_profile/user_info.test.tsx | 13 +- app/screens/user_profile/user_info.tsx | 6 +- app/utils/user/index.test.ts | 664 +++++++++++++++++- app/utils/user/index.ts | 261 ++++++- assets/base/i18n/en.json | 2 + types/api/custom_profile_attributes.d.ts | 5 +- .../models/servers/custom_profile_field.ts | 2 + types/screens/edit_profile.ts | 28 +- 29 files changed, 3238 insertions(+), 86 deletions(-) create mode 100644 app/screens/edit_profile/components/select_field.test.tsx create mode 100644 app/screens/edit_profile/components/select_field.tsx create mode 100644 app/screens/user_profile/custom_attributes.test.tsx create mode 100644 app/screens/user_profile/label.test.tsx create mode 100644 app/screens/user_profile/label_link.test.tsx create mode 100644 app/screens/user_profile/label_link.tsx diff --git a/app/actions/remote/custom_profile.test.ts b/app/actions/remote/custom_profile.test.ts index 80c957bc6..a7e895194 100644 --- a/app/actions/remote/custom_profile.test.ts +++ b/app/actions/remote/custom_profile.test.ts @@ -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'); + }); }); diff --git a/app/actions/remote/custom_profile.ts b/app/actions/remote/custom_profile.ts index c56589784..71593495a 100644 --- a/app/actions/remote/custom_profile.ts +++ b/app/actions/remote/custom_profile.ts @@ -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 = {}; + let attrValues: Record = {}; try { [fields, attrValues] = await Promise.all([ client.getCustomProfileAttributeFields(), client.getCustomProfileAttributeValues(userId), ]); + } catch (err) { - logDebug('error on fetchCustomProfileAttributes get fields and attr values', getFullErrorMessage(err)); + 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> = []; const fieldModelPromises: Array> = []; 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}; } diff --git a/app/actions/remote/user.test.ts b/app/actions/remote/user.test.ts index 8664f6a75..4e910acdb 100644 --- a/app/actions/remote/user.test.ts +++ b/app/actions/remote/user.test.ts @@ -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({}); + }); +}); diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 4fbbc4b9e..6ca69769f 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -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, }; } diff --git a/app/components/autocomplete_selector/index.tsx b/app/components/autocomplete_selector/index.tsx index 77cce1461..c90c5057b 100644 --- a/app/components/autocomplete_selector/index.tsx +++ b/app/components/autocomplete_selector/index.tsx @@ -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 ( diff --git a/app/database/models/server/custom_profile_field.ts b/app/database/models/server/custom_profile_field.ts index fc61e90d2..8a5fc32f7 100644 --- a/app/database/models/server/custom_profile_field.ts +++ b/app/database/models/server/custom_profile_field.ts @@ -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; diff --git a/app/screens/edit_profile/components/form.test.tsx b/app/screens/edit_profile/components/form.test.tsx index 8221dd802..53b9a9240 100644 --- a/app/screens/edit_profile/components/form.test.tsx +++ b/app/screens/edit_profile/components/form.test.tsx @@ -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 = { 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // Should render as a text field since no field definition exists + expect(getByTestId('edit_profile_form.customAttributes.unknownField')).toBeTruthy(); + }); }); diff --git a/app/screens/edit_profile/components/form.tsx b/app/screens/edit_profile/components/form.tsx index 648469682..9686463fc 100644 --- a/app/screens/edit_profile/components/form.tsx +++ b/app/screens/edit_profile/components/form.tsx @@ -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(); + 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 ( + + ); + } + + // Default to text field for other types return ( ); }; + const renderAttribute = (key: string, notLast: boolean) => { if (key.startsWith(CUSTOM_ATTRS_PREFIX)) { return renderCustomAttribute(key, notLast); diff --git a/app/screens/edit_profile/components/select_field.test.tsx b/app/screens/edit_profile/components/select_field.test.tsx new file mode 100644 index 000000000..64639dbf9 --- /dev/null +++ b/app/screens/edit_profile/components/select_field.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByTestId('test_field')).toBeTruthy(); + }); + + it('should handle undefined value', () => { + const props = {...baseProps, value: undefined as any}; + renderWithIntl(); + + expect(screen.getByTestId('test_field')).toBeTruthy(); + }); + + it('should handle null value', () => { + const props = {...baseProps, value: null as any}; + renderWithIntl(); + + 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(); + + 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(); + + 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(); + + 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(); + + const selector = screen.getByTestId('test_field.selector'); + + // Simulate AutocompleteSelector calling onSelected with null + selector.props.onSelected(null); + + expect(onValueChange).toHaveBeenCalledWith('test_field', ''); + }); + }); +}); diff --git a/app/screens/edit_profile/components/select_field.tsx b/app/screens/edit_profile/components/select_field.tsx new file mode 100644 index 000000000..a3624b405 --- /dev/null +++ b/app/screens/edit_profile/components/select_field.tsx @@ -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 ( + + + + ); +}; + +export default SelectField; diff --git a/app/screens/edit_profile/edit_profile.test.tsx b/app/screens/edit_profile/edit_profile.test.tsx index 430234a63..2fe6942ab 100644 --- a/app/screens/edit_profile/edit_profile.test.tsx +++ b/app/screens/edit_profile/edit_profile.test.tsx @@ -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( + , + ); + + // Verify the ProfileForm component is rendered (which means customFields was passed) + const scrollView = screen.getByTestId('edit_profile.scroll_view'); + expect(scrollView).toBeTruthy(); + }); }); diff --git a/app/screens/edit_profile/edit_profile.tsx b/app/screens/edit_profile/edit_profile.tsx index 36b79c011..eebc56992 100644 --- a/app/screens/edit_profile/edit_profile.tsx +++ b/app/screens/edit_profile/edit_profile.tsx @@ -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} /> ) : null; diff --git a/app/screens/edit_profile/index.ts b/app/screens/edit_profile/index.ts index 8904b9478..e70161b84 100644 --- a/app/screens/edit_profile/index.ts +++ b/app/screens/edit_profile/index.ts @@ -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))), ); diff --git a/app/screens/integration_selector/integration_selector.tsx b/app/screens/integration_selector/integration_selector.tsx index 08b30e755..ca3fa6a76 100644 --- a/app/screens/integration_selector/integration_selector.tsx +++ b/app/screens/integration_selector/integration_selector.tsx @@ -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'), }, diff --git a/app/screens/user_profile/custom_attributes.test.tsx b/app/screens/user_profile/custom_attributes.test.tsx new file mode 100644 index 000000000..98e674fae --- /dev/null +++ b/app/screens/user_profile/custom_attributes.test.tsx @@ -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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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(); + }); +}); diff --git a/app/screens/user_profile/custom_attributes.tsx b/app/screens/user_profile/custom_attributes.tsx index 6e5d34306..c905559e9 100644 --- a/app/screens/user_profile/custom_attributes.tsx +++ b/app/screens/user_profile/custom_attributes.tsx @@ -22,6 +22,7 @@ const renderAttribute: ListRenderItem = ({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 ( { + 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 diff --git a/app/screens/user_profile/label.test.tsx b/app/screens/user_profile/label.test.tsx new file mode 100644 index 000000000..a4031a2d2 --- /dev/null +++ b/app/screens/user_profile/label.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(getByTestId('custom-test-id.title')).toBeTruthy(); + expect(getByTestId('custom-test-id.text')).toBeTruthy(); + }); + + it('should work with url type testID', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + expect(getByTestId('custom-test-id.title')).toBeTruthy(); + expect(getByTestId('custom-test-id.url')).toBeTruthy(); + }); + + it('should work with phone type testID', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + expect(getByTestId('custom-test-id.title')).toBeTruthy(); + expect(getByTestId('custom-test-id.phone')).toBeTruthy(); + }); + + it('should work with select type testID', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + const linkElement = getByTestId('test-label.url'); + expect(linkElement).toBeTruthy(); + + // TouchableOpacity should be accessible by default + expect(linkElement.type).toBe('Text'); + }); + }); +}); diff --git a/app/screens/user_profile/label.tsx b/app/screens/user_profile/label.tsx index e24095772..d29a5244e 100644 --- a/app/screens/user_profile/label.tsx +++ b/app/screens/user_profile/label.tsx @@ -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 = ( + + ); + break; + case 'select': + case 'multiselect': + descriptionComponent = ( + + {description} + + ); + break; + case 'text': + default: + descriptionComponent = ( + + {description} + + ); + break; + } return ( @@ -48,12 +85,7 @@ const UserProfileLabel = ({title, description, testID}: Props) => { > {title} - - {description} - + {descriptionComponent} ); }; diff --git a/app/screens/user_profile/label_link.test.tsx b/app/screens/user_profile/label_link.test.tsx new file mode 100644 index 000000000..0106e4145 --- /dev/null +++ b/app/screens/user_profile/label_link.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByTestId('custom-test.url')).toBeTruthy(); + }); + + it('should generate correct testID for phone type', () => { + renderWithIntlAndTheme( + , + ); + + expect(screen.getByTestId('custom-test.phone')).toBeTruthy(); + }); + + it('should handle undefined testID gracefully', () => { + renderWithIntlAndTheme( + , + ); + + expect(screen.getByText('example.com')).toBeTruthy(); + }); + }); + + describe('edge cases', () => { + it('should handle empty description', () => { + renderWithIntlAndTheme( + , + ); + + const linkElement = screen.getByTestId('test-link.url'); + expect(linkElement.props.children).toBe(''); + }); + + it('should handle whitespace in description', () => { + mockGetScheme.mockReturnValue(null); + + renderWithIntlAndTheme( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(getByText('example.com')).toBeTruthy(); + }); + }); + + describe('integration with URL utils', () => { + it('should call getScheme with the correct description', () => { + mockGetScheme.mockReturnValue(null); + + renderWithIntlAndTheme( + , + ); + + 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( + , + ); + + const linkElement = screen.getByTestId('test-link.url'); + fireEvent.press(linkElement); + + expect(mockTryOpenURL).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/screens/user_profile/label_link.tsx b/app/screens/user_profile/label_link.tsx new file mode 100644 index 000000000..3f6b4ae39 --- /dev/null +++ b/app/screens/user_profile/label_link.tsx @@ -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 ( + + + {description} + + + ); +}; + +export default UserProfileLink; diff --git a/app/screens/user_profile/user_info.test.tsx b/app/screens/user_profile/user_info.test.tsx index 00e628e7c..4c6a0a8d3 100644 --- a/app/screens/user_profile/user_info.test.tsx +++ b/app/screens/user_profile/user_info.test.tsx @@ -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({ diff --git a/app/screens/user_profile/user_info.tsx b/app/screens/user_profile/user_info.tsx index 68dea5b99..73c9d26d6 100644 --- a/app/screens/user_profile/user_info.tsx +++ b/app/screens/user_profile/user_info.tsx @@ -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(); diff --git a/app/utils/user/index.test.ts b/app/utils/user/index.test.ts index 70a278c36..794ecd355 100644 --- a/app/utils/user/index.test.ts +++ b/app/utils/user/index.test.ts @@ -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'); + }); }); diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index 2a78eb59e..d21adb1e8 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -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 => { + 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 = {}; + 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 => { + 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>(); 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 => { + if (!displayValue || !optionsMap) { + return displayValue; + } + + // Create reverse map (option names to IDs) + const reverseOptionsMap: Record = {}; + 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); +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index fdcb909dd..c86fbc56d 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/types/api/custom_profile_attributes.d.ts b/types/api/custom_profile_attributes.d.ts index 2b48056ad..2d5cca662 100644 --- a/types/api/custom_profile_attributes.d.ts +++ b/types/api/custom_profile_attributes.d.ts @@ -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; } diff --git a/types/database/models/servers/custom_profile_field.ts b/types/database/models/servers/custom_profile_field.ts index e994295aa..fd702ece5 100644 --- a/types/database/models/servers/custom_profile_field.ts +++ b/types/database/models/servers/custom_profile_field.ts @@ -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; }; diff --git a/types/screens/edit_profile.ts b/types/screens/edit_profile.ts index 372cfd75d..b2eebc5dd 100644 --- a/types/screens/edit_profile.ts +++ b/types/screens/edit_profile.ts @@ -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 +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'; +} +