mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const state: GlobalState = {
|
||||
serverVersion: '',
|
||||
firstAdminVisitMarketplaceStatus: false,
|
||||
firstAdminCompleteSetup: false,
|
||||
customProfileAttributes: {},
|
||||
},
|
||||
users: {
|
||||
currentUserId: '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -123,6 +123,7 @@ export type ClientConfig = {
|
||||
FileLevel: string;
|
||||
FeatureFlagAppsEnabled: string;
|
||||
FeatureFlagCallsEnabled: string;
|
||||
FeatureFlagCustomProfileAttributes: string;
|
||||
FeatureFlagWebSocketEventScope: string;
|
||||
ForgotPasswordLink: string;
|
||||
GiphySdkKey: string;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
34
webapp/platform/types/src/properties.ts
Normal file
34
webapp/platform/types/src/properties.ts
Normal 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'>>;
|
||||
Reference in New Issue
Block a user