MM-62564 - implement websockets for CPA (#30169)

* implement websockets for CPA

* fix for testing with server

* revert feature flag

* fix unit test

* update constant names

* add reconnect handler

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Scott Bishel
2025-02-24 11:53:54 -07:00
коммит произвёл GitHub
родитель 806fce3030
Коммит 1803f1c215
20 изменённых файлов: 471 добавлений и 110 удалений

Просмотреть файл

@@ -37,7 +37,7 @@ import {
} from 'mattermost-redux/actions/channels';
import {getCloudSubscription} from 'mattermost-redux/actions/cloud';
import {clearErrors, logError} from 'mattermost-redux/actions/errors';
import {setServerVersion, getClientConfig} from 'mattermost-redux/actions/general';
import {setServerVersion, getClientConfig, getCustomProfileAttributeFields} from 'mattermost-redux/actions/general';
import {getGroup as fetchGroup} from 'mattermost-redux/actions/groups';
import {
getCustomEmojiForReaction,
@@ -285,6 +285,9 @@ export function reconnect() {
}
});
// Refresh custom profile attributes on reconnect
dispatch(getCustomProfileAttributeFields());
if (state.websocket.lastDisconnectAt) {
dispatch(checkForModifiedUsers());
}
@@ -630,6 +633,18 @@ export function handleEvent(msg) {
case SocketEvents.HOSTED_CUSTOMER_SIGNUP_PROGRESS_UPDATED:
dispatch(handleHostedCustomerSignupProgressUpdated(msg));
break;
case SocketEvents.CPA_VALUES_UPDATED:
dispatch(handleCustomAttributeValuesUpdated(msg));
break;
case SocketEvents.CPA_FIELD_CREATED:
dispatch(handleCustomAttributesCreated(msg));
break;
case SocketEvents.CPA_FIELD_UPDATED:
dispatch(handleCustomAttributesUpdated(msg));
break;
case SocketEvents.CPA_FIELD_DELETED:
dispatch(handleCustomAttributesDeleted(msg));
break;
default:
}
@@ -1898,3 +1913,31 @@ function handleChannelBookmarkSorted(msg) {
data: {channelId: msg.broadcast.channel_id, bookmarks},
};
}
export function handleCustomAttributeValuesUpdated(msg) {
return {
type: UserTypes.RECEIVED_CPA_VALUES,
data: {userID: msg.data.user_id, customAttributeValues: msg.data.values},
};
}
export function handleCustomAttributesCreated(msg) {
return {
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELD_CREATED,
data: msg.data.field,
};
}
export function handleCustomAttributesUpdated(msg) {
return {
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELD_PATCHED,
data: msg.data.field,
};
}
export function handleCustomAttributesDeleted(msg) {
return {
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELD_DELETED,
data: msg.data.field_id,
};
}

Просмотреть файл

@@ -4,6 +4,7 @@
import {CloudTypes} from 'mattermost-redux/action_types';
import {fetchMyCategories} from 'mattermost-redux/actions/channel_categories';
import {fetchAllMyTeamsChannels} from 'mattermost-redux/actions/channels';
import {getCustomProfileAttributeFields} from 'mattermost-redux/actions/general';
import {getGroup} from 'mattermost-redux/actions/groups';
import {
getPostThreads,
@@ -11,7 +12,8 @@ import {
} from 'mattermost-redux/actions/posts';
import {batchFetchStatusesProfilesGroupsFromPosts} from 'mattermost-redux/actions/status_profile_polling';
import {getUser} from 'mattermost-redux/actions/users';
import {getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import {getCustomProfileAttributes} from 'mattermost-redux/selectors/entities/general';
import {getStatusForUserId, getUser as stateUser} from 'mattermost-redux/selectors/entities/users';
import {handleNewPost} from 'actions/post_actions';
import {syncPostsInChannel} from 'actions/views/channel';
@@ -41,6 +43,10 @@ import {
handleCloudSubscriptionChanged,
handleGroupAddedMemberEvent,
handleStatusChangedEvent,
handleCustomAttributeValuesUpdated,
handleCustomAttributesCreated,
handleCustomAttributesUpdated,
handleCustomAttributesDeleted,
} from './websocket_actions';
jest.mock('mattermost-redux/actions/posts', () => ({
@@ -59,6 +65,11 @@ jest.mock('mattermost-redux/actions/status_profile_polling', () => ({
batchFetchStatusesProfilesGroupsFromPosts: jest.fn(() => ({type: ''})),
}));
jest.mock('mattermost-redux/actions/general', () => ({
...jest.requireActual('mattermost-redux/actions/general'),
getCustomProfileAttributeFields: jest.fn(() => ({type: 'CUSTOM_PROFILE_ATTRIBUTE_FIELDS_RECEIVED'})),
}));
jest.mock('mattermost-redux/actions/groups', () => ({
...jest.requireActual('mattermost-redux/actions/groups'),
getGroup: jest.fn(() => ({type: 'RECEIVED_GROUP'})),
@@ -664,6 +675,11 @@ describe('reconnect', () => {
reconnect();
expect(fetchAllMyTeamsChannels).toHaveBeenCalled();
});
test('should reload custom profile attribute fields on reconnect', () => {
reconnect();
expect(getCustomProfileAttributeFields).toHaveBeenCalled();
});
});
describe('handleChannelUpdatedEvent', () => {
@@ -1282,3 +1298,141 @@ describe('handleStatusChangedEvent', () => {
expect(getStatusForUserId(testStore.getState(), currentUserId)).toBe(UserStatuses.OFFLINE);
});
});
describe('handleCustomAttributeValuesUpdated', () => {
const currentUserId = 'user1';
function makeInitialState() {
return {
entities: {
users: {
profiles: {
user1: {id: currentUserId},
},
},
},
};
}
test('should add the CustomAttributeValues to the user', () => {
const testStore = realConfigureStore(makeInitialState());
expect(stateUser(testStore.getState(), currentUserId)).toEqual({id: currentUserId});
testStore.dispatch(handleCustomAttributeValuesUpdated({
event: SocketEvents.CPA_VALUES_UPDATED,
data: {
user_id: currentUserId,
values: {field1: 'value1', field2: 'value2'},
},
}));
expect(stateUser(testStore.getState(), currentUserId).custom_profile_attributes).toBeTruthy();
expect(stateUser(testStore.getState(), currentUserId).custom_profile_attributes.field1).toEqual('value1');
expect(stateUser(testStore.getState(), currentUserId).custom_profile_attributes.field2).toEqual('value2');
// update one field, add new field
testStore.dispatch(handleCustomAttributeValuesUpdated({
event: SocketEvents.CPA_VALUES_UPDATED,
data: {
user_id: currentUserId,
values: {field1: 'valueChanged', field3: 'new field'},
},
}));
expect(stateUser(testStore.getState(), currentUserId).custom_profile_attributes).toBeTruthy();
expect(stateUser(testStore.getState(), currentUserId).custom_profile_attributes.field1).toEqual('valueChanged');
expect(stateUser(testStore.getState(), currentUserId).custom_profile_attributes.field2).toEqual('value2');
expect(stateUser(testStore.getState(), currentUserId).custom_profile_attributes.field3).toEqual('new field');
});
test('should ignore the CustomAttributeValues if no user', () => {
const testStore = realConfigureStore(makeInitialState());
expect(stateUser(testStore.getState(), currentUserId)).toEqual({id: currentUserId});
testStore.dispatch(handleCustomAttributeValuesUpdated({
event: SocketEvents.CPA_VALUES_UPDATED,
data: {
user_id: 'nonExistantUser',
values: {field1: 'value1', field2: 'value2'},
},
}));
expect(stateUser(testStore.getState(), 'nonExistintUser')).toBeFalsy();
expect(stateUser(testStore.getState(), currentUserId)).toBeTruthy();
expect(stateUser(testStore.getState(), currentUserId).custom_profile_attributes).toBeFalsy();
});
});
describe('handleCustomAttributeCRUD', () => {
const field1 = {id: 'field1', groupid: 'group1', name: 'FIELD ONE', type: 'text'};
const field2 = {id: 'field2', groupid: 'group1', name: 'FIELD TWO', type: 'text'};
function makeInitialState() {
return {
entities: {
general: {
},
},
};
}
test('should add the CustomAttributeField to the state', () => {
const testStore = realConfigureStore(makeInitialState());
testStore.dispatch(handleCustomAttributesCreated({
event: SocketEvents.CPA_FIELD_CREATED,
data: {
field: field1,
},
}));
let cpaFields = getCustomProfileAttributes(testStore.getState());
expect(cpaFields).toBeTruthy();
expect(Object.keys(cpaFields).length).toEqual(1);
expect(cpaFields.field1.type).toEqual(field1.type);
expect(cpaFields.field1.name).toEqual(field1.name);
// create second field
testStore.dispatch(handleCustomAttributesCreated({
event: SocketEvents.CPA_FIELD_CREATED,
data: {
field: field2,
},
}));
cpaFields = getCustomProfileAttributes(testStore.getState());
expect(cpaFields).toBeTruthy();
expect(Object.keys(cpaFields).length).toEqual(2);
expect(cpaFields.field2.type).toEqual(field2.type);
expect(cpaFields.field2.name).toEqual(field2.name);
// update field
testStore.dispatch(handleCustomAttributesUpdated({
event: SocketEvents.CPA_FIELD_UPDATED,
data: {
field: {...field1, name: 'Updated Name'},
},
}));
cpaFields = getCustomProfileAttributes(testStore.getState());
expect(cpaFields).toBeTruthy();
expect(Object.keys(cpaFields).length).toEqual(2);
expect(cpaFields.field1.name).toEqual('Updated Name');
expect(cpaFields.field2.name).toEqual(field2.name);
// delete field
testStore.dispatch(handleCustomAttributesDeleted({
event: SocketEvents.CPA_FIELD_DELETED,
data: {
field_id: field1.id,
},
}));
cpaFields = getCustomProfileAttributes(testStore.getState());
expect(cpaFields).toBeTruthy();
expect(Object.keys(cpaFields).length).toEqual(1);
expect(cpaFields.field2).toBeTruthy();
});
});

Просмотреть файл

@@ -1,12 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import React, {useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {getCustomProfileAttributeFields} from 'mattermost-redux/actions/general';
import {Client4} from 'mattermost-redux/client';
import {getCustomProfileAttributeValues} from 'mattermost-redux/actions/users';
import {getCustomProfileAttributes} from 'mattermost-redux/selectors/entities/general';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
@@ -17,42 +17,43 @@ const ProfilePopoverCustomAttributes = ({
userID,
}: Props) => {
const dispatch = useDispatch();
const [customAttributeValues, setCustomAttributeValues] = useState<Record<string, string>>({});
const userProfile = useSelector((state: GlobalState) => getUser(state, userID));
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;
if (!userProfile.custom_profile_attributes) {
dispatch(getCustomProfileAttributeValues(userID));
}
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>
);
});
const attributeSections = Object.values(customProfileAttributeFields).map((attribute) => {
if (userProfile.custom_profile_attributes) {
const value = userProfile.custom_profile_attributes[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 null;
});
return (
<>{attributeSections}</>
);

Просмотреть файл

@@ -8,7 +8,7 @@ import {withRouter} from 'react-router-dom';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {getFirstAdminSetupComplete} from 'mattermost-redux/actions/general';
import {getFirstAdminSetupComplete, getCustomProfileAttributeFields} from 'mattermost-redux/actions/general';
import {getProfiles} from 'mattermost-redux/actions/users';
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
@@ -89,6 +89,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
initializeProducts,
handleLoginLogoutSignal,
redirectToOnboardingOrDefaultTeam,
getCustomProfileAttributeFields,
}, dispatch),
};
}

Просмотреть файл

@@ -105,6 +105,7 @@ describe('components/Root', () => {
handleLoginLogoutSignal,
redirectToOnboardingOrDefaultTeam,
}, store.dispatch),
getCustomProfileAttributeFields: jest.fn(),
},
permalinkRedirectTeamName: 'myTeam',
...{

Просмотреть файл

@@ -185,6 +185,7 @@ export default class Root extends React.PureComponent<Props, State> {
this.props.actions.migrateRecentEmojis();
this.props.actions.loadRecentlyUsedCustomEmojis();
this.props.actions.getCustomProfileAttributeFields();
this.showLandingPageIfNecessary();

Просмотреть файл

@@ -6,13 +6,13 @@ 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,
getCustomProfileAttributeValues,
} from 'mattermost-redux/actions/users';
import {getConfig, getCustomProfileAttributes, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
@@ -67,7 +67,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
setDefaultProfileImage,
uploadProfileImage,
saveCustomProfileAttribute,
getCustomProfileAttributeFields,
getCustomProfileAttributeValues,
}, dispatch),
};
}

Просмотреть файл

@@ -7,8 +7,6 @@ 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';
@@ -56,7 +54,7 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
setDefaultProfileImage: jest.fn(),
uploadProfileImage: jest.fn(),
saveCustomProfileAttribute: jest.fn(),
getCustomProfileAttributeFields: jest.fn(),
getCustomProfileAttributeValues: jest.fn(),
},
maxFileSize: 1024,
ldapPositionAttributeSet: false,
@@ -66,7 +64,7 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
};
const customProfileAttribute: UserPropertyField = {
id: '1',
id: 'field1',
group_id: 'custom_profile_attributes',
name: 'Test Attribute',
type: 'text',
@@ -196,65 +194,98 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
});
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};
const testUser = {...user, custom_profile_attributes: {}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: {field1: customProfileAttribute},
user: testUser,
};
renderWithContext(<UserSettingsGeneral {...props}/>);
expect(props.actions.getCustomProfileAttributeValues).not.toHaveBeenCalled();
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};
const testUser = {...user, custom_profile_attributes: {field1: ''}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: {field1: customProfileAttribute},
user: testUser,
};
renderWithContext(<UserSettingsGeneral {...props}/>);
expect(props.actions.getCustomProfileAttributeValues).not.toHaveBeenCalled();
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};
test('should show Custom Attribute Field with value', async () => {
const testUser = {...user, custom_profile_attributes: {field1: 'FieldOneValue'}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: {field1: customProfileAttribute},
user: testUser,
};
renderWithContext(<UserSettingsGeneral {...props}/>);
expect(props.actions.getCustomProfileAttributeValues).not.toHaveBeenCalled();
expect(await screen.findByText('FieldOneValue')).toBeInTheDocument();
});
test('should call getCustomProfileAttributeValues if users properties are null', async () => {
const testUser = {...user};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: {field1: customProfileAttribute},
actions: {...requiredProps.actions},
user: testUser,
};
const {rerender} = renderWithContext(<UserSettingsGeneral {...props}/>);
expect(props.actions.getCustomProfileAttributeValues).toHaveBeenCalledTimes(1);
expect(await screen.getByRole('button', {name: `${customProfileAttribute.name} Edit`})).toBeInTheDocument();
expect(await screen.findByText('Custom Attribute Value'));
props.user = {...testUser, custom_profile_attributes: {field1: 'FieldOneValue'}};
console.log(props.user);
rerender(<UserSettingsGeneral {...props}/>);
expect(props.actions.getCustomProfileAttributeValues).toHaveBeenCalledTimes(1);
expect(await screen.findByText('FieldOneValue')).toBeInTheDocument();
});
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';
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: {field1: customProfileAttribute},
user,
activeSection: 'customAttribute_field1',
};
renderWithContext(<UserSettingsGeneral {...props}/>);
expect(await screen.getByRole('textbox', {name: `${customProfileAttribute.name}`})).toBeInTheDocument();
});
test('submitAttribute() should have called saveCustomProfileAttribute', async () => {
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({1: 'Updated Value'});
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({field1: 'Updated Value'});
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
actions: {...requiredProps.actions, saveCustomProfileAttribute},
customProfileAttributeFields: {1: customProfileAttribute},
customProfileAttributeFields: {field1: customProfileAttribute},
user: {...user},
activeSection: 'customAttribute_1',
activeSection: 'customAttribute_field1',
};
renderWithContext(<UserSettingsGeneral {...props}/>);
@@ -266,6 +297,6 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
userEvent.click(screen.getByRole('button', {name: 'Save'}));
expect(saveCustomProfileAttribute).toHaveBeenCalledTimes(1);
expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', '1', 'Updated Value');
expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'Updated Value');
});
});

Просмотреть файл

@@ -13,7 +13,6 @@ import type {IDMappedObjects} from '@mattermost/types/utilities';
import type {LogErrorOptions} from 'mattermost-redux/actions/errors';
import {LogErrorBarMode} from 'mattermost-redux/actions/errors';
import {Client4} from 'mattermost-redux/client';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {isEmail} from 'mattermost-redux/utils/helpers';
@@ -120,7 +119,7 @@ export type Props = {
setDefaultProfileImage: (id: string) => void;
uploadProfileImage: (id: string, file: File) => Promise<ActionResult>;
saveCustomProfileAttribute: (userID: string, attributeID: string, attributeValue: string) => Promise<ActionResult<Record<string, string>>>;
getCustomProfileAttributeFields: () => Promise<ActionResult>;
getCustomProfileAttributeValues: (userID: string) => Promise<ActionResult<Record<string, string>>>;
};
requireEmailVerification?: boolean;
ldapFirstNameAttributeSet?: boolean;
@@ -165,14 +164,8 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
}
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();
if (this.props.enableCustomProfileAttributes && !this.props.user.custom_profile_attributes) {
this.props.actions.getCustomProfileAttributeValues(this.props.user.id);
}
}
@@ -495,10 +488,6 @@ 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,
@@ -514,7 +503,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
sectionIsSaving: false,
showSpinner: false,
serverError: '',
customAttributeValues: cav,
customAttributeValues: user.custom_profile_attributes || {},
};
}
@@ -1332,12 +1321,11 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
};
createCustomAttributeSection = () => {
if (this.props.customProfileAttributeFields == null) {
if (!this.props.enableCustomProfileAttributes || 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;
@@ -1365,7 +1353,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
className='form-control'
type='text'
onChange={this.updateAttribute}
value={attributeValue}
value={this.state.customAttributeValues[attribute.id] || ''}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
autoCapitalize='off'
onFocus={Utils.moveCursorToEnd}
@@ -1399,6 +1387,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
);
}
let describe: JSX.Element|string = '';
const attributeValue = this.props.user.custom_profile_attributes?.[attribute.id];
if (attributeValue) {
describe = attributeValue;
} else {
@@ -1536,7 +1525,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 customAttributeSection = this.createCustomAttributeSection();
const pictureSection = this.createPictureSection();
return (
@@ -1576,7 +1565,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
<div className='divider-light'/>
{emailSection}
<div className='divider-light'/>
{customProperiesSection}
{customAttributeSection}
{pictureSection}
<div className='divider-dark'/>
</div>

Просмотреть файл

@@ -12,7 +12,10 @@ export default keyMirror({
CLIENT_LICENSE_RECEIVED: null,
CLIENT_LICENSE_RESET: null,
CUSTOM_PROFILE_ATTRIBUTES_RECEIVED: null,
CUSTOM_PROFILE_ATTRIBUTE_FIELDS_RECEIVED: null,
CUSTOM_PROFILE_ATTRIBUTE_FIELD_CREATED: null,
CUSTOM_PROFILE_ATTRIBUTE_FIELD_DELETED: null,
CUSTOM_PROFILE_ATTRIBUTE_FIELD_PATCHED: null,
LOG_CLIENT_ERROR_REQUEST: null,
LOG_CLIENT_ERROR_SUCCESS: null,

Просмотреть файл

@@ -31,6 +31,7 @@ export default keyMirror({
RECEIVED_TERMS_OF_SERVICE_STATUS: null,
RECEIVED_PROFILE: null,
RECEIVED_PROFILES: null,
RECEIVED_CPA_VALUES: null,
RECEIVED_PROFILES_LIST: null,
RECEIVED_PROFILES_IN_TEAM: null,
RECEIVED_PROFILE_IN_TEAM: null,

Просмотреть файл

@@ -46,22 +46,10 @@ export function getLicenseConfig() {
export function getCustomProfileAttributeFields() {
return bindClientFunc({
clientFunc: Client4.getCustomProfileAttributeFields,
onSuccess: [GeneralTypes.CUSTOM_PROFILE_ATTRIBUTES_RECEIVED],
onSuccess: [GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELDS_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,

Просмотреть файл

@@ -1776,4 +1776,31 @@ describe('Actions.Users', () => {
expect(profiles).toBe(originalState.entities.users.profiles);
});
});
it('getCustomProfileAttributeValues', async () => {
const userID = 'user1';
nock(Client4.getUserRoute(userID) + '/custom_profile_attributes').
get('').
query(true).
reply(200, {field1: 'value1', field2: 'value2'});
const originalState = {
entities: {
users: {
profiles: {
user1: {id: userID},
},
},
},
};
store = configureStore(originalState);
await store.dispatch(Actions.getCustomProfileAttributeValues(userID));
const customProfileAttributeValues = store.getState().entities.users.profiles[userID].custom_profile_attributes;
// Check a few basic fields since they may change over time
expect(Object.keys(customProfileAttributeValues).length).toEqual(2);
expect(customProfileAttributeValues.field1).toEqual('value1');
expect(customProfileAttributeValues.field2).toEqual('value2');
});
});

Просмотреть файл

@@ -476,6 +476,24 @@ export function getMe(): ActionFuncAsync<UserProfile> {
};
}
export function getCustomProfileAttributeValues(userID: string): ActionFuncAsync<Record<string, string>> {
return async (dispatch) => {
let data;
try {
data = await Client4.getUserCustomProfileAttributesValues(userID);
} catch (error) {
return {error};
}
dispatch({
type: UserTypes.RECEIVED_CPA_VALUES,
data: {userID, customAttributeValues: data},
});
return {data};
};
}
export function updateMyTermsOfServiceStatus(termsOfServiceId: string, accepted: boolean): ActionFuncAsync {
return async (dispatch, getState) => {
const response = await dispatch(bindClientFunc({

Просмотреть файл

@@ -52,11 +52,11 @@ describe('reducers.entities.general', () => {
expect(actualState.firstAdminVisitMarketplaceStatus).toEqual(expectedState);
});
it('CUSTOM_PROFILE_ATTRIBUTES_RECEIVED, empty initial state', () => {
it('CUSTOM_PROFILE_ATTRIBUTE_FIELDS_RECEIVED, empty initial state', () => {
const state = {};
const testAttributeOne = {id: '123', name: 'test attribute', type: 'text'};
const action = {
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTES_RECEIVED,
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELDS_RECEIVED,
data: [testAttributeOne],
};
const expectedState = {[testAttributeOne.id]: testAttributeOne} as ReducerState['customProfileAttributes'];
@@ -64,7 +64,7 @@ describe('reducers.entities.general', () => {
expect(actualState.customProfileAttributes).toEqual(expectedState);
});
it('CUSTOM_PROFILE_ATTRIBUTES_RECEIVED, attributes are completely replaced', () => {
it('CUSTOM_PROFILE_ATTRIBUTE_FIELDS_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};
@@ -72,7 +72,7 @@ describe('reducers.entities.general', () => {
const updatedAttributeOne = {id: '123', name: 'new name value', type: 'text'};
const action = {
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTES_RECEIVED,
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELDS_RECEIVED,
data: [updatedAttributeOne],
};
const expectedState = {[updatedAttributeOne.id]: updatedAttributeOne};
@@ -80,5 +80,44 @@ describe('reducers.entities.general', () => {
const actualState = reducer({customProfileAttributes: state} as ReducerState, action);
expect(actualState.customProfileAttributes).toEqual(expectedState);
});
it('CUSTOM_PROFILE_ATTRIBUTE_FIELD_CREATED', () => {
const state = {};
const testAttributeOne = {id: '123', name: 'test attribute', type: 'text'};
const action = {
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELD_CREATED,
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_ATTRIBUTE_FIELD_PATCHED', () => {
const testAttributeOne = {id: '123', name: 'test attribute', type: 'text'};
const state = {[testAttributeOne.id]: testAttributeOne};
const renamedAttributeOne = {...testAttributeOne, name: 'renamed attribute'};
const action = {
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELD_PATCHED,
data: renamedAttributeOne,
};
const expectedState = {[testAttributeOne.id]: renamedAttributeOne} as ReducerState['customProfileAttributes'];
const actualState = reducer({customProfileAttributes: state} as ReducerState, action);
expect(actualState.customProfileAttributes).toEqual(expectedState);
});
it('CUSTOM_PROFILE_ATTRIBUTE_FIELD_DELETED', () => {
const testAttributeOne = {id: '123', name: 'test attribute', type: 'text'};
const state = {[testAttributeOne.id]: testAttributeOne};
const action = {
type: GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELD_DELETED,
data: testAttributeOne.id,
};
const expectedState = {} as ReducerState['customProfileAttributes'];
const actualState = reducer({customProfileAttributes: state} as ReducerState, action);
expect(actualState.customProfileAttributes).toEqual(expectedState);
});
});
});

Просмотреть файл

@@ -40,13 +40,30 @@ 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:
case GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELDS_RECEIVED: {
const data: UserPropertyField[] = action.data;
return data.reduce<IDMappedObjects<UserPropertyField>>((acc, field) => {
acc[field.id] = field;
return acc;
}, {});
}
case GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELD_DELETED: {
const nextState = {...state};
const fieldId = action.data;
if (Object.hasOwn(nextState, fieldId)) {
Reflect.deleteProperty(nextState, fieldId);
return nextState;
}
return state;
}
case GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELD_CREATED:
case GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELD_PATCHED: {
return {
...state,
[action.data.id]: action.data,
};
}
default:
return state;
}

Просмотреть файл

@@ -1053,6 +1053,39 @@ describe('Reducers.users', () => {
expect(newProfiles.second_user_id).toEqual(secondUser);
expect(newProfiles.third_user_id).toEqual(thirdUser);
});
test('UserTypes.RECEIVED_CPA_VALUES, should merge existing users custom attributes', () => {
const firstUser = TestHelper.getUserMock({id: 'first_user_id'});
const secondUser = TestHelper.getUserMock({id: 'second_user_id'});
const state = {
profiles: {
first_user_id: firstUser,
second_user_id: secondUser,
},
};
const action = {
type: UserTypes.RECEIVED_CPA_VALUES,
data: {
userID: 'first_user_id',
customAttributeValues: {field1: 'value1'},
},
};
const {profiles: newProfiles} = reducer(state as unknown as ReducerState, action);
expect(newProfiles.first_user_id.custom_profile_attributes!.field1).toEqual('value1');
// update field
const updateAction = {
type: UserTypes.RECEIVED_CPA_VALUES,
data: {
userID: 'first_user_id',
customAttributeValues: {field1: 'updatedValue'},
},
};
const {profiles: updatedProfiles} = reducer(state as unknown as ReducerState, updateAction);
expect(updatedProfiles.first_user_id.custom_profile_attributes!.field1).toEqual('updatedValue');
});
});
test('PROFILE_NO_LONGER_VISIBLE should remove references to users from state', () => {

Просмотреть файл

@@ -227,6 +227,15 @@ function profiles(state: UsersState['profiles'] = {}, action: MMReduxAction) {
return receiveUserProfile(state, user);
}
case UserTypes.RECEIVED_CPA_VALUES: {
const {userID, customAttributeValues} = action.data;
const existingProfile = state[userID];
if (!existingProfile) {
return state;
}
const profileAttributes = {...existingProfile.custom_profile_attributes, ...customAttributeValues};
return receiveUserProfile(state, {...existingProfile, custom_profile_attributes: profileAttributes});
}
case UserTypes.RECEIVED_PROFILES_LIST: {
const users: UserProfile[] = action.data;

Просмотреть файл

@@ -664,6 +664,10 @@ export const SocketEvents = {
SCHEDULED_POST_DELETED: 'scheduled_post_deleted',
PERSISTENT_NOTIFICATION_TRIGGERED: 'persistent_notification_triggered',
HOSTED_CUSTOMER_SIGNUP_PROGRESS_UPDATED: 'hosted_customer_signup_progress_updated',
CPA_FIELD_CREATED: 'custom_profile_attributes_field_created',
CPA_FIELD_UPDATED: 'custom_profile_attributes_field_updated',
CPA_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
CPA_VALUES_UPDATED: 'custom_profile_attributes_values_updated',
};
export const TutorialSteps = {

Просмотреть файл

@@ -60,6 +60,7 @@ export type UserProfile = {
terms_of_service_create_at: number;
remote_id?: string;
status?: string;
custom_profile_attributes?: Record<string, string>;
};
export type UserProfileWithLastViewAt = UserProfile & {