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 <build@mattermost.com>
Co-authored-by: Caleb Roseland <caleb@calebroseland.com>
This commit is contained in:
Scott Bishel
2025-01-14 08:35:55 -07:00
committed by GitHub
parent c01f212f60
commit c7a87868cb
24 changed files with 701 additions and 14 deletions

View File

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

View File

@@ -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(<ProfilePopover {...props}/>, 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(<ProfilePopover {...props}/>, 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(<ProfilePopover {...props}/>, 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(<ProfilePopover {...props}/>, 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(<ProfilePopover {...props}/>, 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(<ProfilePopover {...props}/>, 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(<ProfilePopover {...props}/>, initialState);
await act(async () => {
expect(await screen.queryByText('Rank')).not.toBeInTheDocument();
expect(await screen.queryByText('CO')).not.toBeInTheDocument();
});
});
});

View File

@@ -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<string>();
@@ -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<HTMLButtonElement>) => {
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}
/>
</div>
{enableCustomProfileAttributes && (
<ProfilePopoverCustomAttributes
userID={userId}
/>
)}
<ProfilePopoverTimezone
currentUserTimezone={currentUserTimezone}
profileUserTimezone={user.timezone}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {getCustomProfileAttributeFields} from 'mattermost-redux/actions/general';
import {Client4} from 'mattermost-redux/client';
import {getCustomProfileAttributes} from 'mattermost-redux/selectors/entities/general';
import type {GlobalState} from 'types/store';
type Props = {
userID: string;
}
const ProfilePopoverCustomAttributes = ({
userID,
}: Props) => {
const dispatch = useDispatch();
const [customAttributeValues, setCustomAttributeValues] = useState<Record<string, string>>({});
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 (
<div
key={'customAttribute_' + attribute.id}
className='user-popover__custom_attributes'
>
<strong
id={`user-popover__custom_attributes-title-${attribute.id}`}
className='user-popover__subtitle'
>
{attribute.name}
</strong>
<p
aria-labelledby={`user-popover__custom_attributes-title-${attribute.id}`}
className='user-popover__subtitle-text'
>
{value}
</p>
</div>
);
});
return (
<>{attributeSections}</>
);
};
export default ProfilePopoverCustomAttributes;

View File

@@ -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),
};
}

View File

@@ -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<typeof configureStore>;
@@ -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(<UserSettingsGeneral {...props}/>);
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(<UserSettingsGeneral {...props}/>);
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(<UserSettingsGeneral {...props}/>);
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(<UserSettingsGeneral {...props}/>);
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(<UserSettingsGeneral {...props}/>);
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');
});
});

View File

@@ -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<UserPropertyField>;
actions: {
logError: ({message, type}: {message: any; type: string}, status: boolean) => void;
clearErrors: () => void;
@@ -113,6 +117,8 @@ export type Props = {
sendVerificationEmail: (email: string) => Promise<ActionResult>;
setDefaultProfileImage: (id: string) => void;
uploadProfileImage: (id: string, file: File) => Promise<ActionResult>;
saveCustomProfileAttribute: (userID: string, attributeID: string, attributeValue: string) => Promise<ActionResult>;
getCustomProfileAttributeFields: () => Promise<ActionResult>;
};
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<string, string>;
}
export class UserSettingsGeneralTab extends PureComponent<Props, State> {
@@ -151,10 +159,21 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
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<Props, State> {
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<HTMLInputElement>) => {
this.setState({username: e.target.value});
};
@@ -436,6 +477,13 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
}
};
updateAttribute = (e: React.ChangeEvent<HTMLInputElement>) => {
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<Props, State> {
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<Props, State> {
sectionIsSaving: false,
showSpinner: false,
serverError: '',
customAttributeValues: cav,
};
}
@@ -1276,6 +1328,112 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
);
};
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(
<div
key={sectionName}
className='form-group'
>
<label className='col-sm-5 control-label'>{attributeLabel}</label>
<div className='col-sm-7'>
<input
id={sectionName}
autoFocus={true}
className='form-control'
type='text'
onChange={this.updateAttribute}
value={attributeValue}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
autoCapitalize='off'
onFocus={Utils.moveCursorToEnd}
aria-label={attribute.name}
/>
</div>
</div>,
);
const extraInfo = (
<span>
<FormattedMessage
id='user.settings.general.attributeExtra'
defaultMessage='This will be shown in your profile popover.'
/>
</span>
);
max = (
<SettingItemMax
key={'settingItemMax_' + attribute.id}
title={attribute.name}
inputs={inputs}
submit={this.submitAttribute.bind(this, [attribute.id])}
saving={this.state.sectionIsSaving}
serverError={this.state.serverError}
clientError={this.state.clientError}
updateSection={this.updateSection}
extraInfo={extraInfo}
/>
);
}
let describe: JSX.Element|string = '';
if (attributeValue) {
describe = attributeValue;
} else {
describe = (
<FormattedMessage
id='user.settings.general.emptyAttribute'
defaultMessage="Click 'Edit' to add your custom attribute"
/>
);
if (this.props.isMobileView) {
describe = (
<FormattedMessage
id='user.settings.general.mobile.emptyAttribute'
defaultMessage='Click to add your custom attribute'
/>
);
}
}
return (
<div key={sectionName}>
<SettingItem
key={'settingItem_' + attribute.id}
active={active}
areAllSectionsInactive={this.props.activeSection === ''}
title={attribute.name}
describe={describe}
section={sectionName}
updateSection={this.updateSection}
max={max}
/>
<div className='divider-dark'/>
</div>
);
});
return <>{attributeSections}</>;
};
createPictureSection = () => {
const user = this.props.user;
const {formatMessage} = this.props.intl;
@@ -1375,6 +1533,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
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<Props, State> {
<div className='divider-light'/>
{emailSection}
<div className='divider-light'/>
{customProperiesSection}
{pictureSection}
<div className='divider-dark'/>
</div>

View File

@@ -32,6 +32,14 @@ const baseState: DeepPartial<GlobalState> = {
},
},
};
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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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');
});
});

View File

@@ -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<SystemSetting> {
export default {
getClientConfig,
getLicenseConfig,
getCustomProfileAttributeFields,
logClientError,
setServerVersion,
setUrl,

View File

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

View File

@@ -970,6 +970,18 @@ export function updateMe(user: Partial<UserProfile>): ActionFuncAsync<UserProfil
};
}
export function saveCustomProfileAttribute(userID: string, attributeID: string, attributeValue: string): ActionFuncAsync {
return async (dispatch) => {
try {
await Client4.updateCustomProfileAttributeValues(attributeID, attributeValue);
} catch (error) {
dispatch(logError(error));
return {error};
}
return {data: true};
};
}
export function patchUser(user: UserProfile): ActionFuncAsync<UserProfile> {
return async (dispatch) => {
let data: UserProfile;

View File

@@ -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);
});
});
});

View File

@@ -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<UserPropertyField> = {}, action: MMReduxAction) {
const data: UserPropertyField[] = action.data;
switch (action.type) {
case GeneralTypes.CUSTOM_PROFILE_ATTRIBUTES_RECEIVED:
return data.reduce<IDMappedObjects<UserPropertyField>>((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,

View File

@@ -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({});
});
});
});

View File

@@ -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<UserPropertyField> {
return state.entities.general.customProfileAttributes;
}

View File

@@ -13,6 +13,7 @@ const state: GlobalState = {
serverVersion: '',
firstAdminVisitMarketplaceStatus: false,
firstAdminCompleteSetup: false,
customProfileAttributes: {},
},
users: {
currentUserId: '',

View File

@@ -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,

View File

@@ -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<UserPropertyField[]>(
`${this.getCustomProfileAttributeFieldsRoute()}`,
{method: 'GET'},
);
};
createCustomProfileAttributeField = async (patch: UserPropertyFieldPatch) => {
return this.doFetch<UserPropertyField>(
`${this.getCustomProfileAttributeFieldsRoute()}`,
{method: 'POST', body: JSON.stringify(patch)},
);
};
patchCustomProfileAttributeField = async (fieldId: string, patch: UserPropertyFieldPatch) => {
return this.doFetch<UserPropertyField>(
`${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<StatusOK>(
`${this.getCustomProfileAttributeFieldRoute(fieldId)}`,
{method: 'DELETE'},
);
};
updateCustomProfileAttributeValues = (attributeID: string, attributeValue: string) => {
const obj: { [key: string]: string } = {};
obj[attributeID] = attributeValue;
return this.doFetch<Record<string, string>>(
`${this.getCustomProfileAttributeValuesRoute()}`,
{method: 'PATCH', body: JSON.stringify(obj)},
);
};
getUserCustomProfileAttributesValues = async (userID: string) => {
const data = await this.doFetch<Record<string, string>>(
`${this.getUserRoute(userID)}/custom_profile_attributes`,
{method: 'GET'},
);
return data;
};
// Post Routes
createPost = async (post: Post) => {

View File

@@ -123,6 +123,7 @@ export type ClientConfig = {
FileLevel: string;
FeatureFlagAppsEnabled: string;
FeatureFlagCallsEnabled: string;
FeatureFlagCustomProfileAttributes: string;
FeatureFlagWebSocketEventScope: string;
ForgotPasswordLink: string;
GiphySdkKey: string;

View File

@@ -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<ClientConfig>;
@@ -9,6 +11,7 @@ export type GeneralState = {
firstAdminCompleteSetup: boolean;
license: ClientLicense;
serverVersion: string;
customProfileAttributes: IDMappedObjects<UserPropertyField>;
};
export type SystemSetting = {

View File

@@ -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<T> = {
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<Pick<UserPropertyField, 'name' | 'attrs' | 'type'>>;