diff --git a/webapp/channels/src/components/profile_popover/profile_popover.test.tsx b/webapp/channels/src/components/profile_popover/profile_popover.test.tsx index 43a6645939..39db587d00 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.test.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover.test.tsx @@ -402,6 +402,66 @@ describe('components/ProfilePopover', () => { expect(screen.queryByText('Base')).not.toBeInTheDocument(); }); + test('should display select attribute values correctly', async () => { + const [props, initialState] = getBasePropsAndState(); + (Client4.getUserCustomProfileAttributesValues as jest.Mock).mockImplementation(async () => { + return { + 123: 'opt1', + }; + }); + + initialState.entities!.general!.config!.FeatureFlagCustomProfileAttributes = 'true'; + initialState.entities!.general!.customProfileAttributes = { + 123: { + id: '123', + name: 'Department', + type: 'select', + attrs: { + options: [ + {id: 'opt1', name: 'Engineering', color: ''}, + {id: 'opt2', name: 'Sales', color: ''}, + ], + }, + }, + }; + + renderWithPluginReducers(, initialState); + await act(async () => { + expect(await screen.findByText('Engineering')).toBeInTheDocument(); + expect(screen.queryByText('opt1')).not.toBeInTheDocument(); + }); + }); + + test('should display multiselect attribute values correctly', async () => { + const [props, initialState] = getBasePropsAndState(); + (Client4.getUserCustomProfileAttributesValues as jest.Mock).mockImplementation(async () => { + return { + 123: ['opt1', 'opt2'], + }; + }); + + initialState.entities!.general!.config!.FeatureFlagCustomProfileAttributes = 'true'; + initialState.entities!.general!.customProfileAttributes = { + 123: { + id: '123', + name: 'Skills', + type: 'multiselect', + attrs: { + options: [ + {id: 'opt1', name: 'JavaScript', color: ''}, + {id: 'opt2', name: 'Python', color: ''}, + ], + }, + }, + }; + + renderWithPluginReducers(, initialState); + await act(async () => { + expect(await screen.findByText(/JavaScript/)).toBeInTheDocument(); + expect(await screen.findByText(/Python/)).toBeInTheDocument(); + }); + }); + test('should not display attributes if user attributes is null', async () => { const [props, initialState] = getBasePropsAndState(); diff --git a/webapp/channels/src/components/profile_popover/profile_popover.tsx b/webapp/channels/src/components/profile_popover/profile_popover.tsx index a601bd8b08..319476858f 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover.tsx @@ -79,7 +79,7 @@ const ProfilePopover = ({ const status = useSelector((state: GlobalState) => getStatusForUserId(state, userId) || UserStatuses.OFFLINE); const currentUserTimezone = useSelector(getCurrentTimezone); const currentUserId = useSelector(getCurrentUserId); - const enableCustomProfileAttributes = useSelector((state: GlobalState) => getFeatureFlagValue(state, 'CustomProfileAttributes') === 'true'); + const enableCustomProfileAttributes = useSelector((state: GlobalState) => getFeatureFlagValue(state, 'CustomProfileAttributes') === 'true' && !fromWebhook); const [loadingDMChannel, setLoadingDMChannel] = useState(); @@ -192,9 +192,10 @@ const ProfilePopover = ({ /> - {enableCustomProfileAttributes && ( + {enableCustomProfileAttributes && !user.is_bot && ( )} ({ + getCustomProfileAttributeValues: jest.fn().mockReturnValue({type: 'GET_CUSTOM_PROFILE_ATTRIBUTE_VALUES'}), +})); + +describe('components/ProfilePopoverCustomAttributes', () => { + const mockStore = configureStore(); + + const textAttribute: UserPropertyField = { + id: 'text_attribute_id', + name: 'Text Attribute', + type: 'text', + group_id: 'custom_profile_attributes', + create_at: 0, + update_at: 0, + delete_at: 0, + attrs: { + value_type: '' as UserPropertyValueType, + visibility: 'when_set', + sort_order: 0, + }, + }; + + const phoneAttribute: UserPropertyField = { + id: 'phone_attribute_id', + name: 'Phone Number', + type: 'text', + group_id: 'custom_profile_attributes', + create_at: 0, + update_at: 0, + delete_at: 0, + attrs: { + value_type: 'phone' as UserPropertyValueType, + visibility: 'when_set', + sort_order: 1, + }, + }; + + const urlAttribute: UserPropertyField = { + id: 'url_attribute_id', + name: 'Website', + type: 'text', + group_id: 'custom_profile_attributes', + create_at: 0, + update_at: 0, + delete_at: 0, + attrs: { + value_type: 'url' as UserPropertyValueType, + visibility: 'when_set', + sort_order: 2, + }, + }; + + const selectAttribute: UserPropertyField = { + id: 'select_attribute_id', + name: 'Select Attribute', + type: 'select', + group_id: 'custom_profile_attributes', + create_at: 0, + update_at: 0, + delete_at: 0, + attrs: { + options: [ + {id: 'option1', name: 'Option 1', color: '#FF0000'}, + {id: 'option2', name: 'Option 2', color: '#00FF00'}, + ], + visibility: 'when_set', + sort_order: 3, + value_type: '', + }, + }; + + const userProfile = TestHelper.getUserMock({ + id: 'user_id', + custom_profile_attributes: { + text_attribute_id: 'text value', + phone_attribute_id: '+1 (555) 123-4567', + url_attribute_id: 'https://example.com', + select_attribute_id: 'option1', + }, + }); + + const baseState = { + entities: { + general: { + config: {}, + customProfileAttributes: { + text_attribute_id: textAttribute, + phone_attribute_id: phoneAttribute, + url_attribute_id: urlAttribute, + select_attribute_id: selectAttribute, + }, + }, + users: { + profiles: { + user_id: userProfile, + }, + }, + }, + }; + + const baseProps = { + userID: 'user_id', + }; + + test('should render all attribute types', () => { + const store = mockStore(baseState); + + renderWithContext( + + + , + ); + + // Check that all attribute titles are rendered + expect(screen.getByText('Text Attribute')).toBeInTheDocument(); + expect(screen.getByText('Phone Number')).toBeInTheDocument(); + expect(screen.getByText('Website')).toBeInTheDocument(); + expect(screen.getByText('Select Attribute')).toBeInTheDocument(); + + // Check that all attribute values are rendered + expect(screen.getByText('text value')).toBeInTheDocument(); + expect(screen.getByText('+1 (555) 123-4567')).toBeInTheDocument(); + expect(screen.getByText('https://example.com')).toBeInTheDocument(); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + test('should fetch custom profile attributes if not available', () => { + const state = { + ...baseState, + entities: { + ...baseState.entities, + users: { + profiles: { + user_id: TestHelper.getUserMock({ + id: 'user_id', + custom_profile_attributes: undefined, + }), + }, + }, + }, + }; + + const store = mockStore(state); + const dispatchMock = jest.spyOn(store, 'dispatch'); + + renderWithContext( + + + , + ); + + expect(dispatchMock).toHaveBeenCalledWith(expect.objectContaining({ + type: 'GET_CUSTOM_PROFILE_ATTRIBUTE_VALUES', + })); + }); + + test('should respect visibility settings', () => { + const state = { + ...baseState, + entities: { + ...baseState.entities, + general: { + ...baseState.entities.general, + customProfileAttributes: { + ...baseState.entities.general.customProfileAttributes, + text_attribute_id: { + ...textAttribute, + attrs: { + visibility: 'hidden', + }, + }, + }, + }, + }, + }; + + const store = mockStore(state); + + renderWithContext( + + + , + ); + + // The attribute with 'hidden' visibility should not be rendered + expect(screen.queryByText('Text Attribute')).not.toBeInTheDocument(); + + // Other attributes should still be rendered + expect(screen.getByText('Phone Number')).toBeInTheDocument(); + expect(screen.getByText('Website')).toBeInTheDocument(); + expect(screen.getByText('Select Attribute')).toBeInTheDocument(); + }); + + test('should respect when_set visibility with empty values', () => { + const state = { + ...baseState, + entities: { + ...baseState.entities, + users: { + profiles: { + user_id: TestHelper.getUserMock({ + id: 'user_id', + custom_profile_attributes: { + ...userProfile.custom_profile_attributes, + text_attribute_id: '', // Empty value + }, + }), + }, + }, + general: { + ...baseState.entities.general, + customProfileAttributes: { + ...baseState.entities.general.customProfileAttributes, + text_attribute_id: { + ...textAttribute, + attrs: { + visibility: 'when_set', + }, + }, + }, + }, + }, + }; + + const store = mockStore(state); + + renderWithContext( + + + , + ); + + // The attribute with empty value and 'when_set' visibility should not be rendered + expect(screen.queryByText('Text Attribute')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx index c1273d4a4e..e27c38f442 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx @@ -4,17 +4,26 @@ import React, {useEffect} from 'react'; import {useDispatch, useSelector} from 'react-redux'; +import type {UserPropertyValueType} from '@mattermost/types/properties'; + import {getCustomProfileAttributeValues} from 'mattermost-redux/actions/users'; import {getCustomProfileAttributes} from 'mattermost-redux/selectors/entities/general'; import {getUser} from 'mattermost-redux/selectors/entities/users'; import type {GlobalState} from 'types/store'; +import ProfilePopoverPhone from './profile_popover_phone'; +import ProfilePopoverSelectAttribute from './profile_popover_select_attribute'; +import ProfilePopoverTextAttribute from './profile_popover_text_attribute'; +import ProfilePopoverUrl from './profile_popover_url'; + type Props = { userID: string; + hideStatus?: boolean; } const ProfilePopoverCustomAttributes = ({ userID, + hideStatus = false, }: Props) => { const dispatch = useDispatch(); const userProfile = useSelector((state: GlobalState) => getUser(state, userID)); @@ -25,12 +34,22 @@ const ProfilePopoverCustomAttributes = ({ dispatch(getCustomProfileAttributeValues(userID)); } }); + const attributeSections = customProfileAttributeFields.map((attribute) => { - if (userProfile.custom_profile_attributes) { - const value = userProfile.custom_profile_attributes[attribute.id]; - if (!value) { + if (!hideStatus && userProfile.custom_profile_attributes) { + const visibility = attribute.attrs?.visibility || 'when_set'; + if (visibility === 'hidden') { return null; } + + // Check if the attribute has a value + const hasValue = userProfile.custom_profile_attributes[attribute.id]?.length > 0; + + if (!hasValue && visibility === 'when_set') { + return null; + } + + const valueType = (attribute.attrs?.value_type as UserPropertyValueType) || ''; return (
{attribute.name} -

- {value} -

+ {(attribute.type === 'multiselect' || attribute.type === 'select') && ( + + )} + {attribute.type === 'text' && valueType === 'phone' && ( + + )} + {attribute.type === 'text' && valueType === 'url' && ( + + )} + {attribute.type === 'text' && valueType === '' && ( + + )}
); } diff --git a/webapp/channels/src/components/profile_popover/profile_popover_phone.test.tsx b/webapp/channels/src/components/profile_popover/profile_popover_phone.test.tsx new file mode 100644 index 0000000000..758c521185 --- /dev/null +++ b/webapp/channels/src/components/profile_popover/profile_popover_phone.test.tsx @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react'; +import React from 'react'; + +import type {UserPropertyField} from '@mattermost/types/properties'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import ProfilePopoverPhone from './profile_popover_phone'; + +import {TestHelper} from '../../utils/test_helper'; + +describe('components/ProfilePopoverPhone', () => { + const attribute: UserPropertyField = { + id: 'phone_attribute_id', + name: 'Phone Number', + type: 'text', + group_id: 'custom_profile_attributes', + create_at: 0, + update_at: 0, + delete_at: 0, + attrs: { + value_type: 'phone', + visibility: 'when_set', + sort_order: 0, + }, + }; + + const baseProps = { + attribute, + userProfile: TestHelper.getUserMock({ + id: 'user_id', + custom_profile_attributes: { + phone_attribute_id: '+1 (555) 123-4567', + }, + }), + }; + + test('should not render when phone is missing', () => { + const props = { + ...baseProps, + userProfile: TestHelper.getUserMock({ + id: 'user_id', + custom_profile_attributes: {}, + }), + }; + renderWithContext(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + test('should not render when phone is empty', () => { + const props = { + ...baseProps, + userProfile: TestHelper.getUserMock({ + id: 'user_id', + custom_profile_attributes: { + phone_attribute_id: '', + }, + }), + }; + renderWithContext(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + test('should render phone with icon', () => { + renderWithContext(); + + const phone = '+1 (555) 123-4567'; + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', 'tel:+1 (555) 123-4567'); + expect(link).toHaveTextContent(phone); + expect(screen.getByTitle(phone)).toBeInTheDocument(); + expect(screen.getByLabelText('phone icon')).toBeInTheDocument(); + }); + + test('should handle international phone numbers', () => { + const props = { + ...baseProps, + userProfile: TestHelper.getUserMock({ + id: 'user_id', + custom_profile_attributes: { + phone_attribute_id: '+44 20 7123 4567', + }, + }), + }; + renderWithContext(); + + const phone = '+44 20 7123 4567'; + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', 'tel:+44 20 7123 4567'); + expect(link).toHaveTextContent(phone); + }); +}); diff --git a/webapp/channels/src/components/profile_popover/profile_popover_phone.tsx b/webapp/channels/src/components/profile_popover/profile_popover_phone.tsx new file mode 100644 index 0000000000..3a4c868ceb --- /dev/null +++ b/webapp/channels/src/components/profile_popover/profile_popover_phone.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {UserPropertyField} from '@mattermost/types/properties'; +import type {UserProfile} from '@mattermost/types/users'; + +type Props = { + attribute: UserPropertyField; + userProfile: UserProfile; +} + +const ProfilePopoverPhone = ({attribute, userProfile}: Props) => { + const phone = userProfile.custom_profile_attributes?.[attribute.id] as string; + + if (!phone) { + return null; + } + + return ( +