From c7a87868cbfbb5623103d71e823756a5e8552d2e Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Tue, 14 Jan 2025 08:35:55 -0700 Subject: [PATCH] MM-62170 - CPA User Settings Profile popup (#29717) * implement custom profile attributes for user settings and profile popover * update PropertyField to use UserPropertyField * updates from initial review * misc fixes for change to IDMappedObjects * fix: unique id * fix: merge conflict, method ordering * a11y fix labelledby --------- Co-authored-by: Mattermost Build Co-authored-by: Caleb Roseland --- .../profile_popover/profile_popover.scss | 20 +++ .../profile_popover/profile_popover.test.tsx | 68 +++++++- .../profile_popover/profile_popover.tsx | 17 +- .../profile_popover_custom_attributes.tsx | 61 +++++++ .../components/user_settings/general/index.ts | 10 +- .../general/user_settings_general.test.tsx | 98 +++++++++++ .../general/user_settings_general.tsx | 164 +++++++++++++++++- .../modal/user_settings_modal.test.tsx | 8 + webapp/channels/src/i18n/en.json | 3 + .../src/action_types/general.ts | 2 + .../src/actions/general.test.ts | 17 ++ .../mattermost-redux/src/actions/general.ts | 20 +++ .../src/actions/users.test.ts | 22 +++ .../mattermost-redux/src/actions/users.ts | 12 ++ .../src/reducers/entities/general.test.ts | 40 +++++ .../src/reducers/entities/general.ts | 16 ++ .../src/selectors/entities/general.test.ts | 29 ++++ .../src/selectors/entities/general.ts | 6 + .../src/store/initial_state.ts | 1 + webapp/channels/src/utils/constants.tsx | 1 + webapp/platform/client/src/client4.ts | 62 +++++++ webapp/platform/types/src/config.ts | 1 + webapp/platform/types/src/general.ts | 3 + webapp/platform/types/src/properties.ts | 34 ++++ 24 files changed, 701 insertions(+), 14 deletions(-) create mode 100644 webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx create mode 100644 webapp/platform/types/src/properties.ts diff --git a/webapp/channels/src/components/profile_popover/profile_popover.scss b/webapp/channels/src/components/profile_popover/profile_popover.scss index ddf5a53580..2e93edd4bb 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.scss +++ b/webapp/channels/src/components/profile_popover/profile_popover.scss @@ -189,6 +189,26 @@ $profilePopoverBorderWidth: 1px; font-size: 16px; } } + + .user-popover__custom_attributes { + display: flex; + flex-direction: column; + padding: 0 16px 12px 16px; + + .user-popover__subtitle { + display: flex; + margin-bottom: 4px; + font-size: 11px; + font-weight: 600; + gap: 4px; + line-height: 16px; + @include mixins.textEllipsis; + } + + .user-popover__subtitle-text { + margin: 0; + } + } .user-popover__time-status-container { display: flex; 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 eb409974d0..6676f7fcd0 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.test.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover.test.tsx @@ -12,7 +12,7 @@ import type {DeepPartial} from '@mattermost/types/utilities'; import {Client4} from 'mattermost-redux/client'; import {General, Permissions} from 'mattermost-redux/constants'; -import {renderWithContext} from 'tests/react_testing_utils'; +import {act, renderWithContext} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; import {getDirectChannelName} from 'utils/utils'; @@ -24,6 +24,7 @@ jest.mock('@mattermost/client', () => ({ ...jest.requireActual('@mattermost/client'), Client4: class MockClient4 extends jest.requireActual('@mattermost/client').Client4 { getCallsChannelState = jest.fn(); + getUserCustomProfileAttributesValues = jest.fn(); }, })); @@ -309,7 +310,9 @@ describe('components/ProfilePopover', () => { initialState.plugins!.plugins = {}; renderWithPluginReducers(, initialState); - expect(screen.queryByLabelText('Start Call')).not.toBeInTheDocument(); + await act(async () => { + expect(screen.queryByLabelText('Start Call')).not.toBeInTheDocument(); + }); }); test('should disable start call button when call is ongoing in the DM', async () => { @@ -326,8 +329,10 @@ describe('components/ProfilePopover', () => { (initialState as any)['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: false}}; renderWithPluginReducers(, initialState); - expect(await screen.queryByLabelText('Start Call')).not.toBeInTheDocument(); - expect(await screen.queryByLabelText('Call with user is ongoing')).not.toBeInTheDocument(); + await act(async () => { + expect(await screen.queryByLabelText('Start Call')).not.toBeInTheDocument(); + expect(await screen.queryByLabelText('Call with user is ongoing')).not.toBeInTheDocument(); + }); }); test('should not show start call button for users when calls test mode is on', async () => { @@ -335,7 +340,9 @@ describe('components/ProfilePopover', () => { (initialState as any)['plugins-com.mattermost.calls'].callsConfig = {DefaultEnabled: false}; renderWithPluginReducers(, initialState); - expect(await screen.queryByLabelText('Start Call')).not.toBeInTheDocument(); + await act(async () => { + expect(await screen.queryByLabelText('Start Call')).not.toBeInTheDocument(); + }); }); test('should show start call button for users when calls test mode is on if calls in channel have been explicitly enabled', async () => { @@ -344,7 +351,9 @@ describe('components/ProfilePopover', () => { (initialState as any)['plugins-com.mattermost.calls'].channels = {dmChannelId: {enabled: true}}; renderWithPluginReducers(, initialState); - expect(await screen.queryByLabelText('Start Call')).toBeInTheDocument(); + await act(async () => { + expect(await screen.queryByLabelText('Start Call')).toBeInTheDocument(); + }); }); test('should show start call button for admin when calls test mode is on', async () => { @@ -362,6 +371,51 @@ describe('components/ProfilePopover', () => { }; renderWithPluginReducers(, initialState); - expect(await screen.findByLabelText('Start Call')).toBeInTheDocument(); + await act(async () => { + expect(await screen.findByLabelText('Start Call')).toBeInTheDocument(); + }); + }); + + test('should display attributes if attribute exists for user', async () => { + const [props, initialState] = getBasePropsAndState(); + (Client4.getUserCustomProfileAttributesValues as jest.Mock).mockImplementation(async () => { + return { + 123: 'Private', + 456: 'Seargent York', + }; + }); + + initialState.entities!.general!.config!.FeatureFlagCustomProfileAttributes = 'true'; + initialState.entities!.general!.customProfileAttributes = { + 123: {id: '123', name: 'Rank', type: 'text'}, + 456: {id: '456', name: 'CO', type: 'text'}, + 789: {id: '789', name: 'Base', type: 'text'}, + }; + + renderWithPluginReducers(, initialState); + await act(async () => { + expect(await screen.findByText('Private')).toBeInTheDocument(); + expect(await screen.findByText('CO')).toBeInTheDocument(); + expect(await screen.findByText('Seargent York')).toBeInTheDocument(); + }); + expect(screen.queryByText('Rank')).toBeInTheDocument(); + expect(screen.queryByText('Base')).not.toBeInTheDocument(); + }); + + test('should not display attributes if user attributes is null', async () => { + const [props, initialState] = getBasePropsAndState(); + + initialState.entities!.general!.config!.FeatureFlagCustomProfileAttributes = 'true'; + initialState.entities!.general!.customProfileAttributes = { + 123: {id: '123', name: 'Rank', type: 'text'}, + 456: {id: '456', name: 'CO', type: 'text'}, + }; + (Client4.getUserCustomProfileAttributesValues as jest.Mock).mockImplementation(async () => ({})); + + renderWithPluginReducers(, initialState); + await act(async () => { + expect(await screen.queryByText('Rank')).not.toBeInTheDocument(); + expect(await screen.queryByText('CO')).not.toBeInTheDocument(); + }); }); }); diff --git a/webapp/channels/src/components/profile_popover/profile_popover.tsx b/webapp/channels/src/components/profile_popover/profile_popover.tsx index 60723bf718..238cbc8363 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover.tsx @@ -5,6 +5,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {getCurrentChannelId, getCurrentUserId} from 'mattermost-redux/selectors/entities/common'; +import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; import {getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users'; @@ -25,6 +26,7 @@ import * as Utils from 'utils/utils'; import type {GlobalState} from 'types/store'; import ProfilePopoverAvatar from './profile_popover_avatar'; +import ProfilePopoverCustomAttributes from './profile_popover_custom_attributes'; import ProfilePopoverCustomStatus from './profile_popover_custom_status'; import ProfilePopoverEmail from './profile_popover_email'; import ProfilePopoverLastActive from './profile_popover_last_active'; @@ -77,6 +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 [loadingDMChannel, setLoadingDMChannel] = useState(); @@ -96,7 +99,7 @@ const ProfilePopover = ({ }, )); }; - }, []); + }, [returnFocus]); const handleCloseModals = useCallback(() => { for (const modal in modals?.modalState) { @@ -107,7 +110,7 @@ const ProfilePopover = ({ dispatch(closeModal(modal)); } } - }, [modals]); + }, [modals, dispatch]); const handleShowDirectChannel = useCallback(async (e: React.MouseEvent) => { e.preventDefault(); @@ -130,7 +133,7 @@ const ProfilePopover = ({ hide?.(); getHistory().push(`${teamUrl}/messages/@${user.username}`); } - }, [user, loadingDMChannel, handleCloseModals, isMobileView, hide, teamUrl]); + }, [user, loadingDMChannel, handleCloseModals, isMobileView, hide, teamUrl, dispatch]); useEffect(() => { if (currentTeamId && userId) { @@ -140,7 +143,7 @@ const ProfilePopover = ({ channelId, )); } - }, []); + }, [channelId, userId, currentTeamId, dispatch]); if (!user) { return null; @@ -188,6 +191,12 @@ const ProfilePopover = ({ fromWebhook={fromWebhook} /> + + {enableCustomProfileAttributes && ( + + )} { + const dispatch = useDispatch(); + const [customAttributeValues, setCustomAttributeValues] = useState>({}); + const customProfileAttributeFields = useSelector((state: GlobalState) => getCustomProfileAttributes(state)); + + useEffect(() => { + const fetchValues = async () => { + const response = await Client4.getUserCustomProfileAttributesValues(userID); + setCustomAttributeValues(response); + }; + dispatch(getCustomProfileAttributeFields()); + fetchValues(); + }, [userID, dispatch]); + const attributeSections = Object.values(customProfileAttributeFields).map((attribute) => { + const value = customAttributeValues[attribute.id]; + if (!value) { + return null; + } + return ( +
+ + {attribute.name} + +

+ {value} +

+
+ ); + }); + return ( + <>{attributeSections} + ); +}; + +export default ProfilePopoverCustomAttributes; diff --git a/webapp/channels/src/components/user_settings/general/index.ts b/webapp/channels/src/components/user_settings/general/index.ts index 459d138cfa..8e1f1e673c 100644 --- a/webapp/channels/src/components/user_settings/general/index.ts +++ b/webapp/channels/src/components/user_settings/general/index.ts @@ -6,13 +6,15 @@ import {bindActionCreators} from 'redux'; import type {Dispatch} from 'redux'; import {clearErrors, logError} from 'mattermost-redux/actions/errors'; +import {getCustomProfileAttributeFields} from 'mattermost-redux/actions/general'; import { updateMe, sendVerificationEmail, setDefaultProfileImage, uploadProfileImage, + saveCustomProfileAttribute, } from 'mattermost-redux/actions/users'; -import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getConfig, getCustomProfileAttributes, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; import {getIsMobileView} from 'selectors/views/browser'; @@ -22,6 +24,7 @@ import UserSettingsGeneralTab from './user_settings_general'; function mapStateToProps(state: GlobalState) { const config = getConfig(state); + const customProfileAttributeFields = getCustomProfileAttributes(state); const requireEmailVerification = config.RequireEmailVerification === 'true'; const maxFileSize = parseInt(config.MaxFileSize!, 10); @@ -34,11 +37,13 @@ function mapStateToProps(state: GlobalState) { const samlPositionAttributeSet = config.SamlPositionAttributeSet === 'true'; const ldapPositionAttributeSet = config.LdapPositionAttributeSet === 'true'; const ldapPictureAttributeSet = config.LdapPictureAttributeSet === 'true'; + const enableCustomProfileAttributes = getFeatureFlagValue(state, 'CustomProfileAttributes') === 'true'; return { isMobileView: getIsMobileView(state), requireEmailVerification, maxFileSize, + customProfileAttributeFields, ldapFirstNameAttributeSet, ldapLastNameAttributeSet, samlFirstNameAttributeSet, @@ -48,6 +53,7 @@ function mapStateToProps(state: GlobalState) { samlPositionAttributeSet, ldapPositionAttributeSet, ldapPictureAttributeSet, + enableCustomProfileAttributes, }; } @@ -60,6 +66,8 @@ function mapDispatchToProps(dispatch: Dispatch) { sendVerificationEmail, setDefaultProfileImage, uploadProfileImage, + saveCustomProfileAttribute, + getCustomProfileAttributeFields, }, dispatch), }; } diff --git a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx index c77396f0eb..109602417e 100644 --- a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx +++ b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx @@ -4,16 +4,27 @@ import React from 'react'; import {Provider} from 'react-redux'; +import type {UserPropertyField} from '@mattermost/types/properties'; import type {UserProfile} from '@mattermost/types/users'; +import {Client4} from 'mattermost-redux/client'; + import configureStore from 'store'; import {shallowWithIntl, mountWithIntl} from 'tests/helpers/intl-test-helper'; +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; import UserSettingsGeneral from './user_settings_general'; import type {UserSettingsGeneralTab} from './user_settings_general'; +jest.mock('@mattermost/client', () => ({ + ...jest.requireActual('@mattermost/client'), + Client4: class MockClient4 extends jest.requireActual('@mattermost/client').Client4 { + getUserCustomProfileAttributesValues = jest.fn(); + }, +})); + describe('components/user_settings/general/UserSettingsGeneral', () => { const user: UserProfile = TestHelper.getUserMock({ id: 'user_id', @@ -36,6 +47,7 @@ describe('components/user_settings/general/UserSettingsGeneral', () => { closeModal: jest.fn(), collapseModal: jest.fn(), isMobileView: false, + customProfileAttributeFields: {}, actions: { logError: jest.fn(), clearErrors: jest.fn(), @@ -43,11 +55,23 @@ describe('components/user_settings/general/UserSettingsGeneral', () => { sendVerificationEmail: jest.fn(), setDefaultProfileImage: jest.fn(), uploadProfileImage: jest.fn(), + saveCustomProfileAttribute: jest.fn(), + getCustomProfileAttributeFields: jest.fn(), }, maxFileSize: 1024, ldapPositionAttributeSet: false, samlPositionAttributeSet: false, ldapPictureAttributeSet: false, + enableCustomProfileAttributes: false, + }; + + const customProfileAttribute: UserPropertyField = { + id: '1', + name: 'Test Attribute', + type: 'text', + create_at: 0, + update_at: 0, + delete_at: 0, }; let store: ReturnType; @@ -169,4 +193,78 @@ describe('components/user_settings/general/UserSettingsGeneral', () => { await (wrapper.instance() as UserSettingsGeneralTab).submitUser(requiredProps.user, false); expect(wrapper.state('serverError')).toBe('This username conflicts with an existing group name.'); }); + + test('should show Custom Attribute Field with no value', async () => { + (Client4.getUserCustomProfileAttributesValues as jest.Mock).mockImplementation(async () => { + return {}; + }); + const props = {...requiredProps, enableCustomProfileAttributes: true, customProfileAttributeFields: {1: customProfileAttribute}}; + props.user = {...user}; + + renderWithContext(); + + expect(await screen.getByRole('button', {name: `${customProfileAttribute.name} Edit`})).toBeInTheDocument(); + expect(await screen.findByText('Click \'Edit\' to add your custom attribute')); + }); + + test('should show Custom Attribute Field with empty value', async () => { + (Client4.getUserCustomProfileAttributesValues as jest.Mock).mockImplementation(async () => { + return { + 1: '', + }; + }); + const props = {...requiredProps, enableCustomProfileAttributes: true, customProfileAttributeFields: {1: customProfileAttribute}}; + props.user = {...user}; + + renderWithContext(); + + expect(await screen.getByRole('button', {name: `${customProfileAttribute.name} Edit`})).toBeInTheDocument(); + expect(await screen.findByText('Click \'Edit\' to add your custom attribute')); + }); + + test('should show Custom Attribute Field with value set', async () => { + (Client4.getUserCustomProfileAttributesValues as jest.Mock).mockImplementation(async () => { + return {1: 'Custom Attribute Value'}; + }); + const props = {...requiredProps, enableCustomProfileAttributes: true, customProfileAttributeFields: {1: customProfileAttribute}}; + props.user = {...user}; + + renderWithContext(); + + expect(await screen.getByRole('button', {name: `${customProfileAttribute.name} Edit`})).toBeInTheDocument(); + expect(await screen.findByText('Custom Attribute Value')); + }); + + test('should show Custom Attribute Field editing with empty value', async () => { + const props = {...requiredProps, enableCustomProfileAttributes: true, customProfileAttributeFields: {1: customProfileAttribute}}; + props.user = {...user}; + props.activeSection = 'customAttribute_1'; + + renderWithContext(); + + expect(await screen.getByRole('textbox', {name: `${customProfileAttribute.name}`})).toBeInTheDocument(); + }); + + test('submitAttribute() should have called saveCustomProfileAttribute', async () => { + const saveCustomProfileAttribute = jest.fn().mockResolvedValue({data: true}); + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + actions: {...requiredProps.actions, saveCustomProfileAttribute}, + customProfileAttributeFields: {1: customProfileAttribute}, + user: {...user}, + activeSection: 'customAttribute_1', + }; + + renderWithContext(); + + expect(await screen.getByRole('textbox', {name: `${customProfileAttribute.name}`})).toBeInTheDocument(); + expect(await screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); + userEvent.clear(screen.getByRole('textbox', {name: `${customProfileAttribute.name}`})); + userEvent.type(screen.getByRole('textbox', {name: `${customProfileAttribute.name}`}), 'Updated Value'); + userEvent.click(screen.getByRole('button', {name: 'Save'})); + + expect(saveCustomProfileAttribute).toHaveBeenCalledTimes(1); + expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', '1', 'Updated Value'); + }); }); diff --git a/webapp/channels/src/components/user_settings/general/user_settings_general.tsx b/webapp/channels/src/components/user_settings/general/user_settings_general.tsx index cb19506fc9..3e7b11dd7f 100644 --- a/webapp/channels/src/components/user_settings/general/user_settings_general.tsx +++ b/webapp/channels/src/components/user_settings/general/user_settings_general.tsx @@ -7,8 +7,11 @@ import React, {PureComponent} from 'react'; import {defineMessage, defineMessages, FormattedDate, FormattedMessage, injectIntl} from 'react-intl'; import type {IntlShape} from 'react-intl'; +import type {UserPropertyField} from '@mattermost/types/properties'; import type {UserProfile} from '@mattermost/types/users'; +import type {IDMappedObjects} from '@mattermost/types/utilities'; +import {Client4} from 'mattermost-redux/client'; import type {ActionResult} from 'mattermost-redux/types/actions'; import {isEmail} from 'mattermost-redux/utils/helpers'; @@ -106,6 +109,7 @@ export type Props = { collapseModal: () => void; isMobileView: boolean; maxFileSize: number; + customProfileAttributeFields: IDMappedObjects; actions: { logError: ({message, type}: {message: any; type: string}, status: boolean) => void; clearErrors: () => void; @@ -113,6 +117,8 @@ export type Props = { sendVerificationEmail: (email: string) => Promise; setDefaultProfileImage: (id: string) => void; uploadProfileImage: (id: string, file: File) => Promise; + saveCustomProfileAttribute: (userID: string, attributeID: string, attributeValue: string) => Promise; + getCustomProfileAttributeFields: () => Promise; }; requireEmailVerification?: boolean; ldapFirstNameAttributeSet?: boolean; @@ -124,6 +130,7 @@ export type Props = { ldapPositionAttributeSet?: boolean; samlPositionAttributeSet?: boolean; ldapPictureAttributeSet?: boolean; + enableCustomProfileAttributes: boolean; } type State = { @@ -144,6 +151,7 @@ type State = { clientError?: string | null; serverError?: string | {server_error_id: string; message: string}; emailError?: string; + customAttributeValues: Record; } export class UserSettingsGeneralTab extends PureComponent { @@ -151,10 +159,21 @@ export class UserSettingsGeneralTab extends PureComponent { constructor(props: Props) { super(props); - this.state = this.setupInitialState(props); } + componentDidMount() { + if (this.props.enableCustomProfileAttributes) { + const fetchValues = async () => { + const response = await Client4.getUserCustomProfileAttributesValues(this.props.user.id); + this.setState({customAttributeValues: response}); + }; + + this.props.actions.getCustomProfileAttributeFields(); + fetchValues(); + } + } + handleEmailResend = (email: string) => { this.setState({resendStatus: 'sending', showSpinner: true}); this.props.actions.sendVerificationEmail(email).then(({data, error: err}) => { @@ -393,6 +412,28 @@ export class UserSettingsGeneralTab extends PureComponent { this.submitUser(user, false); }; + submitAttribute = async (settings: string[]) => { + const attributeID = settings[0]; + const attributeValue = this.state.customAttributeValues?.[attributeID]; + if (attributeValue == null) { + return; + } + + trackEvent('settings', 'user_settings_update', {field: 'customAttributeValues-' + attributeID}); + + this.setState({sectionIsSaving: true}); + + this.props.actions.saveCustomProfileAttribute(this.props.user.id, attributeID, attributeValue). + then(({data, error: err}) => { + if (data) { + this.updateSection(''); + } else if (err) { + const serverError = err; + this.setState({serverError, emailError: '', clientError: '', sectionIsSaving: false}); + } + }); + }; + updateUsername = (e: React.ChangeEvent) => { this.setState({username: e.target.value}); }; @@ -436,6 +477,13 @@ export class UserSettingsGeneralTab extends PureComponent { } }; + updateAttribute = (e: React.ChangeEvent) => { + const attributeValues = Object.assign({}, this.state.customAttributeValues); + const id = e.target.id.substring(e.target.id.indexOf('_') + 1); + attributeValues[id] = e.target.value; + this.setState({customAttributeValues: attributeValues}); + }; + updateSection = (section: string) => { this.setState(Object.assign({}, this.setupInitialState(this.props), {clientError: '', serverError: '', emailError: '', sectionIsSaving: false})); this.submitActive = false; @@ -444,7 +492,10 @@ export class UserSettingsGeneralTab extends PureComponent { setupInitialState(props: Props) { const user = props.user; - + let cav = {}; + if (this.state !== undefined) { + cav = this.state.customAttributeValues; + } return { username: user.username, firstName: user.first_name, @@ -460,6 +511,7 @@ export class UserSettingsGeneralTab extends PureComponent { sectionIsSaving: false, showSpinner: false, serverError: '', + customAttributeValues: cav, }; } @@ -1276,6 +1328,112 @@ export class UserSettingsGeneralTab extends PureComponent { ); }; + createCustomAttributeSection = () => { + if (this.props.customProfileAttributeFields == null) { + return <>; + } + + const attributeSections = Object.values(this.props.customProfileAttributeFields).map((attribute) => { + const attributeValue = this.state.customAttributeValues?.[attribute.id] ?? ''; + const sectionName = 'customAttribute_' + attribute.id; + const active = this.props.activeSection === sectionName; + let max = null; + + if (active) { + const inputs = []; + + let attributeLabel: JSX.Element | string = ( + attribute.name + ); + if (this.props.isMobileView) { + attributeLabel = ''; + } + + inputs.push( +
+ +
+ +
+
, + ); + + const extraInfo = ( + + + + ); + + max = ( + + ); + } + let describe: JSX.Element|string = ''; + if (attributeValue) { + describe = attributeValue; + } else { + describe = ( + + ); + if (this.props.isMobileView) { + describe = ( + + ); + } + } + + return ( +
+ +
+
+ ); + }); + return <>{attributeSections}; + }; + createPictureSection = () => { const user = this.props.user; const {formatMessage} = this.props.intl; @@ -1375,6 +1533,7 @@ export class UserSettingsGeneralTab extends PureComponent { const usernameSection = this.createUsernameSection(); const positionSection = this.createPositionSection(); const emailSection = this.createEmailSection(); + const customProperiesSection = this.createCustomAttributeSection(); const pictureSection = this.createPictureSection(); return ( @@ -1410,6 +1569,7 @@ export class UserSettingsGeneralTab extends PureComponent {
{emailSection}
+ {customProperiesSection} {pictureSection}
diff --git a/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx b/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx index 8b5953be37..b24a1b93f4 100644 --- a/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx +++ b/webapp/channels/src/components/user_settings/modal/user_settings_modal.test.tsx @@ -32,6 +32,14 @@ const baseState: DeepPartial = { }, }, }; + +jest.mock('@mattermost/client', () => ({ + ...jest.requireActual('@mattermost/client'), + Client4: class MockClient4 extends jest.requireActual('@mattermost/client').Client4 { + getUserCustomProfileAttributesValues = jest.fn(); + }, +})); + describe('do first render to avoid other testing issues', () => { // For some reason, the first time we render, the modal does not // completly renders. This makes it so further tests go properly diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index a7852effec..b24f2092d1 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -5627,6 +5627,7 @@ "user.settings.display.theme.title": "Theme", "user.settings.display.timezone": "Timezone", "user.settings.display.title": "Display Settings", + "user.settings.general.attributeExtra": "This will be shown in your profile popover.", "user.settings.general.close": "Close", "user.settings.general.confirmEmail": "Confirm Email", "user.settings.general.currentEmail": "Current Email", @@ -5641,6 +5642,7 @@ "user.settings.general.emailOffice365CantUpdate": "Login occurs through Entra ID. Email cannot be updated. Email address used for notifications is {email}.", "user.settings.general.emailOpenIdCantUpdate": "Login occurs through OpenID Connect. Email cannot be updated. Email address used for notifications is {email}.", "user.settings.general.emailSamlCantUpdate": "Login occurs through SAML. Email cannot be updated. Email address used for notifications is {email}.", + "user.settings.general.emptyAttribute": "Click 'Edit' to add your custom attribute", "user.settings.general.emptyName": "Click 'Edit' to add your full name", "user.settings.general.emptyNickname": "Click 'Edit' to add a nickname", "user.settings.general.emptyPassword": "Please enter your current password.", @@ -5657,6 +5659,7 @@ "user.settings.general.loginLdap": "Login done through AD/LDAP ({email})", "user.settings.general.loginOffice365": "Login done through Entra ID ({email})", "user.settings.general.loginSaml": "Login done through SAML ({email})", + "user.settings.general.mobile.emptyAttribute": "Click to add your custom attribute", "user.settings.general.mobile.emptyName": "Click to add your full name", "user.settings.general.mobile.emptyNickname": "Click to add a nickname", "user.settings.general.mobile.emptyPosition": "Click to add your job title / position", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts index c94d5d6beb..8701df69bf 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts @@ -12,6 +12,8 @@ export default keyMirror({ CLIENT_LICENSE_RECEIVED: null, CLIENT_LICENSE_RESET: null, + CUSTOM_PROFILE_ATTRIBUTES_RECEIVED: null, + LOG_CLIENT_ERROR_REQUEST: null, LOG_CLIENT_ERROR_SUCCESS: null, LOG_CLIENT_ERROR_FAILURE: null, diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/general.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/general.test.ts index 4d3795c5aa..3892a49d77 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/general.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/general.test.ts @@ -74,4 +74,21 @@ describe('Actions.General', () => { const {firstAdminVisitMarketplaceStatus} = store.getState().entities.general; expect(firstAdminVisitMarketplaceStatus).toEqual(true); }); + + it('getCustomProfileAttributes', async () => { + nock(Client4.getCustomProfileAttributeFieldsRoute()). + get(''). + query(true). + reply(200, [{id: '123', name: 'test attribute', dataType: 'text'}]); + + await store.dispatch(Actions.getCustomProfileAttributeFields()); + + const customProfileAttributes = store.getState().entities.general.customProfileAttributes; + + // Check a few basic fields since they may change over time + expect(Object.keys(customProfileAttributes).length).toEqual(1); + expect(customProfileAttributes[123].id).toEqual('123'); + expect(customProfileAttributes[123].name).toEqual('test attribute'); + expect(customProfileAttributes[123].dataType).toEqual('text'); + }); }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts index a4da3c15fe..28dfa24797 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts @@ -43,6 +43,25 @@ export function getLicenseConfig() { }); } +export function getCustomProfileAttributeFields() { + return bindClientFunc({ + clientFunc: Client4.getCustomProfileAttributeFields, + onSuccess: [GeneralTypes.CUSTOM_PROFILE_ATTRIBUTES_RECEIVED], + }); +} + +export function getCustomProfileAttributeValues(userID: string) { + return async () => { + let data; + try { + data = await Client4.getUserCustomProfileAttributesValues(userID); + } catch (error) { + return {error}; + } + return {data}; + }; +} + export function logClientError(message: string, level = LogLevel.Error) { return bindClientFunc({ clientFunc: Client4.logClientError, @@ -103,6 +122,7 @@ export function getFirstAdminSetupComplete(): ActionFuncAsync { export default { getClientConfig, getLicenseConfig, + getCustomProfileAttributeFields, logClientError, setServerVersion, setUrl, diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/users.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/users.test.ts index f191002bb4..2dcf7c6afe 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/users.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/users.test.ts @@ -1693,6 +1693,28 @@ describe('Actions.Users', () => { expect(Object.values(myUserAccessTokens).length === 0).toBeTruthy(); }); + it('saveCustomProfileAttribute', async () => { + TestHelper.mockLogin(); + store.dispatch({ + type: UserTypes.LOGIN_SUCCESS, + }); + await store.dispatch(Actions.loadMe()); + + const state = store.getState(); + const currentUser = state.entities.users.profiles[state.entities.users.currentUserId]; + + nock(Client4.getCustomProfileAttributeValuesRoute()). + patch(''). + query(true). + reply(200, { + 123: 'NewValue', + }); + + const response = await store.dispatch(Actions.saveCustomProfileAttribute(currentUser.id, '123', 'NewValue')); + const data = response.data!; + expect(data).toBeTruthy(); + }); + describe('checkForModifiedUsers', () => { test('should request users by IDs that have changed since the last websocket disconnect', async () => { const lastDisconnectAt = 1500; diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts index 4a38c6e3c3..f3d043b1ab 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts @@ -970,6 +970,18 @@ export function updateMe(user: Partial): ActionFuncAsync { + try { + await Client4.updateCustomProfileAttributeValues(attributeID, attributeValue); + } catch (error) { + dispatch(logError(error)); + return {error}; + } + return {data: true}; + }; +} + export function patchUser(user: UserProfile): ActionFuncAsync { return async (dispatch) => { let data: UserProfile; diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.test.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.test.ts index 5e8b1efb81..a38ea510b3 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.test.ts @@ -41,4 +41,44 @@ describe('reducers.entities.general', () => { expect(actualState.firstAdminVisitMarketplaceStatus).toEqual(expectedState); }); }); + + describe('customProfileAttributes', () => { + it('initial state', () => { + const state = {}; + const action = {type: undefined}; + const expectedState = {}; + + const actualState = reducer({firstAdminVisitMarketplaceStatus: state} as ReducerState, action); + expect(actualState.firstAdminVisitMarketplaceStatus).toEqual(expectedState); + }); + + it('CUSTOM_PROFILE_ATTRIBUTES_RECEIVED, empty initial state', () => { + const state = {}; + const testAttributeOne = {id: '123', name: 'test attribute', type: 'text'}; + const action = { + type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTES_RECEIVED, + data: [testAttributeOne], + }; + const expectedState = {[testAttributeOne.id]: testAttributeOne} as ReducerState['customProfileAttributes']; + const actualState = reducer({customProfileAttributes: state} as ReducerState, action); + expect(actualState.customProfileAttributes).toEqual(expectedState); + }); + + it('CUSTOM_PROFILE_ATTRIBUTES_RECEIVED, attributes are completely replaced', () => { + const testAttributeOne = {id: '123', name: 'test attribute', type: 'text'}; + const testAttributeTwo = {id: '456', name: 'test attribute two', type: 'text'}; + const state = {[testAttributeOne.id]: testAttributeOne, [testAttributeTwo.id]: testAttributeTwo}; + + const updatedAttributeOne = {id: '123', name: 'new name value', type: 'text'}; + + const action = { + type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTES_RECEIVED, + data: [updatedAttributeOne], + }; + const expectedState = {[updatedAttributeOne.id]: updatedAttributeOne}; + + const actualState = reducer({customProfileAttributes: state} as ReducerState, action); + expect(actualState.customProfileAttributes).toEqual(expectedState); + }); + }); }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts index bc40dd216e..f6d7e6896b 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts @@ -4,6 +4,8 @@ import {combineReducers} from 'redux'; import type {ClientLicense, ClientConfig} from '@mattermost/types/config'; +import type {UserPropertyField} from '@mattermost/types/properties'; +import type {IDMappedObjects} from '@mattermost/types/utilities'; import type {MMReduxAction} from 'mattermost-redux/action_types'; import {GeneralTypes, UserTypes} from 'mattermost-redux/action_types'; @@ -37,6 +39,19 @@ function license(state: ClientLicense = {}, action: MMReduxAction) { } } +function customProfileAttributes(state: IDMappedObjects = {}, action: MMReduxAction) { + const data: UserPropertyField[] = action.data; + switch (action.type) { + case GeneralTypes.CUSTOM_PROFILE_ATTRIBUTES_RECEIVED: + return data.reduce>((acc, field) => { + acc[field.id] = field; + return acc; + }, {}); + default: + return state; + } +} + function serverVersion(state = '', action: MMReduxAction) { switch (action.type) { case GeneralTypes.RECEIVED_SERVER_VERSION: @@ -71,6 +86,7 @@ function firstAdminCompleteSetup(state = false, action: MMReduxAction) { export default combineReducers({ config, license, + customProfileAttributes, serverVersion, firstAdminVisitMarketplaceStatus, firstAdminCompleteSetup, diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.test.ts index d852bbdf4e..a97745a052 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.test.ts @@ -400,5 +400,34 @@ describe('Selectors.General', () => { expect(Selectors.getFirstAdminVisitMarketplaceStatus(state)).toEqual(false); }); }); + + describe('getCustomProfileAttributes', () => { + test('should return empty when no attributes', () => { + const state = { + entities: { + general: { + customProfileAttributes: { + }, + }, + }, + } as unknown as GlobalState; + + expect(Selectors.getCustomProfileAttributes(state)).toEqual({}); + }); + + test('should return the value of the attributes', () => { + const state = { + entities: { + general: { + customProfileAttributes: [{id: '123', name: 'test attribute', dataType: 'text'}], + }, + }, + } as unknown as GlobalState; + + expect(Selectors.getCustomProfileAttributes(state)[0].id).toEqual('123'); + state.entities.general.customProfileAttributes = {}; + expect(Selectors.getCustomProfileAttributes(state)).toEqual({}); + }); + }); }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts index 722f3503fd..b0ac14593c 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts @@ -4,7 +4,9 @@ import {GiphyFetch} from '@giphy/js-fetch-api'; import type {ClientConfig, FeatureFlags, ClientLicense} from '@mattermost/types/config'; +import type {UserPropertyField} from '@mattermost/types/properties'; import type {GlobalState} from '@mattermost/types/store'; +import type {IDMappedObjects} from '@mattermost/types/utilities'; import {General} from 'mattermost-redux/constants'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; @@ -165,3 +167,7 @@ export function developerModeEnabled(state: GlobalState): boolean { export function testingEnabled(state: GlobalState): boolean { return state.entities.general.config.EnableTesting === 'true'; } + +export function getCustomProfileAttributes(state: GlobalState): IDMappedObjects { + return state.entities.general.customProfileAttributes; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index e6e63a2d96..42563cc7e5 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -13,6 +13,7 @@ const state: GlobalState = { serverVersion: '', firstAdminVisitMarketplaceStatus: false, firstAdminCompleteSetup: false, + customProfileAttributes: {}, }, users: { currentUserId: '', diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index b80feaf44b..63af904fd8 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -2020,6 +2020,7 @@ export const Constants = { MAX_CHANNELNAME_LENGTH: 64, DEFAULT_CHANNELURL_SHORTEN_LENGTH: 52, MAX_CHANNELPURPOSE_LENGTH: 250, + MAX_CUSTOM_ATTRIBUTE_LENGTH: 64, MAX_FIRSTNAME_LENGTH: 64, MAX_LASTNAME_LENGTH: 64, MAX_EMAIL_LENGTH: 128, diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index d934199b5c..1d28ae8245 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -112,6 +112,7 @@ import type { import type {Post, PostList, PostSearchResults, PostsUsageResponse, TeamsUsageResponse, PaginatedPostList, FilesUsageResponse, PostAcknowledgement, PostAnalytics, PostInfo} from '@mattermost/types/posts'; import type {PreferenceType} from '@mattermost/types/preferences'; import type {ProductNotices} from '@mattermost/types/product_notices'; +import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties'; import type {Reaction} from '@mattermost/types/reactions'; import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters'; import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports'; @@ -341,6 +342,18 @@ export default class Client4 { return `${this.getRemoteClustersRoute()}/${remoteId}`; } + getCustomProfileAttributeFieldsRoute() { + return `${this.getBaseRoute()}/custom_profile_attributes/fields`; + } + + getCustomProfileAttributeFieldRoute(propertyFieldId: string) { + return `${this.getCustomProfileAttributeFieldsRoute()}/${propertyFieldId}`; + } + + getCustomProfileAttributeValuesRoute() { + return `${this.getBaseRoute()}/custom_profile_attributes/values`; + } + getPostsRoute() { return `${this.getBaseRoute()}/posts`; } @@ -2059,6 +2072,55 @@ export default class Client4 { ); }; + // System Properties Routes + + getCustomProfileAttributeFields = async () => { + return this.doFetch( + `${this.getCustomProfileAttributeFieldsRoute()}`, + {method: 'GET'}, + ); + }; + + createCustomProfileAttributeField = async (patch: UserPropertyFieldPatch) => { + return this.doFetch( + `${this.getCustomProfileAttributeFieldsRoute()}`, + {method: 'POST', body: JSON.stringify(patch)}, + ); + }; + + patchCustomProfileAttributeField = async (fieldId: string, patch: UserPropertyFieldPatch) => { + return this.doFetch( + `${this.getCustomProfileAttributeFieldRoute(fieldId)}`, + {method: 'PATCH', body: JSON.stringify(patch)}, + ); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deleteCustomProfileAttributeField = async (fieldId: string) => { + return this.doFetch( + `${this.getCustomProfileAttributeFieldRoute(fieldId)}`, + {method: 'DELETE'}, + ); + }; + + updateCustomProfileAttributeValues = (attributeID: string, attributeValue: string) => { + const obj: { [key: string]: string } = {}; + obj[attributeID] = attributeValue; + + return this.doFetch>( + `${this.getCustomProfileAttributeValuesRoute()}`, + {method: 'PATCH', body: JSON.stringify(obj)}, + ); + }; + + getUserCustomProfileAttributesValues = async (userID: string) => { + const data = await this.doFetch>( + `${this.getUserRoute(userID)}/custom_profile_attributes`, + {method: 'GET'}, + ); + return data; + }; + // Post Routes createPost = async (post: Post) => { diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index f003997008..37940be8b7 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -123,6 +123,7 @@ export type ClientConfig = { FileLevel: string; FeatureFlagAppsEnabled: string; FeatureFlagCallsEnabled: string; + FeatureFlagCustomProfileAttributes: string; FeatureFlagWebSocketEventScope: string; ForgotPasswordLink: string; GiphySdkKey: string; diff --git a/webapp/platform/types/src/general.ts b/webapp/platform/types/src/general.ts index 459ecb5700..45914a6c6e 100644 --- a/webapp/platform/types/src/general.ts +++ b/webapp/platform/types/src/general.ts @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. import type {ClientConfig, ClientLicense} from './config'; +import type {UserPropertyField} from './properties'; +import type {IDMappedObjects} from './utilities'; export type GeneralState = { config: Partial; @@ -9,6 +11,7 @@ export type GeneralState = { firstAdminCompleteSetup: boolean; license: ClientLicense; serverVersion: string; + customProfileAttributes: IDMappedObjects; }; export type SystemSetting = { diff --git a/webapp/platform/types/src/properties.ts b/webapp/platform/types/src/properties.ts new file mode 100644 index 0000000000..c1b6202fb6 --- /dev/null +++ b/webapp/platform/types/src/properties.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type PropertyField = { + id: string; + group_id?: string; + name: string; + type: string; + attrs?: {[key: string]: unknown}; + target_id?: string; + target_type?: string; + create_at: number; + update_at: number; + delete_at: number; +}; + +export type PropertyValue = { + id: string; + target_id: string; + target_type: string; + group_id: string; + value: T; + create_at: number; + update_at: number; + delete_at: number; +} + +export type UserPropertyFieldType = 'text'; +export type UserPropertyFieldGroupID = 'user_properties'; + +export type UserPropertyField = PropertyField & { + type: UserPropertyFieldType; +} +export type UserPropertyFieldPatch = Partial>;