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>;