Skip to content

Commit

Permalink
MM-62564 - implement websockets for CPA (mattermost#30169)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
sbishel and mattermost-build authored Feb 24, 2025
1 parent 806fce3 commit 1803f1c
Show file tree
Hide file tree
Showing 20 changed files with 470 additions and 109 deletions.
45 changes: 44 additions & 1 deletion webapp/channels/src/actions/websocket_actions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -285,6 +285,9 @@ export function reconnect() {
}
});

// Refresh custom profile attributes on reconnect
dispatch(getCustomProfileAttributeFields());

if (state.websocket.lastDisconnectAt) {
dispatch(checkForModifiedUsers());
}
Expand Down Expand Up @@ -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:
}

Expand Down Expand Up @@ -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,
};
}
156 changes: 155 additions & 1 deletion webapp/channels/src/actions/websocket_actions.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
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,
receivedNewPost,
} 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';
Expand Down Expand Up @@ -41,6 +43,10 @@ import {
handleCloudSubscriptionChanged,
handleGroupAddedMemberEvent,
handleStatusChangedEvent,
handleCustomAttributeValuesUpdated,
handleCustomAttributesCreated,
handleCustomAttributesUpdated,
handleCustomAttributesDeleted,
} from './websocket_actions';

jest.mock('mattermost-redux/actions/posts', () => ({
Expand All @@ -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'})),
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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'
});
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'
>
{value}
</p>
</div>
);
<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}</>
);
Expand Down
3 changes: 2 additions & 1 deletion webapp/channels/src/components/root/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -89,6 +89,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
initializeProducts,
handleLoginLogoutSignal,
redirectToOnboardingOrDefaultTeam,
getCustomProfileAttributeFields,
}, dispatch),
};
}
Expand Down
1 change: 1 addition & 0 deletions webapp/channels/src/components/root/root.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ describe('components/Root', () => {
handleLoginLogoutSignal,
redirectToOnboardingOrDefaultTeam,
}, store.dispatch),
getCustomProfileAttributeFields: jest.fn(),
},
permalinkRedirectTeamName: 'myTeam',
...{
Expand Down
1 change: 1 addition & 0 deletions webapp/channels/src/components/root/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading

0 comments on commit 1803f1c

Please sign in to comment.