MM-62699 Add additional property types to user profile form (#30317)

* fix for testing with server

* revert feature flag

* user settings to handle other property types

* lint fixes

* update css so container will grow with several multiselect values

* change CPASelectOption to PropertyFieldOption

* lint fix

* lint and style fix

* review fixes

* fixes for change in UserPropertyFields

* update url validation

* update url validation

* update unit test

* Update webapp/channels/src/components/user_settings/general/user_settings_general.tsx

Co-authored-by: Caleb Roseland <caleb@calebroseland.com>

* fix: Handle missing options in user settings attribute rendering

* update handling of single/multi values

* remove unused file

* partially update properties

* make attrs property required

* revert change to user_properties_utils.ts

* fix bad merge

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Caleb Roseland <caleb@calebroseland.com>
Этот коммит содержится в:
Scott Bishel
2025-04-02 15:15:14 -06:00
коммит произвёл GitHub
родитель 65343f84a7
Коммит c417ac1b57
10 изменённых файлов: 403 добавлений и 60 удалений

Просмотреть файл

@@ -27,7 +27,7 @@ import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {isValidUrl, parseLink, removeScheme} from 'utils/url';
import {removeScheme, validHttpUrl} from 'utils/url';
import {generateId} from 'utils/utils';
import type {GlobalState} from 'types/store';
@@ -693,20 +693,3 @@ export const useBookmarkLinkValidation = (link: string, onValidated: (validatedL
return [error, {loading: Boolean(loading), suppressed}] as const;
};
export const validHttpUrl = (input: string) => {
const val = parseLink(input);
if (!val || !isValidUrl(val)) {
return null;
}
let url;
try {
url = new URL(val);
} catch {
return null;
}
return url;
};

Просмотреть файл

@@ -263,7 +263,6 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
expect(await screen.getByRole('button', {name: `${customProfileAttribute.name} Edit`})).toBeInTheDocument();
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();
@@ -282,6 +281,62 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
expect(await screen.getByRole('textbox', {name: `${customProfileAttribute.name}`})).toBeInTheDocument();
});
test('should show select Custom Attribute Field with value', async () => {
const selectAttribute: UserPropertyField = {
...customProfileAttribute,
type: 'select',
attrs: {
value_type: '',
visibility: 'when_set',
sort_order: 0,
options: [
{id: 'opt1', name: 'Option 1', color: ''},
{id: 'opt2', name: 'Option 2', color: ''},
],
},
};
const testUser = {...user, custom_profile_attributes: {field1: 'opt1'}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [selectAttribute],
user: testUser,
activeSection: 'customAttribute_field1',
};
renderWithContext(<UserSettingsGeneral {...props}/>);
expect(await screen.getByText('Option 1')).toBeInTheDocument();
});
test('should show multiselect Custom Attribute Field with value', async () => {
const multiselectAttribute: UserPropertyField = {
...customProfileAttribute,
type: 'multiselect',
attrs: {
value_type: '',
visibility: 'when_set',
sort_order: 0,
options: [
{id: 'opt1', name: 'Option 1', color: ''},
{id: 'opt2', name: 'Option 2', color: ''},
],
},
};
const testUser = {...user, custom_profile_attributes: {field1: 'opt2'}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [multiselectAttribute],
user: testUser,
activeSection: 'customAttribute_field1',
};
renderWithContext(<UserSettingsGeneral {...props}/>);
expect(await screen.getByText('Option 2')).toBeInTheDocument();
});
test('submitAttribute() should have called saveCustomProfileAttribute', async () => {
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({field1: 'Updated Value'});
const props = {
@@ -304,4 +359,150 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
expect(saveCustomProfileAttribute).toHaveBeenCalledTimes(1);
expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'Updated Value');
});
test('submitAttribute() should handle server error', async () => {
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({error: {message: 'Server Error'}});
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
actions: {...requiredProps.actions, saveCustomProfileAttribute},
customProfileAttributeFields: [customProfileAttribute],
user: {...user},
activeSection: 'customAttribute_field1',
};
renderWithContext(<UserSettingsGeneral {...props}/>);
userEvent.clear(screen.getByRole('textbox', {name: `${customProfileAttribute.name}`}));
userEvent.type(screen.getByRole('textbox', {name: `${customProfileAttribute.name}`}), 'Updated Value');
userEvent.click(screen.getByRole('button', {name: 'Save'}));
expect(await screen.findByText('Server Error')).toBeInTheDocument();
});
test('updateSelectAttribute() should handle single select changes', async () => {
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({});
const selectAttribute: UserPropertyField = {
...customProfileAttribute,
type: 'select',
attrs: {
value_type: '',
visibility: 'when_set',
sort_order: 0,
options: [
{id: 'opt1', name: 'Option 1', color: ''},
{id: 'opt2', name: 'Option 2', color: ''},
],
},
};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [selectAttribute],
user: {...user},
activeSection: 'customAttribute_field1',
actions: {
...requiredProps.actions,
saveCustomProfileAttribute,
},
};
renderWithContext(<UserSettingsGeneral {...props}/>);
const select = await screen.findByText('Select');
userEvent.click(select);
userEvent.click(await screen.findByText('Option 2'));
const saveButton = screen.getByRole('button', {name: 'Save'});
userEvent.click(saveButton);
expect(props.actions.saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'opt2');
});
test('updateSelectAttribute() should handle multi-select changes', async () => {
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({});
const multiselectAttribute: UserPropertyField = {
...customProfileAttribute,
type: 'multiselect',
attrs: {
value_type: '',
visibility: 'when_set',
sort_order: 0,
options: [
{id: 'opt1', name: 'Option 1', color: ''},
{id: 'opt2', name: 'Option 2', color: ''},
],
},
};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [multiselectAttribute],
user: {...user},
activeSection: 'customAttribute_field1',
actions: {
...requiredProps.actions,
saveCustomProfileAttribute,
},
};
renderWithContext(<UserSettingsGeneral {...props}/>);
const select = await screen.findByText('Select');
userEvent.click(select);
userEvent.click(await screen.findByText('Option 1'));
userEvent.click(await screen.findByText('Option 1'));
userEvent.click(await screen.findByText('Option 2'));
const saveButton = screen.getByRole('button', {name: 'Save'});
userEvent.click(saveButton);
expect(props.actions.saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', ['opt1', 'opt2']);
});
test('updateSelectAttribute() should handle clearing selections', async () => {
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({});
const selectAttribute: UserPropertyField = {
...customProfileAttribute,
type: 'select',
attrs: {
value_type: '',
visibility: 'when_set',
sort_order: 0,
options: [
{id: 'opt1', name: 'Option 1', color: ''},
{id: 'opt2', name: 'Option 2', color: ''},
],
},
};
const testUser = {...user, custom_profile_attributes: {field1: 'opt1'}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [selectAttribute],
user: testUser,
activeSection: 'customAttribute_field1',
actions: {
...requiredProps.actions,
saveCustomProfileAttribute,
},
};
const {container} = renderWithContext(<UserSettingsGeneral {...props}/>);
const clearIndicator = container.querySelector('.react-select__clear-indicator');
expect(clearIndicator).toBeInTheDocument();
userEvent.click(clearIndicator!);
await screen.findByText('Select');
const saveButton = screen.getByRole('button', {name: 'Save'});
userEvent.click(saveButton);
expect(props.actions.saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', '');
});
});

Просмотреть файл

@@ -4,10 +4,12 @@
/* eslint-disable max-lines */
import React, {PureComponent} from 'react';
import {defineMessage, defineMessages, FormattedDate, FormattedMessage, injectIntl} from 'react-intl';
import {defineMessage, defineMessages, FormattedDate, FormattedMessage, FormattedList, injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import ReactSelect from 'react-select';
import type {OnChangeValue, ActionMeta, StylesConfig} from 'react-select';
import type {UserPropertyField} from '@mattermost/types/properties';
import type {UserPropertyField, PropertyFieldOption} from '@mattermost/types/properties';
import type {UserProfile} from '@mattermost/types/users';
import type {LogErrorOptions} from 'mattermost-redux/actions/errors';
@@ -23,6 +25,7 @@ import SettingPicture from 'components/setting_picture';
import LoadingWrapper from 'components/widgets/loading/loading_wrapper';
import {AnnouncementBarMessages, AnnouncementBarTypes, AcceptedProfileImageTypes, Constants, ValidationErrors} from 'utils/constants';
import {validHttpUrl} from 'utils/url';
import * as Utils from 'utils/utils';
import SettingDesktopHeader from '../headers/setting_desktop_header';
@@ -45,6 +48,10 @@ const holders = defineMessages({
id: 'user.settings.general.validEmail',
defaultMessage: 'Please enter a valid email address.',
},
validUrl: {
id: 'user.settings.general.validUrl',
defaultMessage: 'Please enter a valid url.',
},
emailMatch: {
id: 'user.settings.general.emailMatch',
defaultMessage: 'The new emails you entered do not match.',
@@ -99,6 +106,34 @@ const holders = defineMessages({
},
});
export type SelectOption = {
value: string;
label: string;
};
const selectStyles: StylesConfig<SelectOption, true> = {
valueContainer: (baseStyles) => ({
...baseStyles,
height: 'auto',
minHeight: '38px',
flexWrap: 'wrap',
whiteSpace: 'normal',
}),
multiValue: (baseStyles) => ({
...baseStyles,
margin: '2px',
}),
control: (baseStyles) => ({
...baseStyles,
height: 'auto',
minHeight: '38px',
}),
multiValueLabel: (baseStyles) => ({
...baseStyles,
padding: '2px 6px',
}),
};
export type Props = {
intl: IntlShape;
user: UserProfile;
@@ -117,7 +152,7 @@ export type Props = {
sendVerificationEmail: (email: string) => Promise<ActionResult>;
setDefaultProfileImage: (id: string) => void;
uploadProfileImage: (id: string, file: File) => Promise<ActionResult>;
saveCustomProfileAttribute: (userID: string, attributeID: string, attributeValue: string) => Promise<ActionResult<Record<string, string>>>;
saveCustomProfileAttribute: (userID: string, attributeID: string, attributeValue: string | string[]) => Promise<ActionResult<Record<string, string | string[]>>>;
getCustomProfileAttributeValues: (userID: string) => Promise<ActionResult<Record<string, string>>>;
};
requireEmailVerification?: boolean;
@@ -151,7 +186,7 @@ type State = {
clientError?: string | null;
serverError?: string | {server_error_id: string; message: string};
emailError?: string;
customAttributeValues: Record<string, string>;
customAttributeValues: Record<string, string | string[]>;
}
export class UserSettingsGeneralTab extends PureComponent<Props, State> {
@@ -407,12 +442,32 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
};
submitAttribute = async (settings: string[]) => {
const {formatMessage} = this.props.intl;
const attributeID = settings[0];
const attributeValue = this.state.customAttributeValues?.[attributeID];
if (attributeValue == null) {
const attributeField = this.props.customProfileAttributeFields.find((field) => field.id === attributeID);
if (attributeField === undefined) {
return;
}
let attributeValue: string | string[] = this.state.customAttributeValues?.[attributeID];
if (typeof attributeValue === 'string' && attributeField.attrs && attributeField.attrs.value_type) {
if (attributeField.attrs.value_type === 'email') {
if (attributeValue !== '' && !isEmail(attributeValue)) {
this.setState({clientError: formatMessage(holders.validEmail), emailError: '', serverError: ''});
return;
}
}
if (attributeField.attrs.value_type === 'url') {
if (attributeValue !== '' && !validHttpUrl(attributeValue)) {
this.setState({clientError: formatMessage(holders.validUrl), emailError: '', serverError: ''});
return;
}
}
}
if (attributeField.type === 'multiselect' && !attributeValue) {
attributeValue = [];
}
trackEvent('settings', 'user_settings_update', {field: 'customAttributeValues-' + attributeID});
this.setState({sectionIsSaving: true});
@@ -423,7 +478,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
this.updateSection('');
this.setState({customAttributeValues: {...this.state.customAttributeValues, ...data}});
} else if (err) {
const serverError = err;
const serverError = err.message;
this.setState({serverError, emailError: '', clientError: '', sectionIsSaving: false});
}
});
@@ -472,6 +527,27 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
}
};
updateSelectAttribute = (selectedOption: OnChangeValue<SelectOption, boolean>, action: ActionMeta<SelectOption>, fieldID: string) => {
const attributeValues = {...this.state.customAttributeValues};
if (!selectedOption) {
attributeValues[fieldID] = '';
} else if (Array.isArray(selectedOption)) {
// Handle multi-select
attributeValues[fieldID] = selectedOption.
filter((option): option is SelectOption =>
Boolean(option && Object.hasOwn(option, 'value'))).
map((option) => option.value);
} else if ('value' in selectedOption) {
// Handle single select
attributeValues[fieldID] = selectedOption.value || '';
} else {
attributeValues[fieldID] = '';
}
this.setState({customAttributeValues: attributeValues});
};
updateAttribute = (e: React.ChangeEvent<HTMLInputElement>) => {
const attributeValues = Object.assign({}, this.state.customAttributeValues);
const id = e.target.id.substring(e.target.id.indexOf('_') + 1);
@@ -1320,7 +1396,8 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
};
createCustomAttributeSection = () => {
if (!this.props.enableCustomProfileAttributes || this.props.customProfileAttributeFields == null) {
const {formatMessage} = this.props.intl;
if (this.props.customProfileAttributeFields == null) {
return <></>;
}
@@ -1329,6 +1406,31 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
const active = this.props.activeSection === sectionName;
let max = null;
const getDisplayValue = (attributeValue: string | string[]) => {
if (!attributeValue || (!Array.isArray(attributeValue) && !attributeValue.length)) {
return '';
}
if (attribute.type === 'select' || attribute.type === 'multiselect') {
const attribOptions = attribute.attrs.options;
if (!attribOptions) {
return '';
}
if (Array.isArray(attributeValue)) {
return attributeValue.map((value) => {
const option = attribOptions.find((o) => o.id === value);
return {label: option?.name, value: option?.id};
});
}
// Handle single select
const option = attribOptions.find((o) => o.id === attributeValue);
return {label: option?.name, value: option?.id};
}
return attributeValue as string;
};
if (active) {
const inputs = [];
@@ -1339,29 +1441,58 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
attributeLabel = '';
}
inputs.push(
<div
key={sectionName}
className='form-group'
>
<label className='col-sm-5 control-label'>{attributeLabel}</label>
<div className='col-sm-7'>
<input
id={sectionName}
autoFocus={true}
className='form-control'
type='text'
onChange={this.updateAttribute}
value={this.state.customAttributeValues[attribute.id] || ''}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
autoCapitalize='off'
onFocus={Utils.moveCursorToEnd}
aria-label={attribute.name}
/>
</div>
</div>,
);
if (attribute.type === 'select' || attribute.type === 'multiselect') {
const attribOptions: PropertyFieldOption[] = attribute.attrs!.options as PropertyFieldOption[];
const opts = attribOptions.map((o) => {
return {label: o.name, value: o.id} as SelectOption;
});
inputs.push(
<ReactSelect
isMulti={attribute.type === 'multiselect' ? true : undefined}
key={sectionName}
id={'customProfileAttribute_' + attribute.id}
inputId={'customProfileAttribute_' + attribute.id + '_input'}
className='react-select inlineSelect'
classNamePrefix='react-select'
options={opts}
isClearable={true}
isSearchable={false}
isDisabled={false}
placeholder={formatMessage({
id: 'user.settings.general.select',
defaultMessage: 'Select',
})}
components={{IndicatorSeparator: null}}
styles={selectStyles}
value={getDisplayValue(this.state.customAttributeValues[attribute.id]) as SelectOption}
onChange={(v, a) => this.updateSelectAttribute(v, a, attribute.id)}
/>,
);
} else {
const inputType = attribute.type as string;
inputs.push(
<div
key={sectionName}
className='form-group'
>
<label className='col-sm-5 control-label'>{attributeLabel}</label>
<div className='col-sm-7'>
<input
id={sectionName}
autoFocus={true}
className='form-control'
type={inputType}
onChange={this.updateAttribute}
value={getDisplayValue(this.state.customAttributeValues[attribute.id]) as string}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
autoCapitalize='off'
onFocus={Utils.moveCursorToEnd}
aria-label={attribute.name}
/>
</div>
</div>,
);
}
const extraInfo = (
<span>
<FormattedMessage
@@ -1386,10 +1517,17 @@ 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 {
if (this.props.user.custom_profile_attributes?.[attribute.id]) {
const attributeValue = getDisplayValue(this.props.user.custom_profile_attributes?.[attribute.id]);
if (typeof attributeValue === 'string') {
describe = attributeValue;
} else if (Array.isArray(attributeValue) && attributeValue.length > 0) {
describe = <FormattedList value={attributeValue.map((attrib) => attrib.label)}/>;
} else if (!Array.isArray(attributeValue) && Object.hasOwn(attributeValue, 'label')) {
describe = attributeValue.label || '';
}
}
if (!describe) {
describe = (
<FormattedMessage
id='user.settings.general.emptyAttribute'

Просмотреть файл

@@ -5731,6 +5731,7 @@
"user.settings.general.position": "Position",
"user.settings.general.positionExtra": "Use Position for your role or job title. This will be shown in your profile popover.",
"user.settings.general.profilePicture": "Profile Picture",
"user.settings.general.select": "Select",
"user.settings.general.sendAgain": "Send again",
"user.settings.general.sending": "Sending",
"user.settings.general.uploadImage": "Click 'Edit' to upload an image.",
@@ -5741,6 +5742,7 @@
"user.settings.general.usernameRestrictions": "Username must begin with a letter, and contain between {min} to {max} lowercase characters made up of numbers, letters, and the symbols '.', '-', and '_'.",
"user.settings.general.validEmail": "Please enter a valid email address",
"user.settings.general.validImage": "Only BMP, JPG or PNG images may be used for profile pictures",
"user.settings.general.validUrl": "Please enter a valid url.",
"user.settings.languages.change": "Change interface language",
"user.settings.languages.dropdown.arialabel": "Dropdown selector to change the interface language",
"user.settings.languages.promote1": "Select which language Mattermost displays in the user interface.",

Просмотреть файл

@@ -1710,7 +1710,7 @@ describe('Actions.Users', () => {
123: 'NewValue',
});
const response = await store.dispatch(Actions.saveCustomProfileAttribute(currentUser.id, '123', ' NewValue '));
const response = await store.dispatch(Actions.saveCustomProfileAttribute(currentUser.id, '123', 'NewValue'));
const data = response.data!;
expect(data).toEqual({123: 'NewValue'});
});

Просмотреть файл

@@ -988,10 +988,10 @@ export function updateMe(user: Partial<UserProfile>): ActionFuncAsync<UserProfil
};
}
export function saveCustomProfileAttribute(userID: string, attributeID: string, attributeValue: string): ActionFuncAsync<Record<string, string>> {
export function saveCustomProfileAttribute(userID: string, attributeID: string, attributeValue: string | string[]): ActionFuncAsync<Record<string, string | string[]>> {
return async (dispatch) => {
try {
const values = {[attributeID]: attributeValue.trim()};
const values = {[attributeID]: attributeValue || ''};
const data = await Client4.updateCustomProfileAttributeValues(values);
return {data};
} catch (error) {

Просмотреть файл

@@ -354,3 +354,20 @@ export function parseLink(href: string, defaultSecure = location.protocol === 'h
return outHref;
}
export const validHttpUrl = (input: string) => {
const val = parseLink(input);
if (!val || !isValidUrl(val)) {
return null;
}
let url;
try {
url = new URL(val);
} catch {
return null;
}
return url;
};

Просмотреть файл

@@ -2106,8 +2106,8 @@ export default class Client4 {
);
};
updateCustomProfileAttributeValues = (attributeValues: Record<string, string>) => {
return this.doFetch<Record<string, string>>(
updateCustomProfileAttributeValues = (attributeValues: Record<string, string | string[]>) => {
return this.doFetch<Record<string, string | string[]>>(
`${this.getCustomProfileAttributeValuesRoute()}`,
{method: 'PATCH', body: JSON.stringify(attributeValues)},
);

Просмотреть файл

@@ -34,7 +34,9 @@ export type PropertyValue<T> = {
delete_at: number;
}
export type UserPropertyFieldType = 'text' | 'select' | 'multiselect';
export type UserPropertyFieldGroupID = 'custom_profile_attributes';
export type UserPropertyValueType = 'phone' | 'url' | '';
export type FieldVisibility = 'always' | 'hidden' | 'when_set';
export type FieldValueType =

Просмотреть файл

@@ -61,7 +61,7 @@ export type UserProfile = {
remote_id?: string;
status?: string;
failed_attempts?: number;
custom_profile_attributes?: Record<string, string>;
custom_profile_attributes?: Record<string, string | string[]>;
};
export type UserProfileWithLastViewAt = UserProfile & {