View custom profile attributes (#8460)
* feat: Add custom attributes section to user profile
This commit is contained in:
parent
84eded1bde
commit
fff6788e94
13 changed files with 311 additions and 34 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
35
app/client/rest/custom_profile_attributes.test.ts
Normal file
35
app/client/rest/custom_profile_attributes.test.ts
Normal 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'},
|
||||
);
|
||||
});
|
||||
});
|
||||
27
app/client/rest/custom_profile_attributes.ts
Normal file
27
app/client/rest/custom_profile_attributes.ts
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
73
app/screens/user_profile/custom_attributes.tsx
Normal file
73
app/screens/user_profile/custom_attributes.tsx
Normal 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;
|
||||
|
|
@ -70,6 +70,7 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro
|
|||
user,
|
||||
canChangeMemberRoles,
|
||||
hideGuestTags: observeConfigBooleanValue(database, 'HideGuestTags'),
|
||||
enableCustomAttributes: observeConfigBooleanValue(database, 'FeatureFlagCustomProfileAttributes'),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
1
types/api/config.d.ts
vendored
1
types/api/config.d.ts
vendored
|
|
@ -121,6 +121,7 @@ interface ClientConfig {
|
|||
FeatureFlagCollapsedThreads?: string;
|
||||
FeatureFlagPostPriority?: string;
|
||||
FeatureFlagChannelBookmarks?: string;
|
||||
FeatureFlagCustomProfileAttributes?: string;
|
||||
ForgotPasswordLink?: string;
|
||||
GfycatApiKey: string;
|
||||
GfycatApiSecret: string;
|
||||
|
|
|
|||
49
types/api/custom_profile_attributes.d.ts
vendored
Normal file
49
types/api/custom_profile_attributes.d.ts
vendored
Normal 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
36
types/api/users.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue