From 1803f1c215e652a0e7aba04a04cbeb73d73d21c8 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Mon, 24 Feb 2025 11:53:54 -0700 Subject: [PATCH] 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 --- .../src/actions/websocket_actions.jsx | 45 ++++- .../src/actions/websocket_actions.test.jsx | 156 +++++++++++++++++- .../profile_popover_custom_attributes.tsx | 67 ++++---- webapp/channels/src/components/root/index.ts | 3 +- .../src/components/root/root.test.tsx | 1 + webapp/channels/src/components/root/root.tsx | 1 + .../components/user_settings/general/index.ts | 4 +- .../general/user_settings_general.test.tsx | 93 +++++++---- .../general/user_settings_general.tsx | 29 +--- .../src/action_types/general.ts | 5 +- .../src/action_types/users.ts | 1 + .../mattermost-redux/src/actions/general.ts | 14 +- .../src/actions/users.test.ts | 27 +++ .../mattermost-redux/src/actions/users.ts | 18 ++ .../src/reducers/entities/general.test.ts | 47 +++++- .../src/reducers/entities/general.ts | 21 ++- .../src/reducers/entities/users.test.ts | 33 ++++ .../src/reducers/entities/users.ts | 9 + webapp/channels/src/utils/constants.tsx | 4 + webapp/platform/types/src/users.ts | 1 + 20 files changed, 470 insertions(+), 109 deletions(-) diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index 9b09b1b9596d..ded39be457a0 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -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, + }; +} diff --git a/webapp/channels/src/actions/websocket_actions.test.jsx b/webapp/channels/src/actions/websocket_actions.test.jsx index 3f5cd04e8cd6..ef2e81c4ffe0 100644 --- a/webapp/channels/src/actions/websocket_actions.test.jsx +++ b/webapp/channels/src/actions/websocket_actions.test.jsx @@ -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(); + }); +}); diff --git a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx index 565501341d14..7d4d7614592b 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx @@ -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>({}); + 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 ( -
- - {attribute.name} - -

{ + if (userProfile.custom_profile_attributes) { + const value = userProfile.custom_profile_attributes[attribute.id]; + if (!value) { + return null; + } + return ( +

- {value} -

-
- ); + + {attribute.name} + +

+ {value} +

+
+ ); + } + return null; }); + return ( <>{attributeSections} ); diff --git a/webapp/channels/src/components/root/index.ts b/webapp/channels/src/components/root/index.ts index 0c6d9c70f1ab..fc198be33946 100644 --- a/webapp/channels/src/components/root/index.ts +++ b/webapp/channels/src/components/root/index.ts @@ -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), }; } diff --git a/webapp/channels/src/components/root/root.test.tsx b/webapp/channels/src/components/root/root.test.tsx index 14eef3eaf71f..7f9df3acbca3 100644 --- a/webapp/channels/src/components/root/root.test.tsx +++ b/webapp/channels/src/components/root/root.test.tsx @@ -105,6 +105,7 @@ describe('components/Root', () => { handleLoginLogoutSignal, redirectToOnboardingOrDefaultTeam, }, store.dispatch), + getCustomProfileAttributeFields: jest.fn(), }, permalinkRedirectTeamName: 'myTeam', ...{ diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index 9101560aed6d..2a9b70143925 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -185,6 +185,7 @@ export default class Root extends React.PureComponent { this.props.actions.migrateRecentEmojis(); this.props.actions.loadRecentlyUsedCustomEmojis(); + this.props.actions.getCustomProfileAttributeFields(); this.showLandingPageIfNecessary(); diff --git a/webapp/channels/src/components/user_settings/general/index.ts b/webapp/channels/src/components/user_settings/general/index.ts index 8e1f1e673c78..63ba219a49e7 100644 --- a/webapp/channels/src/components/user_settings/general/index.ts +++ b/webapp/channels/src/components/user_settings/general/index.ts @@ -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), }; } diff --git a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx index 90d5ccc396c0..128ad36e2f53 100644 --- a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx +++ b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); - 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(); @@ -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'); }); }); diff --git a/webapp/channels/src/components/user_settings/general/user_settings_general.tsx b/webapp/channels/src/components/user_settings/general/user_settings_general.tsx index 6dee9080b445..4892bd43f08e 100644 --- a/webapp/channels/src/components/user_settings/general/user_settings_general.tsx +++ b/webapp/channels/src/components/user_settings/general/user_settings_general.tsx @@ -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; saveCustomProfileAttribute: (userID: string, attributeID: string, attributeValue: string) => Promise>>; - getCustomProfileAttributeFields: () => Promise; + getCustomProfileAttributeValues: (userID: string) => Promise>>; }; requireEmailVerification?: boolean; ldapFirstNameAttributeSet?: boolean; @@ -165,14 +164,8 @@ export class UserSettingsGeneralTab extends PureComponent { } 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 { 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 { sectionIsSaving: false, showSpinner: false, serverError: '', - customAttributeValues: cav, + customAttributeValues: user.custom_profile_attributes || {}, }; } @@ -1332,12 +1321,11 @@ export class UserSettingsGeneralTab extends PureComponent { }; 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 { 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 { ); } 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 { 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 {
{emailSection}
- {customProperiesSection} + {customAttributeSection} {pictureSection}
diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts index 8701df69bfd0..452a08f40352 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts @@ -12,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, diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/users.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/users.ts index 7726a8b4ee51..f4c946163f47 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/users.ts @@ -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, diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts index 28dfa24797b5..ac3d5730bf4a 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts @@ -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, diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/users.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/users.test.ts index 7bd618d295ca..e38fb1efcf9e 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/users.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/users.test.ts @@ -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'); + }); }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts index 3571fa94e5cf..cc57eaff245d 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts @@ -476,6 +476,24 @@ export function getMe(): ActionFuncAsync { }; } +export function getCustomProfileAttributeValues(userID: string): ActionFuncAsync> { + 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({ diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.test.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.test.ts index a38ea510b33f..a9d56148440d 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.test.ts @@ -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); + }); }); }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts index f6d7e6896bad..5e553c4c84d8 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts @@ -40,13 +40,30 @@ function license(state: ClientLicense = {}, action: MMReduxAction) { } function customProfileAttributes(state: IDMappedObjects = {}, action: MMReduxAction) { - const data: UserPropertyField[] = action.data; switch (action.type) { - case GeneralTypes.CUSTOM_PROFILE_ATTRIBUTES_RECEIVED: + case GeneralTypes.CUSTOM_PROFILE_ATTRIBUTE_FIELDS_RECEIVED: { + const data: UserPropertyField[] = action.data; return data.reduce>((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; } diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts index 05da451780df..231a73b754d4 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts @@ -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', () => { diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts index 4c910c7123fd..717db8030b4c 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts @@ -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; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index a33bb5c2d4ee..e65716527c8b 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -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 = { diff --git a/webapp/platform/types/src/users.ts b/webapp/platform/types/src/users.ts index aca522d7a4d2..8ce683bdfb34 100644 --- a/webapp/platform/types/src/users.ts +++ b/webapp/platform/types/src/users.ts @@ -60,6 +60,7 @@ export type UserProfile = { terms_of_service_create_at: number; remote_id?: string; status?: string; + custom_profile_attributes?: Record; }; export type UserProfileWithLastViewAt = UserProfile & {