View custom profile attributes (#8460)

* feat: Add custom attributes section to user profile
This commit is contained in:
Guillermo Vayá 2025-01-13 20:38:14 +01:00 committed by GitHub
parent 84eded1bde
commit fff6788e94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 311 additions and 34 deletions

View file

@ -199,6 +199,10 @@ export default class ClientBase extends ClientTracking {
return `${this.urlVersion}/client_perf`;
}
getCustomProfileAttributesRoute() {
return `${this.urlVersion}/custom_profile_attributes`;
}
doFetch = async (url: string, options: ClientOptions, returnDataOnly = true) => {
return this.doFetchWithTracking(url, options, returnDataOnly);
};

View file

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import TestHelper from '@test/test_helper';
import type ClientBase from './base';
import type {ClientCustomAttributesMix} from './custom_profile_attributes';
describe('CustomAttributes', () => {
let client: ClientCustomAttributesMix & ClientBase;
beforeAll(() => {
client = TestHelper.createClient();
client.doFetch = jest.fn();
});
test('getCustomProfileAttributeFields', async () => {
await client.getCustomProfileAttributeFields();
expect(client.doFetch).toHaveBeenCalledWith(
`${client.getCustomProfileAttributesRoute()}/fields`,
{method: 'get'},
);
});
test('getCustomProfileAttributeValues', async () => {
const userId = 'user1';
await client.getCustomProfileAttributeValues(userId);
expect(client.doFetch).toHaveBeenCalledWith(
`${client.getUserRoute(userId)}/custom_profile_attributes`,
{method: 'get'},
);
});
});

View file

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type ClientBase from './base';
export interface ClientCustomAttributesMix {
getCustomProfileAttributeFields: () => Promise<CustomProfileField[]>;
getCustomProfileAttributeValues: (userID: string) => Promise<CustomProfileAttributeSimple>;
}
const ClientCustomAttributes = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
getCustomProfileAttributeFields = async () => {
return this.doFetch(
`${this.getCustomProfileAttributesRoute()}/fields`,
{method: 'get'},
);
};
getCustomProfileAttributeValues = async (userID: string) => {
return this.doFetch(
`${this.getUserRoute(userID)}/custom_profile_attributes`,
{method: 'get'},
);
};
};
export default ClientCustomAttributes;

View file

@ -11,6 +11,7 @@ import ClientCategories, {type ClientCategoriesMix} from './categories';
import ClientChannelBookmarks, {type ClientChannelBookmarksMix} from './channel_bookmark';
import ClientChannels, {type ClientChannelsMix} from './channels';
import {DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE, HEADER_X_VERSION_ID} from './constants';
import ClientCustomAttributes, {type ClientCustomAttributesMix} from './custom_profile_attributes';
import ClientEmojis, {type ClientEmojisMix} from './emojis';
import ClientFiles, {type ClientFilesMix} from './files';
import ClientGeneral, {type ClientGeneralMix} from './general';
@ -44,7 +45,8 @@ interface Client extends ClientBase,
ClientUsersMix,
ClientCallsMix,
ClientPluginsMix,
ClientNPSMix
ClientNPSMix,
ClientCustomAttributesMix
{}
class Client extends mix(ClientBase).with(
@ -66,6 +68,7 @@ class Client extends mix(ClientBase).with(
ClientCalls,
ClientPlugins,
ClientNPS,
ClientCustomAttributes,
) {
// eslint-disable-next-line no-useless-constructor
constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) {

View file

@ -258,6 +258,13 @@ const ClientUsers = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
);
};
getCustomProfileAttributeFields = async () => {
return this.doFetch(
`${this.getCustomProfileAttributesRoute()}/fields`,
{method: 'get'},
);
};
getUserByUsername = async (username: string) => {
return this.doFetch(
`${this.getUsersRoute()}/username/${username}`,

View file

@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {View, StyleSheet, type ListRenderItem} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import UserProfileLabel from './label';
type Props = {
nickname?: string;
position?: string;
localTime?: string;
customAttributes?: DisplayCustomAttribute[];
}
const renderAttribute: ListRenderItem<DisplayCustomAttribute> = ({item}) => (
<UserProfileLabel
title={item.name}
description={item.value}
testID={`custom_attribute.${item.id}`}
/>
);
const CustomAttributes = ({nickname, position, localTime, customAttributes}: Props) => {
const {formatMessage} = useIntl();
// Combine standard and custom attributes
const mergeAttributes = [
(nickname ? {
id: 'nickname',
name: formatMessage({id: 'channel_info.nickname', defaultMessage: 'Nickname'}),
value: nickname,
} : {}),
(position ? {
id: 'position',
name: formatMessage({id: 'channel_info.position', defaultMessage: 'Position'}),
value: position,
} : {}),
(localTime ? {
id: 'local_time',
name: formatMessage({id: 'channel_info.local_time', defaultMessage: 'Local Time'}),
value: localTime,
} : {}),
...(customAttributes ?? []),
];
// remove any empty objects
const attributes = mergeAttributes.filter((v) => Object.entries(v).length !== 0);
return (
<View style={styles.container}>
<FlatList
data={attributes}
renderItem={renderAttribute}
showsVerticalScrollIndicator={true}
scrollEnabled={true}
removeClippedSubviews={true}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 12,
flex: 1,
},
});
export default CustomAttributes;

View file

@ -70,6 +70,7 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro
user,
canChangeMemberRoles,
hideGuestTags: observeConfigBooleanValue(database, 'HideGuestTags'),
enableCustomAttributes: observeConfigBooleanValue(database, 'FeatureFlagCustomProfileAttributes'),
};
});

View file

@ -5,7 +5,7 @@ import React from 'react';
import {Text, View} from 'react-native';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
@ -17,15 +17,21 @@ type Props = {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
marginVertical: 8,
flexDirection: 'row',
alignItems: 'center',
},
description: {
color: theme.centerChannelColor,
flex: 2,
...typography('Body', 200),
},
title: {
color: changeOpacity(theme.centerChannelColor, 0.56),
marginBottom: 2,
...typography('Body', 50, 'SemiBold'),
color: theme.centerChannelColor,
flex: 1,
marginRight: 20,
height: '100%',
alignItems: 'flex-start',
...typography('Body', 100, 'SemiBold'),
},
}));
@ -38,6 +44,7 @@ const UserProfileLabel = ({title, description, testID}: Props) => {
<Text
style={styles.title}
testID={`${testID}.title`}
numberOfLines={1}
>
{title}
</Text>

View file

@ -1,12 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import React, {useEffect, useRef, useState} from 'react';
import {useServerUrl} from '@context/server';
import NetworkManager from '@managers/network_manager';
import {getUserCustomStatus} from '@utils/user';
import CustomAttributes from './custom_attributes';
import UserProfileCustomStatus from './custom_status';
import UserProfileLabel from './label';
import type {UserModel} from '@database/models/server';
@ -17,36 +19,63 @@ type Props = {
showNickname: boolean;
showPosition: boolean;
user: UserModel;
enableCustomAttributes?: boolean;
}
const UserInfo = ({localTime, showCustomStatus, showLocalTime, showNickname, showPosition, user}: Props) => {
const {formatMessage} = useIntl();
const emptyList: DisplayCustomAttribute[] = []; /** avoid re-renders **/
const UserInfo = ({localTime, showCustomStatus, showLocalTime, showNickname, showPosition, user, enableCustomAttributes}: Props) => {
const customStatus = getUserCustomStatus(user);
const serverUrl = useServerUrl();
const [customAttributes, setCustomAttributes] = useState<DisplayCustomAttribute[]>(emptyList);
const lastRequest = useRef(0);
useEffect(() => {
if (enableCustomAttributes) {
const fetchData = async () => {
const reqTime = Date.now();
lastRequest.current = reqTime;
try {
const client = NetworkManager.getClient(serverUrl);
const [fields, attrValues] = await Promise.all([
client.getCustomProfileAttributeFields(),
client.getCustomProfileAttributeValues(user.id),
]);
// ignore results if there was a newer request
if (fields && fields.length > 0 && lastRequest.current === reqTime) {
const attributes = fields.map((field) => {
if (attrValues[field.id]) {
return ({
id: field.id,
name: field.name,
value: attrValues[field.id] || '',
} as DisplayCustomAttribute);
}
return {} as DisplayCustomAttribute; // this will be cleaned out in CustomAttributes along with the fixed attributes.
});
setCustomAttributes(attributes);
}
} catch {
setCustomAttributes(emptyList);
}
};
fetchData();
} else {
setCustomAttributes(emptyList);
}
}, [enableCustomAttributes, serverUrl, user.id]);
return (
<>
{showCustomStatus && <UserProfileCustomStatus customStatus={customStatus!}/> }
{showNickname &&
<UserProfileLabel
description={user.nickname}
testID='user_profile.nickname'
title={formatMessage({id: 'channel_info.nickname', defaultMessage: 'Nickname'})}
/>
}
{showPosition &&
<UserProfileLabel
description={user.position}
testID='user_profile.position'
title={formatMessage({id: 'channel_info.position', defaultMessage: 'Position'})}
/>
}
{showLocalTime &&
<UserProfileLabel
description={localTime!}
testID='user_profile.local_time'
title={formatMessage({id: 'channel_info.local_time', defaultMessage: 'Local Time'})}
/>
}
{showCustomStatus && <UserProfileCustomStatus customStatus={customStatus!}/>}
<CustomAttributes
nickname={showNickname ? user.nickname : undefined}
position={showPosition ? user.position : undefined}
localTime={showLocalTime ? localTime : undefined}
customAttributes={customAttributes}
/>
</>
);
};

View file

@ -46,6 +46,7 @@ type Props = {
userIconOverride?: string;
usernameOverride?: string;
hideGuestTags: boolean;
enableCustomAttributes: boolean;
}
const TITLE_HEIGHT = 118;
@ -86,6 +87,7 @@ const UserProfile = ({
userIconOverride,
usernameOverride,
hideGuestTags,
enableCustomAttributes,
}: Props) => {
const {formatMessage, locale} = useIntl();
const serverUrl = useServerUrl();
@ -150,12 +152,14 @@ const UserProfile = ({
return [
1,
bottomSheetSnapPoint(optionsCount, LABEL_HEIGHT) + title + extraHeight,
'90%',
];
}, [
headerText, showUserProfileOptions, showCustomStatus,
showNickname, showPosition, showLocalTime,
manageMode, bottom, showOptions,
canChangeMemberRoles, canManageAndRemoveMembers,
enableCustomAttributes,
]);
useEffect(() => {
@ -189,7 +193,7 @@ const UserProfile = ({
userId={user.id}
/>
}
{!manageMode &&
{!manageMode && (
<UserInfo
localTime={localTime}
showCustomStatus={showCustomStatus}
@ -197,8 +201,9 @@ const UserProfile = ({
showPosition={showPosition}
showLocalTime={showLocalTime}
user={user}
enableCustomAttributes={enableCustomAttributes}
/>
}
)}
{manageMode && channelId && (canManageAndRemoveMembers || canChangeMemberRoles) &&
<ManageUserOptions
canChangeMemberRoles={canChangeMemberRoles}

View file

@ -121,6 +121,7 @@ interface ClientConfig {
FeatureFlagCollapsedThreads?: string;
FeatureFlagPostPriority?: string;
FeatureFlagChannelBookmarks?: string;
FeatureFlagCustomProfileAttributes?: string;
ForgotPasswordLink?: string;
GfycatApiKey: string;
GfycatApiSecret: string;

View file

@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* CustomProfileField
* @description Each of the system properties is defined by a field.
**/
type CustomProfileField = {
/** server assigned id **/
id: string;
/** id of the group the field belongs to **/
group_id: string;
/** name of the field **/
name: string;
/** type of values accepted. Currently only text is supported **/
type: string;
/** any extra properties of the field **/
attrs?: unknown;
/** id of the target element if empty it is a system property **/
target_id: string;
/** type of element this is assigned to. Possible values user, post, card... if empty it is a system property **/
target_type: string;
create_at: number;
update_at: number;
delete_at: number;
};
/**
* DisplayCustomAttribute
* @description a simplified version of a field with its value for display purposes
**/
type DisplayCustomAttribute = {
/** field id **/
id: string;
/** field name **/
name: string;
/** value assigned to that field **/
value: string;
};

36
types/api/users.d.ts vendored
View file

@ -25,6 +25,42 @@ type UserNotifyProps = {
calls_mobile_notification_sound: string;
};
/**
* CustomProfileAttribute
* @description Value of a system property defined by a custom profile attribute field
**/
type CustomProfileAttribute = {
/** server assigned id **/
id: string;
/** id of the target element if empty it is a system property **/
target_id: string;
/** type of element this is assigned to. Possible values user, post, card... if empty it is a system property **/
target_type: string;
/** id of the group the field belongs to **/
group_id: string;
/** field id this value is referring to **/
field_id: string;
/** actual value **/
value: string;
create_at: number;
update_at: number;
delete_at: number;
}
/**
* CustomProfileAttributeSimple
* @description simpler type to display a field id with its value.
**/
type CustomProfileAttributeSimple = {
[field_id: string]: string;
}
type UserProfile = {
id: string;
create_at: number;