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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
806fce3030
Коммит
1803f1c215
@@ -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 & {
|
||||
|
||||
Ссылка в новой задаче
Block a user