[MM-62700] User Attribute types (#8903)
* add basic types to user_profile * fix storing multiselect in DB * fix storing multi-select on db * add tests * add tests for select-multiselect * imporove testing * improve intl, add tests * address comments and tests * review comments * more test refactoring * remove styling tests * remove styling tests * small improvements * nitpick * improve messaging * safer stringify * fix tests
This commit is contained in:
parent
efeabb48a2
commit
972bd34da6
29 changed files with 3238 additions and 86 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import DatabaseManager from '@database/manager';
|
|||
import NetworkManager from '@managers/network_manager';
|
||||
import {customProfileAttributeId} from '@utils/custom_profile_attribute';
|
||||
import {getFullErrorMessage} from '@utils/errors';
|
||||
import {logDebug} from '@utils/log';
|
||||
import {logError} from '@utils/log';
|
||||
import {convertValueForServer} from '@utils/user';
|
||||
|
||||
import type Model from '@nozbe/watermelondb/Model';
|
||||
import type {CustomProfileField, CustomAttribute, CustomAttributeSet, CustomProfileAttribute, UserCustomProfileAttributeSimple} from '@typings/api/custom_profile_attributes';
|
||||
|
|
@ -25,15 +26,16 @@ export const fetchCustomProfileAttributes = async (serverUrl: string, userId: st
|
|||
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
let fields: CustomProfileField[] = [];
|
||||
let attrValues: Record<string, string> = {};
|
||||
let attrValues: Record<string, string | string[]> = {};
|
||||
|
||||
try {
|
||||
[fields, attrValues] = await Promise.all([
|
||||
client.getCustomProfileAttributeFields(),
|
||||
client.getCustomProfileAttributeValues(userId),
|
||||
]);
|
||||
|
||||
} catch (err) {
|
||||
logDebug('error on fetchCustomProfileAttributes get fields and attr values', getFullErrorMessage(err));
|
||||
logError('error on fetchCustomProfileAttributes get fields and attr values', getFullErrorMessage(err));
|
||||
return {attributes, error: err};
|
||||
}
|
||||
|
||||
|
|
@ -46,11 +48,25 @@ export const fetchCustomProfileAttributes = async (serverUrl: string, userId: st
|
|||
const attributeModelPromises: Array<Promise<Model[]>> = [];
|
||||
const fieldModelPromises: Array<Promise<Model[]>> = [];
|
||||
for (const field of fields) {
|
||||
const value = attrValues[field.id] || '';
|
||||
const rawValue = attrValues[field.id];
|
||||
let value = '';
|
||||
|
||||
// Handle different value types properly
|
||||
if (rawValue !== undefined && rawValue !== null) {
|
||||
if (Array.isArray(rawValue)) {
|
||||
// For arrays (multiselect), serialize to JSON string
|
||||
value = JSON.stringify(rawValue);
|
||||
} else {
|
||||
// For other types, convert to string
|
||||
value = String(rawValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (!filterEmpty || value) {
|
||||
attributes[field.id] = {
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
type: field.type || 'text',
|
||||
value,
|
||||
sort_order: field.attrs?.sort_order,
|
||||
};
|
||||
|
|
@ -87,13 +103,13 @@ export const fetchCustomProfileAttributes = async (serverUrl: string, userId: st
|
|||
}
|
||||
|
||||
} catch (err) {
|
||||
logDebug('error on fetchCustomProfileAttributes field iteration', getFullErrorMessage(err));
|
||||
logError('error on fetchCustomProfileAttributes field iteration', getFullErrorMessage(err));
|
||||
return {attributes, error: err};
|
||||
}
|
||||
|
||||
return {attributes, error: undefined};
|
||||
} catch (error) {
|
||||
logDebug('error on fetchCustomProfileAttributes', getFullErrorMessage(error));
|
||||
logError('error on fetchCustomProfileAttributes', getFullErrorMessage(error));
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {attributes, error};
|
||||
}
|
||||
|
|
@ -114,7 +130,7 @@ export const updateCustomProfileAttributes = async (serverUrl: string, userId: s
|
|||
|
||||
// Convert attributes to the format expected by the API
|
||||
Object.entries(attributes).forEach(([id, attr]) => {
|
||||
attributeValues[id] = attr.value;
|
||||
attributeValues[id] = convertValueForServer(attr.value, attr.type);
|
||||
});
|
||||
|
||||
// Update on the server
|
||||
|
|
@ -137,7 +153,7 @@ export const updateCustomProfileAttributes = async (serverUrl: string, userId: s
|
|||
|
||||
return {success: true, error: undefined};
|
||||
} catch (error) {
|
||||
logDebug('error on updateCustomProfileAttributes', getFullErrorMessage(error));
|
||||
logError('error on updateCustomProfileAttributes', getFullErrorMessage(error));
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {success: false, error};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ function AutoCompleteSelector({
|
|||
if (onSelected) {
|
||||
onSelected(selectedOptions);
|
||||
}
|
||||
}, [teammateNameDisplay, intl, dataSource]);
|
||||
}, [teammateNameDisplay, intl, dataSource, onSelected]);
|
||||
|
||||
// Handle the text for the default value.
|
||||
useEffect(() => {
|
||||
|
|
@ -202,7 +202,7 @@ function AutoCompleteSelector({
|
|||
Promise.all(namePromises).then((names) => {
|
||||
setItemText(names.join(', '));
|
||||
});
|
||||
}, []);
|
||||
}, [dataSource, teammateNameDisplay, intl, options, selected, serverUrl]);
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent} from '@testing-library/react-native';
|
||||
import {fireEvent, screen} from '@testing-library/react-native';
|
||||
import React, {type ComponentProps} from 'react';
|
||||
|
||||
import {renderWithIntl} from '@test/intl-test-helper';
|
||||
|
|
@ -11,6 +11,17 @@ import ProfileForm from './form';
|
|||
|
||||
import type {CustomAttributeSet} from '@typings/api/custom_profile_attributes';
|
||||
|
||||
// Mock AutocompleteSelector to avoid database dependency
|
||||
jest.mock('@components/autocomplete_selector', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const MockAutocompleteSelector = jest.requireMock('@components/autocomplete_selector').default;
|
||||
MockAutocompleteSelector.mockImplementation((props: any) =>
|
||||
React.createElement('AutocompleteSelector', {...props}),
|
||||
);
|
||||
|
||||
describe('ProfileForm', () => {
|
||||
const baseProps: ComponentProps<typeof ProfileForm> = {
|
||||
canSave: false,
|
||||
|
|
@ -62,11 +73,13 @@ describe('ProfileForm', () => {
|
|||
field1: {
|
||||
id: 'field1',
|
||||
name: 'Field 1',
|
||||
type: 'text',
|
||||
value: 'value1',
|
||||
},
|
||||
field2: {
|
||||
id: 'field2',
|
||||
name: 'Field 2',
|
||||
type: 'text',
|
||||
value: 'value2',
|
||||
},
|
||||
},
|
||||
|
|
@ -94,6 +107,7 @@ describe('ProfileForm', () => {
|
|||
field1: {
|
||||
id: 'field1',
|
||||
name: 'Field 1',
|
||||
type: 'text',
|
||||
value: 'value1',
|
||||
},
|
||||
},
|
||||
|
|
@ -134,18 +148,21 @@ describe('ProfileForm', () => {
|
|||
name: 'Department',
|
||||
value: 'Engineering',
|
||||
sort_order: 1,
|
||||
type: 'text',
|
||||
},
|
||||
attr2: {
|
||||
id: 'attr2',
|
||||
name: 'Location',
|
||||
value: 'Remote',
|
||||
sort_order: 0,
|
||||
type: 'text',
|
||||
},
|
||||
attr3: {
|
||||
id: 'attr3',
|
||||
name: 'Start Date',
|
||||
value: '2023',
|
||||
sort_order: 2,
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -171,4 +188,252 @@ describe('ProfileForm', () => {
|
|||
expect(attributeFields[1].props.testID).toBe('edit_profile_form.customAttributes.attr1'); // sort_order: 1
|
||||
expect(attributeFields[2].props.testID).toBe('edit_profile_form.customAttributes.attr3'); // sort_order: 2
|
||||
});
|
||||
|
||||
it('should render SelectField for select type custom attributes', () => {
|
||||
const customFields = [
|
||||
TestHelper.fakeCustomProfileFieldModel({
|
||||
id: 'department',
|
||||
name: 'Department',
|
||||
type: 'select',
|
||||
attrs: {
|
||||
options: [
|
||||
{id: 'eng', name: 'Engineering'},
|
||||
{id: 'mkt', name: 'Marketing'},
|
||||
{id: 'sales', name: 'Sales'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
enableCustomAttributes: true,
|
||||
customFields,
|
||||
userInfo: {
|
||||
...baseProps.userInfo,
|
||||
customAttributes: {
|
||||
department: {
|
||||
id: 'department',
|
||||
name: 'Department',
|
||||
type: 'select',
|
||||
value: 'eng',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithIntl(
|
||||
<ProfileForm {...props}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('edit_profile_form.customAttributes.department')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render SelectField for multiselect type custom attributes', () => {
|
||||
const customFields = [
|
||||
TestHelper.fakeCustomProfileFieldModel({
|
||||
id: 'skills',
|
||||
name: 'Skills',
|
||||
type: 'multiselect',
|
||||
attrs: {
|
||||
options: [
|
||||
{id: 'js', name: 'JavaScript'},
|
||||
{id: 'react', name: 'React'},
|
||||
{id: 'ts', name: 'TypeScript'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
enableCustomAttributes: true,
|
||||
customFields,
|
||||
userInfo: {
|
||||
...baseProps.userInfo,
|
||||
customAttributes: {
|
||||
skills: {
|
||||
id: 'skills',
|
||||
name: 'Skills',
|
||||
type: 'multiselect',
|
||||
value: 'js,react',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithIntl(
|
||||
<ProfileForm {...props}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('edit_profile_form.customAttributes.skills')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render SelectField for select type custom attributes with correct value', () => {
|
||||
const customFields = [
|
||||
TestHelper.fakeCustomProfileFieldModel({
|
||||
id: 'department',
|
||||
name: 'Department',
|
||||
type: 'select',
|
||||
attrs: {
|
||||
options: [
|
||||
{id: 'eng', name: 'Engineering'},
|
||||
{id: 'mkt', name: 'Marketing'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
enableCustomAttributes: true,
|
||||
customFields,
|
||||
userInfo: {
|
||||
...baseProps.userInfo,
|
||||
customAttributes: {
|
||||
department: {
|
||||
id: 'department',
|
||||
name: 'Department',
|
||||
type: 'select',
|
||||
value: 'eng',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithIntl(
|
||||
<ProfileForm {...props}/>,
|
||||
);
|
||||
|
||||
// Verify that the SelectField is rendered with the correct testID
|
||||
expect(screen.getByTestId('edit_profile_form.customAttributes.department')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render SelectField for multiselect type custom attributes with correct value', () => {
|
||||
const customFields = [
|
||||
TestHelper.fakeCustomProfileFieldModel({
|
||||
id: 'skills',
|
||||
name: 'Skills',
|
||||
type: 'multiselect',
|
||||
attrs: {
|
||||
options: [
|
||||
{id: 'js', name: 'JavaScript'},
|
||||
{id: 'react', name: 'React'},
|
||||
{id: 'ts', name: 'TypeScript'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
enableCustomAttributes: true,
|
||||
customFields,
|
||||
userInfo: {
|
||||
...baseProps.userInfo,
|
||||
customAttributes: {
|
||||
skills: {
|
||||
id: 'skills',
|
||||
name: 'Skills',
|
||||
type: 'multiselect',
|
||||
value: 'js',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithIntl(
|
||||
<ProfileForm {...props}/>,
|
||||
);
|
||||
|
||||
// Verify that the SelectField is rendered with the correct testID
|
||||
expect(screen.getByTestId('edit_profile_form.customAttributes.skills')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render text field for custom attributes without field definition', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
enableCustomAttributes: true,
|
||||
customFields: [], // No field definitions
|
||||
userInfo: {
|
||||
...baseProps.userInfo,
|
||||
customAttributes: {
|
||||
unknownField: {
|
||||
id: 'unknownField',
|
||||
name: 'Unknown Field',
|
||||
type: 'text',
|
||||
value: 'some value',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithIntl(
|
||||
<ProfileForm {...props}/>,
|
||||
);
|
||||
|
||||
// Should render as text field since no field definition exists
|
||||
expect(screen.getByTestId('edit_profile_form.customAttributes.unknownField')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render text field for custom attributes with unsupported type', () => {
|
||||
const customFields = [
|
||||
TestHelper.fakeCustomProfileFieldModel({
|
||||
id: 'customField',
|
||||
name: 'Custom Field',
|
||||
type: 'unsupported_type',
|
||||
attrs: {},
|
||||
}),
|
||||
];
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
enableCustomAttributes: true,
|
||||
customFields,
|
||||
userInfo: {
|
||||
...baseProps.userInfo,
|
||||
customAttributes: {
|
||||
customField: {
|
||||
id: 'customField',
|
||||
name: 'Custom Field',
|
||||
type: 'unsupported_type',
|
||||
value: 'some value',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithIntl(
|
||||
<ProfileForm {...props}/>,
|
||||
);
|
||||
|
||||
// Should render as text field for unsupported types
|
||||
expect(screen.getByTestId('edit_profile_form.customAttributes.customField')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render ProfileForm without errors when custom fields have no field definitions', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
enableCustomAttributes: true,
|
||||
customFields: [], // No field definitions
|
||||
userInfo: {
|
||||
...baseProps.userInfo,
|
||||
customAttributes: {
|
||||
unknownField: {
|
||||
id: 'unknownField',
|
||||
name: 'Unknown Field',
|
||||
type: 'text',
|
||||
value: 'some value',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithIntl(
|
||||
<ProfileForm {...props}/>,
|
||||
);
|
||||
|
||||
// Should render as a text field since no field definition exists
|
||||
expect(getByTestId('edit_profile_form.customAttributes.unknownField')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ import useFieldRefs from '@hooks/field_refs';
|
|||
import {t} from '@i18n';
|
||||
import {getErrorMessage} from '@utils/errors';
|
||||
import {logError} from '@utils/log';
|
||||
import {sortCustomProfileAttributes} from '@utils/user';
|
||||
import {sortCustomProfileAttributes, formatOptionsForSelector} from '@utils/user';
|
||||
|
||||
import DisabledFields from './disabled_fields';
|
||||
import EmailField from './email_field';
|
||||
import Field from './field';
|
||||
import SelectField from './select_field';
|
||||
|
||||
import type {CustomProfileFieldModel} from '@database/models/server';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {FieldConfig, FieldSequence, UserInfo} from '@typings/screens/edit_profile';
|
||||
|
||||
|
|
@ -32,6 +34,7 @@ type Props = {
|
|||
userInfo: UserInfo;
|
||||
submitUser: () => void;
|
||||
enableCustomAttributes?: boolean;
|
||||
customFields?: CustomProfileFieldModel[];
|
||||
}
|
||||
|
||||
const includesSsoService = (sso: string) => ['gitlab', 'google', 'office365'].includes(sso);
|
||||
|
|
@ -87,7 +90,7 @@ const profileKeys = [FIRST_NAME_FIELD, LAST_NAME_FIELD, USERNAME_FIELD, EMAIL_FI
|
|||
const ProfileForm = ({
|
||||
canSave, currentUser, isTablet,
|
||||
lockedFirstName, lockedLastName, lockedNickname, lockedPosition,
|
||||
onUpdateField, userInfo, submitUser, error, enableCustomAttributes,
|
||||
onUpdateField, userInfo, submitUser, error, enableCustomAttributes, customFields,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
|
|
@ -109,6 +112,15 @@ const ProfileForm = ({
|
|||
return total_custom_attrs === 0 ? profileKeys : [...profileKeys, ...newKeys];
|
||||
}, [userInfo.customAttributes, total_custom_attrs]);
|
||||
|
||||
// Create a map of field definitions for quick lookup
|
||||
const customFieldsMap = useMemo(() => {
|
||||
const map = new Map<string, CustomProfileFieldModel>();
|
||||
customFields?.forEach((field) => {
|
||||
map.set(field.id, field);
|
||||
});
|
||||
return map;
|
||||
}, [customFields]);
|
||||
|
||||
const userProfileFields: FieldSequence = useMemo(() => {
|
||||
const service = currentUser.authService;
|
||||
const fields: FieldSequence = {};
|
||||
|
|
@ -250,13 +262,35 @@ const ProfileForm = ({
|
|||
|
||||
const renderCustomAttribute = (key: string, notLast: boolean) => {
|
||||
const fieldID = getFieldID(key);
|
||||
const customAttribute = userInfo.customAttributes[fieldID];
|
||||
const fieldDefinition = customFieldsMap.get(fieldID);
|
||||
|
||||
// Check if this is a select or multiselect field
|
||||
if (fieldDefinition && (fieldDefinition.type === 'select' || fieldDefinition.type === 'multiselect')) {
|
||||
const options = formatOptionsForSelector(fieldDefinition);
|
||||
|
||||
return (
|
||||
<SelectField
|
||||
fieldKey={key}
|
||||
label={customAttribute.name}
|
||||
value={getValue(key)}
|
||||
options={options}
|
||||
isDisabled={userProfileFields[key].isDisabled}
|
||||
onValueChange={onUpdateField}
|
||||
onFocusNextField={onFocusNextField}
|
||||
testID={`edit_profile_form.${key}`}
|
||||
isMultiselect={fieldDefinition.type === 'multiselect'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to text field for other types
|
||||
return (
|
||||
<Field
|
||||
fieldKey={key}
|
||||
isDisabled={userProfileFields[key].isDisabled}
|
||||
fieldRef={setRef(key)}
|
||||
label={userInfo.customAttributes[fieldID].name}
|
||||
label={customAttribute.name}
|
||||
maxLength={128}
|
||||
testID={`edit_profile_form.${key}`}
|
||||
{...fieldConfig}
|
||||
|
|
@ -264,6 +298,7 @@ const ProfileForm = ({
|
|||
value={getValue(key)}
|
||||
/>);
|
||||
};
|
||||
|
||||
const renderAttribute = (key: string, notLast: boolean) => {
|
||||
if (key.startsWith(CUSTOM_ATTRS_PREFIX)) {
|
||||
return renderCustomAttribute(key, notLast);
|
||||
|
|
|
|||
260
app/screens/edit_profile/components/select_field.test.tsx
Normal file
260
app/screens/edit_profile/components/select_field.test.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithIntl} from '@test/intl-test-helper';
|
||||
|
||||
import SelectField from './select_field';
|
||||
|
||||
import type {SelectFieldProps} from '@typings/screens/edit_profile';
|
||||
|
||||
// Mock the AutocompleteSelector component to avoid complex dependencies
|
||||
jest.mock('@components/autocomplete_selector', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const MockAutocompleteSelector = jest.requireMock('@components/autocomplete_selector').default;
|
||||
MockAutocompleteSelector.mockImplementation((props: any) =>
|
||||
React.createElement('AutocompleteSelector', {...props}),
|
||||
);
|
||||
|
||||
// Mock the useIsTablet hook
|
||||
jest.mock('@hooks/device', () => ({
|
||||
useIsTablet: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('SelectField', () => {
|
||||
const mockOptions: DialogOption[] = [
|
||||
{value: 'option1', text: 'Option 1'},
|
||||
{value: 'option2', text: 'Option 2'},
|
||||
{value: 'option3', text: 'Option 3'},
|
||||
];
|
||||
|
||||
const baseProps: SelectFieldProps = {
|
||||
fieldKey: 'test_field',
|
||||
label: 'Test Field',
|
||||
value: '',
|
||||
options: mockOptions,
|
||||
isDisabled: false,
|
||||
onValueChange: jest.fn(),
|
||||
onFocusNextField: jest.fn(),
|
||||
testID: 'test_field',
|
||||
isOptional: false,
|
||||
isMultiselect: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render correctly with basic props', () => {
|
||||
renderWithIntl(<SelectField {...baseProps}/>);
|
||||
|
||||
expect(screen.getByTestId('test_field')).toBeTruthy();
|
||||
expect(screen.getByTestId('test_field.selector')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single Select Mode', () => {
|
||||
it('should pass correct props for single select mode', () => {
|
||||
const props = {...baseProps, value: 'option1'};
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
const selector = screen.getByTestId('test_field.selector');
|
||||
expect(selector.props.selected).toBe('option1');
|
||||
expect(selector.props.isMultiselect).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass value as selected prop to AutocompleteSelector', () => {
|
||||
const props = {...baseProps, value: 'test_value'};
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
const selector = screen.getByTestId('test_field.selector');
|
||||
expect(selector.props.selected).toBe('test_value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiselect Mode', () => {
|
||||
it('should pass correct props for multiselect mode', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isMultiselect: true,
|
||||
value: '["option1", "option2"]',
|
||||
};
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
const selector = screen.getByTestId('test_field.selector');
|
||||
expect(selector.props.isMultiselect).toBe(true);
|
||||
expect(selector.props.selected).toEqual(['option1', 'option2']);
|
||||
});
|
||||
|
||||
it('should handle empty multiselect value', () => {
|
||||
const props = {...baseProps, isMultiselect: true, value: ''};
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
const selector = screen.getByTestId('test_field.selector');
|
||||
expect(selector.props.selected).toEqual([]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should pass disabled prop to AutocompleteSelector', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isDisabled: true,
|
||||
};
|
||||
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
const selector = screen.getByTestId('test_field.selector');
|
||||
expect(selector.props.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tablet Layout', () => {
|
||||
it('should apply tablet styles when on tablet', () => {
|
||||
const {useIsTablet} = require('@hooks/device');
|
||||
useIsTablet.mockReturnValue(true);
|
||||
|
||||
renderWithIntl(<SelectField {...baseProps}/>);
|
||||
|
||||
const container = screen.getByTestId('test_field');
|
||||
expect(container.props.style).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
paddingHorizontal: 20,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
paddingHorizontal: 42,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not apply tablet styles when not on tablet', () => {
|
||||
const {useIsTablet} = require('@hooks/device');
|
||||
useIsTablet.mockReturnValue(false);
|
||||
|
||||
renderWithIntl(<SelectField {...baseProps}/>);
|
||||
|
||||
const container = screen.getByTestId('test_field');
|
||||
expect(container.props.style).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
paddingHorizontal: 42,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty options array', () => {
|
||||
const props = {...baseProps, options: []};
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
expect(screen.getByTestId('test_field')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle undefined value', () => {
|
||||
const props = {...baseProps, value: undefined as any};
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
expect(screen.getByTestId('test_field')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle null value', () => {
|
||||
const props = {...baseProps, value: null as any};
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
expect(screen.getByTestId('test_field')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props Forwarding', () => {
|
||||
it('should forward all props to AutocompleteSelector correctly', () => {
|
||||
const onValueChange = jest.fn();
|
||||
const onFocusNextField = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
isDisabled: true,
|
||||
isMultiselect: true,
|
||||
isOptional: true,
|
||||
onValueChange,
|
||||
onFocusNextField,
|
||||
};
|
||||
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
const selector = screen.getByTestId('test_field.selector');
|
||||
|
||||
// Verify basic props
|
||||
expect(selector.props.testID).toBe('test_field.selector');
|
||||
expect(selector.props.label).toBe('Test Field (optional)');
|
||||
expect(selector.props.placeholder).toBe('Select one or more options');
|
||||
expect(selector.props.disabled).toBe(true);
|
||||
expect(selector.props.isMultiselect).toBe(true);
|
||||
expect(selector.props.options).toBe(mockOptions);
|
||||
|
||||
// Verify callback props are functions
|
||||
expect(typeof selector.props.onSelected).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle onSelected callback for single select', () => {
|
||||
const onValueChange = jest.fn();
|
||||
const onFocusNextField = jest.fn();
|
||||
const props = {...baseProps, onValueChange, onFocusNextField};
|
||||
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
const selector = screen.getByTestId('test_field.selector');
|
||||
|
||||
// Simulate AutocompleteSelector calling onSelected
|
||||
selector.props.onSelected({value: 'test_value', text: 'Test Option'});
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('test_field', 'test_value');
|
||||
expect(onFocusNextField).toHaveBeenCalledWith('test_field');
|
||||
});
|
||||
|
||||
it('should handle onSelected callback for multiselect', () => {
|
||||
const onValueChange = jest.fn();
|
||||
const onFocusNextField = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
isMultiselect: true,
|
||||
onValueChange,
|
||||
onFocusNextField,
|
||||
};
|
||||
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
const selector = screen.getByTestId('test_field.selector');
|
||||
|
||||
// Simulate AutocompleteSelector calling onSelected with array
|
||||
const selectedOptions = [{value: 'option1', text: 'Option 1'}, {value: 'option2', text: 'Option 2'}];
|
||||
selector.props.onSelected(selectedOptions);
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('test_field', '["option1","option2"]');
|
||||
expect(onFocusNextField).toHaveBeenCalledWith('test_field');
|
||||
});
|
||||
|
||||
it('should handle onSelected callback with null (clear selection)', () => {
|
||||
const onValueChange = jest.fn();
|
||||
const props = {...baseProps, onValueChange};
|
||||
|
||||
renderWithIntl(<SelectField {...props}/>);
|
||||
|
||||
const selector = screen.getByTestId('test_field.selector');
|
||||
|
||||
// Simulate AutocompleteSelector calling onSelected with null
|
||||
selector.props.onSelected(null);
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('test_field', '');
|
||||
});
|
||||
});
|
||||
});
|
||||
139
app/screens/edit_profile/components/select_field.tsx
Normal file
139
app/screens/edit_profile/components/select_field.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import AutocompleteSelector from '@components/autocomplete_selector';
|
||||
import {Screens} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {logError} from '@utils/log';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {getSelectedOptionIds} from '@utils/user';
|
||||
|
||||
import type {SelectFieldProps} from '@typings/screens/edit_profile';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
marginVertical: 8,
|
||||
paddingHorizontal: 20,
|
||||
width: '100%',
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
tabletContainer: {
|
||||
paddingHorizontal: 42,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SelectField = ({
|
||||
fieldKey,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
isDisabled = false,
|
||||
onValueChange,
|
||||
onFocusNextField,
|
||||
testID,
|
||||
isOptional = false,
|
||||
isMultiselect = false,
|
||||
}: SelectFieldProps) => {
|
||||
const theme = useTheme();
|
||||
const intl = useIntl();
|
||||
const isTablet = useIsTablet();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
let formattedLabel = label;
|
||||
if (isOptional) {
|
||||
formattedLabel = intl.formatMessage(
|
||||
{
|
||||
id: 'select_field.optional',
|
||||
defaultMessage: '{labelName} (optional)',
|
||||
},
|
||||
{labelName: label},
|
||||
);
|
||||
}
|
||||
|
||||
// Convert stored value to selected format for AutocompleteSelector
|
||||
const selectedValue = useMemo(() => {
|
||||
if (!value) {
|
||||
return isMultiselect ? [] : undefined;
|
||||
}
|
||||
|
||||
const selectedIds = getSelectedOptionIds(value, isMultiselect ? 'multiselect' : 'select');
|
||||
|
||||
if (isMultiselect) {
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
return selectedIds[0] || undefined;
|
||||
}, [value, isMultiselect]);
|
||||
|
||||
const handleSelect = useCallback((newSelection?: SelectedDialogOption) => {
|
||||
if (!newSelection) {
|
||||
onValueChange(fieldKey, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(newSelection)) {
|
||||
// Multiselect: convert array of selections to JSON string of IDs
|
||||
const selectedIds = newSelection.map((option) => option.value);
|
||||
let stringifiedIds;
|
||||
try {
|
||||
stringifiedIds = JSON.stringify(selectedIds);
|
||||
} catch (e) {
|
||||
logError('Error serializing selected IDs', e);
|
||||
stringifiedIds = '';
|
||||
}
|
||||
onValueChange(fieldKey, stringifiedIds);
|
||||
} else {
|
||||
// Single select: store the option ID
|
||||
onValueChange(fieldKey, newSelection.value);
|
||||
}
|
||||
|
||||
// Focus next field after selection
|
||||
onFocusNextField(fieldKey);
|
||||
}, [fieldKey, onValueChange, onFocusNextField]);
|
||||
|
||||
const containerStyle = useMemo(() => [
|
||||
styles.container,
|
||||
isTablet && styles.tabletContainer,
|
||||
], [isTablet, styles]);
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
if (isMultiselect) {
|
||||
return intl.formatMessage({
|
||||
id: 'mobile.action_menu.select_multiple',
|
||||
defaultMessage: 'Select one or more options',
|
||||
});
|
||||
}
|
||||
return intl.formatMessage({
|
||||
id: 'mobile.action_menu.select',
|
||||
defaultMessage: 'Select an option',
|
||||
});
|
||||
}, [intl, isMultiselect]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
testID={testID}
|
||||
>
|
||||
<AutocompleteSelector
|
||||
label={formattedLabel}
|
||||
options={options}
|
||||
selected={selectedValue}
|
||||
onSelected={handleSelect}
|
||||
disabled={isDisabled}
|
||||
isMultiselect={isMultiselect}
|
||||
testID={`${testID}.selector`}
|
||||
location={Screens.EDIT_PROFILE}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectField;
|
||||
|
|
@ -19,18 +19,21 @@ const serverAttributesSet: CustomAttributeSet = {
|
|||
name: 'Custom Attribute 1',
|
||||
value: 'server value 1',
|
||||
sort_order: 1,
|
||||
type: 'text',
|
||||
},
|
||||
attr2: {
|
||||
id: 'attr2',
|
||||
name: 'Custom Attribute 2',
|
||||
value: 'server value 2',
|
||||
sort_order: 2,
|
||||
type: 'text',
|
||||
},
|
||||
attr3: {
|
||||
id: 'attr3',
|
||||
name: 'Custom Attribute 3',
|
||||
value: 'server value 3',
|
||||
sort_order: 3,
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -40,12 +43,14 @@ const dbAttributesSet: CustomAttributeSet = {
|
|||
name: 'Custom Attribute 1',
|
||||
value: 'db value 1',
|
||||
sort_order: 1,
|
||||
type: 'text',
|
||||
},
|
||||
attr2: {
|
||||
id: 'attr2',
|
||||
name: 'Custom Attribute 2',
|
||||
value: 'db value 2',
|
||||
sort_order: 2,
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -186,7 +191,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={serverAttributesSet}
|
||||
/>,
|
||||
|
|
@ -234,7 +238,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={dbAttributesSet}
|
||||
/>,
|
||||
|
|
@ -277,7 +280,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={serverAttributesSet}
|
||||
/>,
|
||||
|
|
@ -315,7 +317,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={{}}
|
||||
/>,
|
||||
|
|
@ -339,7 +340,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={dbAttributesSet}
|
||||
/>,
|
||||
|
|
@ -359,18 +359,21 @@ describe('EditProfile', () => {
|
|||
name: 'Custom Attribute 1',
|
||||
value: 'updated db value 1',
|
||||
sort_order: 1,
|
||||
type: 'text',
|
||||
},
|
||||
attr2: {
|
||||
id: 'attr2',
|
||||
name: 'Custom Attribute 2',
|
||||
value: 'updated db value 2',
|
||||
sort_order: 2,
|
||||
type: 'text',
|
||||
},
|
||||
attr4: {
|
||||
id: 'attr4',
|
||||
name: 'New Attribute',
|
||||
value: 'new db value',
|
||||
sort_order: 4,
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -387,7 +390,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={updatedDbAttributesSet}
|
||||
/>,
|
||||
|
|
@ -419,7 +421,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={serverAttributesSet}
|
||||
/>,
|
||||
|
|
@ -468,7 +469,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={false}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={serverAttributesSet}
|
||||
/>,
|
||||
|
|
@ -514,7 +514,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={{}}
|
||||
/>,
|
||||
|
|
@ -564,7 +563,6 @@ describe('EditProfile', () => {
|
|||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
userCustomAttributes={[]}
|
||||
customFields={[]}
|
||||
customAttributesSet={serverAttributesSet}
|
||||
/>,
|
||||
|
|
@ -601,4 +599,27 @@ describe('EditProfile', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass customFields prop to ProfileForm component', async () => {
|
||||
renderWithIntlAndTheme(
|
||||
<EditProfile
|
||||
componentId={AvailableScreens.EDIT_PROFILE}
|
||||
currentUser={mockCurrentUser}
|
||||
isModal={false}
|
||||
isTablet={false}
|
||||
lockedFirstName={false}
|
||||
lockedLastName={false}
|
||||
lockedNickname={false}
|
||||
lockedPosition={false}
|
||||
lockedPicture={false}
|
||||
enableCustomAttributes={true}
|
||||
customFields={[]}
|
||||
customAttributesSet={{}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the ProfileForm component is rendered (which means customFields was passed)
|
||||
const scrollView = screen.getByTestId('edit_profile.scroll_view');
|
||||
expect(scrollView).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const CUSTOM_ATTRS_PREFIX_NAME = `${CUSTOM_ATTRS_PREFIX}.`;
|
|||
const EditProfile = ({
|
||||
componentId, currentUser, isModal, isTablet,
|
||||
lockedFirstName, lockedLastName, lockedNickname, lockedPosition, lockedPicture, enableCustomAttributes,
|
||||
customAttributesSet,
|
||||
customAttributesSet, customFields,
|
||||
}: EditProfileProps) => {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
|
|
@ -216,7 +216,7 @@ const EditProfile = ({
|
|||
const update = {...userInfo};
|
||||
if (fieldKey.startsWith(CUSTOM_ATTRS_PREFIX_NAME)) {
|
||||
const attrKey = fieldKey.slice(CUSTOM_ATTRS_PREFIX_NAME.length);
|
||||
update.customAttributes = {...update.customAttributes, [attrKey]: {id: attrKey, name: userInfo.customAttributes[attrKey].name, value, sort_order: userInfo.customAttributes[attrKey].sort_order}};
|
||||
update.customAttributes = {...update.customAttributes, [attrKey]: {id: attrKey, name: userInfo.customAttributes[attrKey].name, value, type: userInfo.customAttributes[attrKey].type, sort_order: userInfo.customAttributes[attrKey].sort_order}};
|
||||
} else {
|
||||
switch (fieldKey) {
|
||||
// typescript doesn't like to do update[fieldkey] as it might containg a customAttribute case
|
||||
|
|
@ -307,6 +307,7 @@ const EditProfile = ({
|
|||
userInfo={userInfo}
|
||||
submitUser={submitUser}
|
||||
enableCustomAttributes={enableCustomAttributes}
|
||||
customFields={customFields}
|
||||
/>
|
||||
</KeyboardAwareScrollView>
|
||||
) : null;
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
|
|
|
|||
258
app/screens/user_profile/custom_attributes.test.tsx
Normal file
258
app/screens/user_profile/custom_attributes.test.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
|
||||
import CustomAttributes from './custom_attributes';
|
||||
|
||||
import type {CustomAttribute} from '@typings/api/custom_profile_attributes';
|
||||
|
||||
describe('CustomAttributes', () => {
|
||||
const defaultProps = {
|
||||
nickname: undefined,
|
||||
position: undefined,
|
||||
localTime: undefined,
|
||||
customAttributes: undefined,
|
||||
};
|
||||
|
||||
it('renders empty component when no props are provided', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes {...defaultProps}/>,
|
||||
);
|
||||
|
||||
// Should not display any attributes when all props are undefined
|
||||
expect(screen.queryByText('Nickname')).toBeNull();
|
||||
expect(screen.queryByText('Position')).toBeNull();
|
||||
expect(screen.queryByText('Local Time')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nickname attribute when provided', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
{...defaultProps}
|
||||
nickname='TestNickname'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Nickname')).toBeVisible();
|
||||
expect(screen.getByText('TestNickname')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.nickname.title')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.nickname.text')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders position attribute when provided', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
{...defaultProps}
|
||||
position='Software Engineer'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Position')).toBeVisible();
|
||||
expect(screen.getByText('Software Engineer')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.position.title')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.position.text')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders local time attribute when provided', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
{...defaultProps}
|
||||
localTime='2:00 PM'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Local Time')).toBeVisible();
|
||||
expect(screen.getByText('2:00 PM')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.local_time.title')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.local_time.text')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders custom attributes when provided', () => {
|
||||
const customAttributes: CustomAttribute[] = [
|
||||
{
|
||||
id: 'custom1',
|
||||
name: 'Department',
|
||||
type: 'text',
|
||||
value: 'Engineering',
|
||||
},
|
||||
{
|
||||
id: 'custom2',
|
||||
name: 'Office Location',
|
||||
type: 'text',
|
||||
value: 'New York',
|
||||
},
|
||||
];
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
{...defaultProps}
|
||||
customAttributes={customAttributes}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Department')).toBeVisible();
|
||||
expect(screen.getByText('Engineering')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.custom1.title')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.custom1.text')).toBeVisible();
|
||||
|
||||
expect(screen.getByText('Office Location')).toBeVisible();
|
||||
expect(screen.getByText('New York')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.custom2.title')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.custom2.text')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders custom attributes with different types correctly', () => {
|
||||
const customAttributes: CustomAttribute[] = [
|
||||
{
|
||||
id: 'url_field',
|
||||
name: 'Website',
|
||||
type: 'url',
|
||||
value: 'https://example.com',
|
||||
},
|
||||
{
|
||||
id: 'phone_field',
|
||||
name: 'Phone',
|
||||
type: 'phone',
|
||||
value: '+1234567890',
|
||||
},
|
||||
{
|
||||
id: 'select_field',
|
||||
name: 'Team',
|
||||
type: 'select',
|
||||
value: 'Frontend',
|
||||
},
|
||||
];
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
{...defaultProps}
|
||||
customAttributes={customAttributes}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Website')).toBeVisible();
|
||||
expect(screen.getByText('https://example.com')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.url_field.title')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.url_field.url')).toBeVisible();
|
||||
|
||||
expect(screen.getByText('Phone')).toBeVisible();
|
||||
expect(screen.getByText('+1234567890')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.phone_field.title')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.phone_field.phone')).toBeVisible();
|
||||
|
||||
expect(screen.getByText('Team')).toBeVisible();
|
||||
expect(screen.getByText('Frontend')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.select_field.title')).toBeVisible();
|
||||
expect(screen.getByTestId('custom_attribute.select_field.select')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders all standard and custom attributes together', () => {
|
||||
const customAttributes: CustomAttribute[] = [
|
||||
{
|
||||
id: 'custom_field',
|
||||
name: 'Skills',
|
||||
type: 'text',
|
||||
value: 'React, TypeScript',
|
||||
},
|
||||
];
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
nickname='JohnDoe'
|
||||
position='Senior Developer'
|
||||
localTime='3:30 PM'
|
||||
customAttributes={customAttributes}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Standard attributes
|
||||
expect(screen.getByText('Nickname')).toBeVisible();
|
||||
expect(screen.getByText('JohnDoe')).toBeVisible();
|
||||
expect(screen.getByText('Position')).toBeVisible();
|
||||
expect(screen.getByText('Senior Developer')).toBeVisible();
|
||||
expect(screen.getByText('Local Time')).toBeVisible();
|
||||
expect(screen.getByText('3:30 PM')).toBeVisible();
|
||||
|
||||
// Custom attributes
|
||||
expect(screen.getByText('Skills')).toBeVisible();
|
||||
expect(screen.getByText('React, TypeScript')).toBeVisible();
|
||||
});
|
||||
|
||||
it('handles empty custom attributes array', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
{...defaultProps}
|
||||
customAttributes={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not display any attributes when custom attributes is empty array
|
||||
expect(screen.queryByText('Department')).toBeNull();
|
||||
expect(screen.queryByText('Skills')).toBeNull();
|
||||
});
|
||||
|
||||
it('filters out empty standard attributes correctly', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
nickname=''
|
||||
position='Developer'
|
||||
localTime=''
|
||||
customAttributes={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only position should be visible since nickname and localTime are empty strings
|
||||
expect(screen.getByText('Position')).toBeVisible();
|
||||
expect(screen.getByText('Developer')).toBeVisible();
|
||||
|
||||
// Empty strings should not create attributes
|
||||
expect(screen.queryByText('Nickname')).toBeNull();
|
||||
expect(screen.queryByText('Local Time')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders FlatList with correct props', () => {
|
||||
const customAttributes: CustomAttribute[] = [
|
||||
{
|
||||
id: 'test1',
|
||||
name: 'Test Field',
|
||||
type: 'text',
|
||||
value: 'Test Value',
|
||||
},
|
||||
];
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
{...defaultProps}
|
||||
customAttributes={customAttributes}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the FlatList is rendered (by checking if the data is displayed)
|
||||
expect(screen.getByText('Test Field')).toBeVisible();
|
||||
expect(screen.getByText('Test Value')).toBeVisible();
|
||||
});
|
||||
|
||||
it('handles partial standard attributes correctly', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<CustomAttributes
|
||||
nickname='TestUser'
|
||||
position={undefined}
|
||||
localTime='4:00 PM'
|
||||
customAttributes={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only nickname and localTime should be visible
|
||||
expect(screen.getByText('Nickname')).toBeVisible();
|
||||
expect(screen.getByText('TestUser')).toBeVisible();
|
||||
expect(screen.getByText('Local Time')).toBeVisible();
|
||||
expect(screen.getByText('4:00 PM')).toBeVisible();
|
||||
|
||||
// Position should not be visible since it's undefined
|
||||
expect(screen.queryByText('Position')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@ const renderAttribute: ListRenderItem<CustomAttribute> = ({item}) => (
|
|||
title={item.name}
|
||||
description={item.value}
|
||||
testID={`custom_attribute.${item.id}`}
|
||||
type={item.type}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -29,29 +30,35 @@ const CustomAttributes = ({nickname, position, localTime, customAttributes}: Pro
|
|||
const {formatMessage} = useIntl();
|
||||
|
||||
// Combine standard and custom attributes
|
||||
const mergeAttributes = [
|
||||
(nickname ? {
|
||||
const mergeAttributes: CustomAttribute[] = [];
|
||||
if (nickname) {
|
||||
mergeAttributes.push({
|
||||
id: 'nickname',
|
||||
name: formatMessage({id: 'channel_info.nickname', defaultMessage: 'Nickname'}),
|
||||
type: 'text',
|
||||
value: nickname,
|
||||
} : {}),
|
||||
(position ? {
|
||||
});
|
||||
}
|
||||
if (position) {
|
||||
mergeAttributes.push({
|
||||
id: 'position',
|
||||
name: formatMessage({id: 'channel_info.position', defaultMessage: 'Position'}),
|
||||
type: 'text',
|
||||
value: position,
|
||||
} : {}),
|
||||
(localTime ? {
|
||||
});
|
||||
}
|
||||
if (localTime) {
|
||||
mergeAttributes.push({
|
||||
id: 'local_time',
|
||||
name: formatMessage({id: 'channel_info.local_time', defaultMessage: 'Local Time'}),
|
||||
type: 'text',
|
||||
value: localTime,
|
||||
} : {}),
|
||||
|
||||
...(customAttributes ?? []),
|
||||
];
|
||||
});
|
||||
}
|
||||
mergeAttributes.push(...(customAttributes ?? []));
|
||||
|
||||
// remove any empty objects
|
||||
const attributes = mergeAttributes.filter((v) => Object.entries(v).length !== 0);
|
||||
|
||||
const attributes: CustomAttribute[] = mergeAttributes.filter((v: CustomAttribute) => Object.entries(v).length !== 0);
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import {of as of$, combineLatest} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
import {map, mergeMap, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {General, Permissions, Preferences} from '@constants';
|
||||
import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference';
|
||||
|
|
@ -57,13 +57,13 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro
|
|||
|
||||
// Convert attributes to the format expected by the component
|
||||
const formattedCustomAttributes = combineLatest([rawCustomAttributes, customFields, enableCustomAttributes]).pipe(
|
||||
switchMap(([attributes, fields, enabled]) => {
|
||||
mergeMap(([attributes, fields, enabled]) => {
|
||||
if (!enabled || !attributes?.length) {
|
||||
return of$([] as CustomAttribute[]);
|
||||
}
|
||||
return of$(convertProfileAttributesToCustomAttributes(attributes, fields, sortCustomProfileAttributes));
|
||||
return of$(convertProfileAttributesToCustomAttributes(attributes, fields, sortCustomProfileAttributes, true));
|
||||
}),
|
||||
switchMap((converted) => of$(convertToAttributesMap(converted))),
|
||||
mergeMap((converted) => of$(convertToAttributesMap(converted))),
|
||||
);
|
||||
|
||||
// can remove member
|
||||
|
|
|
|||
401
app/screens/user_profile/label.test.tsx
Normal file
401
app/screens/user_profile/label.test.tsx
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
import * as urlUtils from '@utils/url';
|
||||
|
||||
import UserProfileLabel from './label';
|
||||
|
||||
// Mock the URL utilities
|
||||
jest.mock('@utils/url', () => ({
|
||||
getScheme: jest.fn(),
|
||||
tryOpenURL: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetScheme = jest.mocked(urlUtils.getScheme);
|
||||
const mockTryOpenURL = jest.mocked(urlUtils.tryOpenURL);
|
||||
|
||||
describe('UserProfileLabel', () => {
|
||||
const baseProps = {
|
||||
title: 'Test Title',
|
||||
description: 'Test Description',
|
||||
testID: 'test-label',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('text type (default)', () => {
|
||||
it('should render text type correctly', () => {
|
||||
const {getByTestId, getByText} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Title')).toBeTruthy();
|
||||
expect(getByText('Test Description')).toBeTruthy();
|
||||
expect(getByTestId('test-label.title')).toBeTruthy();
|
||||
expect(getByTestId('test-label.text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render text type when explicitly set', () => {
|
||||
const {getByTestId, getByText} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
type='text'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Title')).toBeTruthy();
|
||||
expect(getByText('Test Description')).toBeTruthy();
|
||||
expect(getByTestId('test-label.title')).toBeTruthy();
|
||||
expect(getByTestId('test-label.text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should limit title to one line', () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel {...baseProps}/>,
|
||||
);
|
||||
|
||||
const titleElement = getByTestId('test-label.title');
|
||||
expect(titleElement.props.numberOfLines).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('url type', () => {
|
||||
it('should render url type as clickable link', () => {
|
||||
const {getByTestId, getByText} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='example.com'
|
||||
type='url'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Title')).toBeTruthy();
|
||||
expect(getByText('example.com')).toBeTruthy();
|
||||
expect(getByTestId('test-label.title')).toBeTruthy();
|
||||
expect(getByTestId('test-label.url')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should add https:// prefix when no scheme is present', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='example.com'
|
||||
type='url'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = getByTestId('test-label.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
|
||||
it('should not add prefix when scheme is already present', () => {
|
||||
mockGetScheme.mockReturnValue('https');
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='https://example.com'
|
||||
type='url'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = getByTestId('test-label.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
|
||||
it('should handle http scheme correctly', () => {
|
||||
mockGetScheme.mockReturnValue('http');
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='http://example.com'
|
||||
type='url'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = getByTestId('test-label.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('http://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone type', () => {
|
||||
it('should render phone type as clickable link', () => {
|
||||
const {getByTestId, getByText} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='+1234567890'
|
||||
type='phone'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Title')).toBeTruthy();
|
||||
expect(getByText('+1234567890')).toBeTruthy();
|
||||
expect(getByTestId('test-label.title')).toBeTruthy();
|
||||
expect(getByTestId('test-label.phone')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should add tel: prefix when no scheme is present', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='+1234567890'
|
||||
type='phone'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = getByTestId('test-label.phone');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:+1234567890');
|
||||
});
|
||||
|
||||
it('should not add prefix when tel scheme is already present', () => {
|
||||
mockGetScheme.mockReturnValue('tel');
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='tel:+1234567890'
|
||||
type='phone'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = getByTestId('test-label.phone');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:+1234567890');
|
||||
});
|
||||
|
||||
it('should handle phone numbers without plus sign', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='1234567890'
|
||||
type='phone'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = getByTestId('test-label.phone');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:1234567890');
|
||||
});
|
||||
});
|
||||
|
||||
describe('select type', () => {
|
||||
it('should render select type as non-clickable text', () => {
|
||||
const {getByTestId, getByText, queryByRole} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='Option 1'
|
||||
type='select'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Title')).toBeTruthy();
|
||||
expect(getByText('Option 1')).toBeTruthy();
|
||||
expect(getByTestId('test-label.title')).toBeTruthy();
|
||||
expect(getByTestId('test-label.select')).toBeTruthy();
|
||||
|
||||
// Should not be a touchable element
|
||||
expect(queryByRole('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiselect type', () => {
|
||||
it('should render multiselect type as non-clickable text', () => {
|
||||
const {getByTestId, getByText, queryByRole} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description='Option 1, Option 2'
|
||||
type='multiselect'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Title')).toBeTruthy();
|
||||
expect(getByText('Option 1, Option 2')).toBeTruthy();
|
||||
expect(getByTestId('test-label.title')).toBeTruthy();
|
||||
expect(getByTestId('test-label.select')).toBeTruthy();
|
||||
|
||||
// Should not be a touchable element
|
||||
expect(queryByRole('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('testID prop', () => {
|
||||
it('should use provided testID for all elements', () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
testID='custom-test-id'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId('custom-test-id.title')).toBeTruthy();
|
||||
expect(getByTestId('custom-test-id.text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should work with url type testID', () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
testID='custom-test-id'
|
||||
type='url'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId('custom-test-id.title')).toBeTruthy();
|
||||
expect(getByTestId('custom-test-id.url')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should work with phone type testID', () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
testID='custom-test-id'
|
||||
type='phone'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId('custom-test-id.title')).toBeTruthy();
|
||||
expect(getByTestId('custom-test-id.phone')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should work with select type testID', () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
testID='custom-test-id'
|
||||
type='select'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId('custom-test-id.title')).toBeTruthy();
|
||||
expect(getByTestId('custom-test-id.select')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty description', () => {
|
||||
const {getByText, getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description=''
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Title')).toBeTruthy();
|
||||
expect(getByTestId('test-label.text')).toBeTruthy();
|
||||
expect(getByTestId('test-label.text').props.children).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty title', () => {
|
||||
const {getByText, getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
title=''
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Description')).toBeTruthy();
|
||||
expect(getByTestId('test-label.title')).toBeTruthy();
|
||||
expect(getByTestId('test-label.title').props.children).toBe('');
|
||||
});
|
||||
|
||||
it('should handle undefined testID gracefully', () => {
|
||||
const {getByText} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
title='Test Title'
|
||||
description='Test Description'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Title')).toBeTruthy();
|
||||
expect(getByText('Test Description')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle special characters in URLs', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
const specialUrl = 'example.com/path?param=value&other=test';
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description={specialUrl}
|
||||
type='url'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = getByTestId('test-label.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith(`https://${specialUrl}`);
|
||||
});
|
||||
|
||||
it('should handle special characters in phone numbers', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
const phoneNumber = '+1 (234) 567-8900';
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
description={phoneNumber}
|
||||
type='phone'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = getByTestId('test-label.phone');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith(`tel:${phoneNumber}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have proper accessibility for text type', () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel {...baseProps}/>,
|
||||
);
|
||||
|
||||
const titleElement = getByTestId('test-label.title');
|
||||
const textElement = getByTestId('test-label.text');
|
||||
|
||||
expect(titleElement).toBeTruthy();
|
||||
expect(textElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have proper accessibility for link types', () => {
|
||||
const {getByTestId} = renderWithIntlAndTheme(
|
||||
<UserProfileLabel
|
||||
{...baseProps}
|
||||
type='url'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = getByTestId('test-label.url');
|
||||
expect(linkElement).toBeTruthy();
|
||||
|
||||
// TouchableOpacity should be accessible by default
|
||||
expect(linkElement.type).toBe('Text');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,10 +8,13 @@ import {useTheme} from '@context/theme';
|
|||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import UserProfileLink from './label_link';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
testID?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
|
|
@ -35,9 +38,43 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const UserProfileLabel = ({title, description, testID}: Props) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const UserProfileLabel = ({title, description, testID, type = 'text'}: Props) => {
|
||||
const styles = getStyleSheet(useTheme());
|
||||
let descriptionComponent;
|
||||
switch (type) {
|
||||
case 'url':
|
||||
case 'phone':
|
||||
descriptionComponent = (
|
||||
<UserProfileLink
|
||||
description={description}
|
||||
linkType={type}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'select':
|
||||
case 'multiselect':
|
||||
descriptionComponent = (
|
||||
<Text
|
||||
style={styles.description}
|
||||
testID={`${testID}.select`}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
case 'text':
|
||||
default:
|
||||
descriptionComponent = (
|
||||
<Text
|
||||
style={styles.description}
|
||||
testID={`${testID}.text`}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
|
@ -48,12 +85,7 @@ const UserProfileLabel = ({title, description, testID}: Props) => {
|
|||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.description}
|
||||
testID={`${testID}.description`}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
{descriptionComponent}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
319
app/screens/user_profile/label_link.test.tsx
Normal file
319
app/screens/user_profile/label_link.test.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent, screen} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
|
||||
import UserProfileLink from './label_link';
|
||||
|
||||
// Mock the URL utilities
|
||||
jest.mock('@utils/url', () => ({
|
||||
getScheme: jest.fn(),
|
||||
tryOpenURL: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetScheme = require('@utils/url').getScheme as jest.Mock;
|
||||
const mockTryOpenURL = require('@utils/url').tryOpenURL as jest.Mock;
|
||||
|
||||
describe('UserProfileLink', () => {
|
||||
const baseProps = {
|
||||
description: 'example.com',
|
||||
linkType: 'url',
|
||||
testID: 'test-link',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('url type', () => {
|
||||
it('should render URL link correctly', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('example.com')).toBeTruthy();
|
||||
expect(screen.getByTestId('test-link.url')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should add https:// prefix when no scheme is present', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink {...baseProps}/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
|
||||
it('should not add prefix when scheme is already present', () => {
|
||||
mockGetScheme.mockReturnValue('https');
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...baseProps}
|
||||
description='https://example.com'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
|
||||
it('should handle http scheme correctly', () => {
|
||||
mockGetScheme.mockReturnValue('http');
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...baseProps}
|
||||
description='http://example.com'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('http://example.com');
|
||||
});
|
||||
|
||||
it('should handle complex URLs with parameters', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
const complexUrl = 'example.com/path?param=value&other=test';
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...baseProps}
|
||||
description={complexUrl}
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith(`https://${complexUrl}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone type', () => {
|
||||
const phoneProps = {
|
||||
description: '+1234567890',
|
||||
linkType: 'phone',
|
||||
testID: 'test-phone',
|
||||
};
|
||||
|
||||
it('should render phone link correctly', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink {...phoneProps}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('+1234567890')).toBeTruthy();
|
||||
expect(screen.getByTestId('test-phone.phone')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should add tel: prefix when no scheme is present', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink {...phoneProps}/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-phone.phone');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:+1234567890');
|
||||
});
|
||||
|
||||
it('should not add prefix when tel scheme is already present', () => {
|
||||
mockGetScheme.mockReturnValue('tel');
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...phoneProps}
|
||||
description='tel:+1234567890'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-phone.phone');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:+1234567890');
|
||||
});
|
||||
|
||||
it('should handle phone numbers without plus sign', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...phoneProps}
|
||||
description='1234567890'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-phone.phone');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('tel:1234567890');
|
||||
});
|
||||
|
||||
it('should handle formatted phone numbers', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
const formattedPhone = '+1 (234) 567-8900';
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...phoneProps}
|
||||
description={formattedPhone}
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-phone.phone');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith(`tel:${formattedPhone}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testID handling', () => {
|
||||
it('should generate correct testID for URL type', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
description='example.com'
|
||||
linkType='url'
|
||||
testID='custom-test'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-test.url')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should generate correct testID for phone type', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
description='+1234567890'
|
||||
linkType='phone'
|
||||
testID='custom-test'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-test.phone')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle undefined testID gracefully', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
description='example.com'
|
||||
linkType='url'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('example.com')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty description', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...baseProps}
|
||||
description=''
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
expect(linkElement.props.children).toBe('');
|
||||
});
|
||||
|
||||
it('should handle whitespace in description', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...baseProps}
|
||||
description=' example.com '
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith('https:// example.com ');
|
||||
});
|
||||
|
||||
it('should handle special characters in URLs', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
const specialUrl = 'example.com/path#section';
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...baseProps}
|
||||
description={specialUrl}
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledWith(`https://${specialUrl}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should be accessible as a touchable element', () => {
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink {...baseProps}/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
expect(linkElement).toBeTruthy();
|
||||
|
||||
// The text element should be rendered and accessible
|
||||
expect(linkElement.type).toBe('Text');
|
||||
});
|
||||
|
||||
it('should have proper text content for screen readers', () => {
|
||||
const {getByText} = renderWithIntlAndTheme(
|
||||
<UserProfileLink {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(getByText('example.com')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with URL utils', () => {
|
||||
it('should call getScheme with the correct description', () => {
|
||||
mockGetScheme.mockReturnValue(null);
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink {...baseProps}/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockGetScheme).toHaveBeenCalledWith('example.com');
|
||||
});
|
||||
|
||||
it('should call tryOpenURL when pressed', () => {
|
||||
mockGetScheme.mockReturnValue('https');
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<UserProfileLink
|
||||
{...baseProps}
|
||||
description='https://example.com'
|
||||
/>,
|
||||
);
|
||||
|
||||
const linkElement = screen.getByTestId('test-link.url');
|
||||
fireEvent.press(linkElement);
|
||||
|
||||
expect(mockTryOpenURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
app/screens/user_profile/label_link.tsx
Normal file
56
app/screens/user_profile/label_link.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {Text, TouchableOpacity} from 'react-native';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
import {getScheme, tryOpenURL} from '@utils/url';
|
||||
|
||||
type Props = {
|
||||
description: string;
|
||||
linkType: string;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
link: {
|
||||
color: theme.linkColor,
|
||||
overflow: 'hidden',
|
||||
flex: 2,
|
||||
...typography('Body', 200),
|
||||
},
|
||||
button: {
|
||||
flex: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
const UserProfileLink = ({description, linkType, testID}: Props) => {
|
||||
const styles = getStyleSheet(useTheme());
|
||||
|
||||
// don't try to be smart, if there is already a scheme, don't add one, otherwise add the most likely one
|
||||
let url = description;
|
||||
if (!getScheme(description)) {
|
||||
url = linkType === 'url' ? `https://${description}` : `tel:${description}`;
|
||||
}
|
||||
const openURL = useCallback(() => tryOpenURL(url), [url]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={openURL}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text
|
||||
style={styles.link}
|
||||
numberOfLines={1}
|
||||
testID={`${testID}.${linkType}`}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfileLink;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import {General, Permissions, Preferences} from '@constants';
|
|||
import {Ringtone} from '@constants/calls';
|
||||
import {CustomStatusDurationEnum} from '@constants/custom_status';
|
||||
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
|
||||
import {toTitleCase} from '@utils/helpers';
|
||||
import {safeParseJSON, toTitleCase} from '@utils/helpers';
|
||||
import {logError} from '@utils/log';
|
||||
|
||||
import type {CustomProfileFieldModel, CustomProfileAttributeModel} from '@database/models/server';
|
||||
import type {CustomAttribute, CustomAttributeSet} from '@typings/api/custom_profile_attributes';
|
||||
|
|
@ -425,6 +426,84 @@ export const convertToAttributesMap = (attributesToConvert: CustomAttributeSet |
|
|||
return attributesMap;
|
||||
};
|
||||
|
||||
export const getDisplayType = (field: CustomProfileFieldModel): string => {
|
||||
if (field.type === 'text' && field.attrs?.value_type !== undefined && field.attrs.value_type !== '') {
|
||||
return field.attrs.value_type;
|
||||
}
|
||||
return field.type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the options from a select or multiselect field as a map of option IDs to option names
|
||||
* @param field - The custom profile field
|
||||
* @returns A record where keys are option IDs and values are option names
|
||||
*/
|
||||
export const getFieldOptions = (field: CustomProfileFieldModel): Record<string, string> => {
|
||||
if (!field.attrs || (field.type !== 'select' && field.type !== 'multiselect')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const options = field.attrs.options as Array<{id: string; name: string}> | undefined;
|
||||
if (!options || !Array.isArray(options)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const optionsMap: Record<string, string> = {};
|
||||
options.forEach((option) => {
|
||||
if (option.id && option.name) {
|
||||
optionsMap[option.id] = option.name;
|
||||
}
|
||||
});
|
||||
|
||||
return optionsMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert option IDs to display values for select/multiselect fields
|
||||
* @param value - The stored value (option ID or comma-separated IDs)
|
||||
* @param fieldType - The field type ('select' or 'multiselect')
|
||||
* @param optionsMap - Map of option IDs to option names
|
||||
* @returns The display value with option names
|
||||
*/
|
||||
const convertOptionsToDisplayValue = (value: string, fieldType: string, optionsMap: Record<string, string>): string => {
|
||||
if (!value || !optionsMap) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (fieldType === 'select') {
|
||||
// Single select: just return the option name for the ID
|
||||
return optionsMap[value] || value;
|
||||
}
|
||||
|
||||
if (fieldType === 'multiselect') {
|
||||
// Multi-select: handle comma-separated IDs or JSON array
|
||||
let optionIds: string[] = [];
|
||||
|
||||
// Try parsing as JSON array first
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
optionIds = parsed;
|
||||
} else {
|
||||
// Fallback to comma-separated string
|
||||
optionIds = value.split(',').map((id) => id.trim());
|
||||
}
|
||||
} catch {
|
||||
// Fallback to comma-separated string
|
||||
optionIds = value.split(',').map((id) => id.trim());
|
||||
}
|
||||
|
||||
// Convert IDs to names and filter out empty values
|
||||
const optionNames = optionIds.
|
||||
map((id) => optionsMap[id] || id).
|
||||
filter((name) => name.trim() !== '');
|
||||
|
||||
return optionNames.join(', ');
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert custom profile attributes to the UI-ready CustomAttribute format
|
||||
* @param attributes - Array of custom profile attribute models
|
||||
|
|
@ -436,26 +515,194 @@ export const convertProfileAttributesToCustomAttributes = (
|
|||
attributes: CustomProfileAttributeModel[] | null | undefined,
|
||||
fields: CustomProfileFieldModel[] | null | undefined,
|
||||
sortFn?: (a: CustomAttribute, b: CustomAttribute) => number,
|
||||
useDisplayType: boolean = false,
|
||||
): CustomAttribute[] => {
|
||||
if (!attributes?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fieldsMap = new Map();
|
||||
const fieldOptionsMap = new Map<string, Record<string, string>>();
|
||||
fields?.forEach((field) => {
|
||||
const customType = useDisplayType ? getDisplayType(field) : field.type;
|
||||
fieldsMap.set(field.id, {
|
||||
name: field.name,
|
||||
type: customType,
|
||||
sort_order: field.attrs?.sort_order || Number.MAX_SAFE_INTEGER,
|
||||
originalField: field,
|
||||
});
|
||||
|
||||
// Store options map for select/multiselect fields
|
||||
if (field.type === 'select' || field.type === 'multiselect') {
|
||||
fieldOptionsMap.set(field.id, getFieldOptions(field));
|
||||
}
|
||||
});
|
||||
|
||||
const customAttrs = attributes.map((attr) => ({
|
||||
id: attr.fieldId,
|
||||
name: fieldsMap.get(attr.fieldId)?.name || attr.fieldId,
|
||||
value: attr.value,
|
||||
sort_order: fieldsMap.get(attr.fieldId)?.sort_order || Number.MAX_SAFE_INTEGER,
|
||||
}));
|
||||
const customAttrs = attributes.map((attr) => {
|
||||
const field = fieldsMap.get(attr.fieldId);
|
||||
let displayValue = attr.value;
|
||||
|
||||
// Convert option IDs to names for select/multiselect fields
|
||||
if (field && (field.type === 'select' || field.type === 'multiselect')) {
|
||||
const optionsMap = fieldOptionsMap.get(attr.fieldId);
|
||||
|
||||
if (optionsMap && Object.keys(optionsMap).length > 0) {
|
||||
displayValue = convertOptionsToDisplayValue(attr.value, field.type, optionsMap);
|
||||
}
|
||||
}
|
||||
|
||||
return ({
|
||||
id: attr.fieldId,
|
||||
name: field?.name || attr.fieldId,
|
||||
type: field?.type || 'text',
|
||||
value: displayValue,
|
||||
sort_order: field?.sort_order || Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort if a sort function is provided
|
||||
return sortFn ? customAttrs.sort(sortFn) : customAttrs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert display values back to option IDs for select/multiselect fields
|
||||
* @param displayValue - The display value (option names)
|
||||
* @param fieldType - The field type ('select' or 'multiselect')
|
||||
* @param optionsMap - Map of option IDs to option names
|
||||
* @returns The option IDs as string (select) or JSON array string (multiselect)
|
||||
*/
|
||||
export const convertDisplayValueToOptionIds = (displayValue: string, fieldType: string, optionsMap: Record<string, string>): string => {
|
||||
if (!displayValue || !optionsMap) {
|
||||
return displayValue;
|
||||
}
|
||||
|
||||
// Create reverse map (option names to IDs)
|
||||
const reverseOptionsMap: Record<string, string> = {};
|
||||
Object.entries(optionsMap).forEach(([id, name]) => {
|
||||
reverseOptionsMap[name] = id;
|
||||
});
|
||||
|
||||
if (fieldType === 'select') {
|
||||
// Single select: return the option ID for the name
|
||||
return reverseOptionsMap[displayValue] || displayValue;
|
||||
}
|
||||
|
||||
if (fieldType === 'multiselect') {
|
||||
// Multi-select: split display names and convert to IDs
|
||||
const optionNames = displayValue.split(',').map((name) => name.trim()).filter((name) => name !== '');
|
||||
const optionIds = optionNames.map((name) => reverseOptionsMap[name] || name);
|
||||
return JSON.stringify(optionIds);
|
||||
}
|
||||
|
||||
return displayValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get selected option IDs from a custom attribute value
|
||||
* @param value - The stored value (option ID or JSON array string)
|
||||
* @param fieldType - The field type ('select' or 'multiselect')
|
||||
* @returns Array of selected option IDs
|
||||
*/
|
||||
export const getSelectedOptionIds = (value: string, fieldType: string): string[] => {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (fieldType === 'select') {
|
||||
return [value];
|
||||
}
|
||||
|
||||
if (fieldType === 'multiselect') {
|
||||
try {
|
||||
const parsed: unknown = safeParseJSON(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Fallback to comma-separated string
|
||||
return value.split(',').map((id) => id.trim()).filter((id) => id !== '');
|
||||
} catch (error) {
|
||||
return value.split(',').map((id) => id.trim()).filter((id) => id !== '');
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Format field options for use with AutocompleteSelector component
|
||||
* @param field - The custom profile field
|
||||
* @returns Array of DialogOption objects for the selector
|
||||
*/
|
||||
export const formatOptionsForSelector = (field: CustomProfileFieldModel): DialogOption[] => {
|
||||
if (!field.attrs || (field.type !== 'select' && field.type !== 'multiselect')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = field.attrs.options as Array<{id: string; name: string}> | undefined;
|
||||
if (!options || !Array.isArray(options)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return options.map((option) => ({
|
||||
text: option.name,
|
||||
value: option.id,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert selected option IDs to the format expected by the server
|
||||
* @param value - The stored value (option ID or JSON array string)
|
||||
* @param fieldType - The field type ('select' or 'multiselect')
|
||||
* @returns String for select, array for multiselect
|
||||
*/
|
||||
export const convertValueForServer = (value: string, fieldType: string): string|string[] => {
|
||||
if (fieldType !== 'multiselect') {
|
||||
return value;
|
||||
}
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
const parsed = value.split(',').map((id) => id.trim()).filter((id) => id !== '');
|
||||
return parsed;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert server response values to the format expected by the UI
|
||||
* @param value - The value from server (string or array)
|
||||
* @param fieldType - The field type ('select' or 'multiselect')
|
||||
* @returns String representation for UI storage
|
||||
*/
|
||||
export const convertValueFromServer = (value: string | string[], fieldType: string): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (fieldType === 'select') {
|
||||
return Array.isArray(value) ? (value[0] || '') : String(value);
|
||||
}
|
||||
|
||||
if (fieldType === 'multiselect') {
|
||||
if (Array.isArray(value)) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (error) {
|
||||
logError('Error converting value from server to string', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
5
types/api/custom_profile_attributes.d.ts
vendored
5
types/api/custom_profile_attributes.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {AvailableScreens} from './navigation';
|
||||
import type {CustomProfileAttributeModel, CustomProfileFieldModel} from '@database/models/server';
|
||||
import type {CustomProfileFieldModel} from '@database/models/server';
|
||||
import type {FieldProps} from '@screens/edit_profile/components/field';
|
||||
import type {CustomAttributeSet} from '@typings/api/custom_profile_attributes';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
|
@ -28,7 +28,6 @@ export type EditProfileProps = {
|
|||
lockedPosition: boolean;
|
||||
lockedPicture: boolean;
|
||||
enableCustomAttributes: boolean;
|
||||
userCustomAttributes: CustomProfileAttributeModel[];
|
||||
customFields: CustomProfileFieldModel[];
|
||||
customAttributesSet?: CustomAttributeSet;
|
||||
};
|
||||
|
|
@ -43,3 +42,28 @@ export type FieldSequence = Record<string, {
|
|||
|
||||
export type FieldConfig = Pick<FieldProps, 'blurOnSubmit' | 'enablesReturnKeyAutomatically' | 'onFocusNextField' | 'onTextChange' | 'returnKeyType'>
|
||||
|
||||
export type SelectFieldProps = {
|
||||
fieldKey: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: DialogOption[];
|
||||
isDisabled?: boolean;
|
||||
onValueChange: (fieldKey: string, value: string) => void;
|
||||
onFocusNextField: (fieldKey: string) => void;
|
||||
testID: string;
|
||||
isOptional?: boolean;
|
||||
isMultiselect?: boolean;
|
||||
}
|
||||
|
||||
export type CustomFieldRenderProps = {
|
||||
fieldKey: string;
|
||||
field: CustomProfileFieldModel;
|
||||
value: string;
|
||||
isDisabled: boolean;
|
||||
onUpdateField: (fieldKey: string, value: string) => void;
|
||||
onFocusNextField: (fieldKey: string) => void;
|
||||
testID: string;
|
||||
isOptional?: boolean;
|
||||
returnKeyType?: 'next' | 'done';
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue