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 Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard'; import {isKeyPressed} from 'utils/keyboard';
import {isValidUrl, parseLink, removeScheme} from 'utils/url'; import {removeScheme, validHttpUrl} from 'utils/url';
import {generateId} from 'utils/utils'; import {generateId} from 'utils/utils';
import type {GlobalState} from 'types/store'; 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; 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(); expect(await screen.getByRole('button', {name: `${customProfileAttribute.name} Edit`})).toBeInTheDocument();
props.user = {...testUser, custom_profile_attributes: {field1: 'FieldOneValue'}}; props.user = {...testUser, custom_profile_attributes: {field1: 'FieldOneValue'}};
console.log(props.user);
rerender(<UserSettingsGeneral {...props}/>); rerender(<UserSettingsGeneral {...props}/>);
expect(props.actions.getCustomProfileAttributeValues).toHaveBeenCalledTimes(1); expect(props.actions.getCustomProfileAttributeValues).toHaveBeenCalledTimes(1);
expect(await screen.findByText('FieldOneValue')).toBeInTheDocument(); 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(); 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 () => { test('submitAttribute() should have called saveCustomProfileAttribute', async () => {
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({field1: 'Updated Value'}); const saveCustomProfileAttribute = jest.fn().mockResolvedValue({field1: 'Updated Value'});
const props = { const props = {
@@ -304,4 +359,150 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
expect(saveCustomProfileAttribute).toHaveBeenCalledTimes(1); expect(saveCustomProfileAttribute).toHaveBeenCalledTimes(1);
expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'Updated Value'); 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 */ /* eslint-disable max-lines */
import React, {PureComponent} from 'react'; 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 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 {UserProfile} from '@mattermost/types/users';
import type {LogErrorOptions} from 'mattermost-redux/actions/errors'; 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 LoadingWrapper from 'components/widgets/loading/loading_wrapper';
import {AnnouncementBarMessages, AnnouncementBarTypes, AcceptedProfileImageTypes, Constants, ValidationErrors} from 'utils/constants'; import {AnnouncementBarMessages, AnnouncementBarTypes, AcceptedProfileImageTypes, Constants, ValidationErrors} from 'utils/constants';
import {validHttpUrl} from 'utils/url';
import * as Utils from 'utils/utils'; import * as Utils from 'utils/utils';
import SettingDesktopHeader from '../headers/setting_desktop_header'; import SettingDesktopHeader from '../headers/setting_desktop_header';
@@ -45,6 +48,10 @@ const holders = defineMessages({
id: 'user.settings.general.validEmail', id: 'user.settings.general.validEmail',
defaultMessage: 'Please enter a valid email address.', defaultMessage: 'Please enter a valid email address.',
}, },
validUrl: {
id: 'user.settings.general.validUrl',
defaultMessage: 'Please enter a valid url.',
},
emailMatch: { emailMatch: {
id: 'user.settings.general.emailMatch', id: 'user.settings.general.emailMatch',
defaultMessage: 'The new emails you entered do not match.', 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 = { export type Props = {
intl: IntlShape; intl: IntlShape;
user: UserProfile; user: UserProfile;
@@ -117,7 +152,7 @@ export type Props = {
sendVerificationEmail: (email: string) => Promise<ActionResult>; sendVerificationEmail: (email: string) => Promise<ActionResult>;
setDefaultProfileImage: (id: string) => void; setDefaultProfileImage: (id: string) => void;
uploadProfileImage: (id: string, file: File) => Promise<ActionResult>; 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>>>; getCustomProfileAttributeValues: (userID: string) => Promise<ActionResult<Record<string, string>>>;
}; };
requireEmailVerification?: boolean; requireEmailVerification?: boolean;
@@ -151,7 +186,7 @@ type State = {
clientError?: string | null; clientError?: string | null;
serverError?: string | {server_error_id: string; message: string}; serverError?: string | {server_error_id: string; message: string};
emailError?: string; emailError?: string;
customAttributeValues: Record<string, string>; customAttributeValues: Record<string, string | string[]>;
} }
export class UserSettingsGeneralTab extends PureComponent<Props, State> { export class UserSettingsGeneralTab extends PureComponent<Props, State> {
@@ -407,12 +442,32 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
}; };
submitAttribute = async (settings: string[]) => { submitAttribute = async (settings: string[]) => {
const {formatMessage} = this.props.intl;
const attributeID = settings[0]; const attributeID = settings[0];
const attributeValue = this.state.customAttributeValues?.[attributeID]; const attributeField = this.props.customProfileAttributeFields.find((field) => field.id === attributeID);
if (attributeValue == null) { if (attributeField === undefined) {
return; 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}); trackEvent('settings', 'user_settings_update', {field: 'customAttributeValues-' + attributeID});
this.setState({sectionIsSaving: true}); this.setState({sectionIsSaving: true});
@@ -423,7 +478,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
this.updateSection(''); this.updateSection('');
this.setState({customAttributeValues: {...this.state.customAttributeValues, ...data}}); this.setState({customAttributeValues: {...this.state.customAttributeValues, ...data}});
} else if (err) { } else if (err) {
const serverError = err; const serverError = err.message;
this.setState({serverError, emailError: '', clientError: '', sectionIsSaving: false}); 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>) => { updateAttribute = (e: React.ChangeEvent<HTMLInputElement>) => {
const attributeValues = Object.assign({}, this.state.customAttributeValues); const attributeValues = Object.assign({}, this.state.customAttributeValues);
const id = e.target.id.substring(e.target.id.indexOf('_') + 1); const id = e.target.id.substring(e.target.id.indexOf('_') + 1);
@@ -1320,7 +1396,8 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
}; };
createCustomAttributeSection = () => { createCustomAttributeSection = () => {
if (!this.props.enableCustomProfileAttributes || this.props.customProfileAttributeFields == null) { const {formatMessage} = this.props.intl;
if (this.props.customProfileAttributeFields == null) {
return <></>; return <></>;
} }
@@ -1329,6 +1406,31 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
const active = this.props.activeSection === sectionName; const active = this.props.activeSection === sectionName;
let max = null; 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) { if (active) {
const inputs = []; const inputs = [];
@@ -1339,29 +1441,58 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
attributeLabel = ''; attributeLabel = '';
} }
inputs.push( if (attribute.type === 'select' || attribute.type === 'multiselect') {
<div const attribOptions: PropertyFieldOption[] = attribute.attrs!.options as PropertyFieldOption[];
key={sectionName} const opts = attribOptions.map((o) => {
className='form-group' return {label: o.name, value: o.id} as SelectOption;
> });
<label className='col-sm-5 control-label'>{attributeLabel}</label> inputs.push(
<div className='col-sm-7'> <ReactSelect
<input isMulti={attribute.type === 'multiselect' ? true : undefined}
id={sectionName} key={sectionName}
autoFocus={true} id={'customProfileAttribute_' + attribute.id}
className='form-control' inputId={'customProfileAttribute_' + attribute.id + '_input'}
type='text' className='react-select inlineSelect'
onChange={this.updateAttribute} classNamePrefix='react-select'
value={this.state.customAttributeValues[attribute.id] || ''} options={opts}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH} isClearable={true}
autoCapitalize='off' isSearchable={false}
onFocus={Utils.moveCursorToEnd} isDisabled={false}
aria-label={attribute.name} placeholder={formatMessage({
/> id: 'user.settings.general.select',
</div> defaultMessage: 'Select',
</div>, })}
); 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 = ( const extraInfo = (
<span> <span>
<FormattedMessage <FormattedMessage
@@ -1386,10 +1517,17 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
); );
} }
let describe: JSX.Element|string = ''; let describe: JSX.Element|string = '';
const attributeValue = this.props.user.custom_profile_attributes?.[attribute.id]; if (this.props.user.custom_profile_attributes?.[attribute.id]) {
if (attributeValue) { const attributeValue = getDisplayValue(this.props.user.custom_profile_attributes?.[attribute.id]);
describe = attributeValue; if (typeof attributeValue === 'string') {
} else { 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 = ( describe = (
<FormattedMessage <FormattedMessage
id='user.settings.general.emptyAttribute' id='user.settings.general.emptyAttribute'

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

@@ -5731,6 +5731,7 @@
"user.settings.general.position": "Position", "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.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.profilePicture": "Profile Picture",
"user.settings.general.select": "Select",
"user.settings.general.sendAgain": "Send again", "user.settings.general.sendAgain": "Send again",
"user.settings.general.sending": "Sending", "user.settings.general.sending": "Sending",
"user.settings.general.uploadImage": "Click 'Edit' to upload an image.", "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.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.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.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.change": "Change interface language",
"user.settings.languages.dropdown.arialabel": "Dropdown selector to change the 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.", "user.settings.languages.promote1": "Select which language Mattermost displays in the user interface.",

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

@@ -1710,7 +1710,7 @@ describe('Actions.Users', () => {
123: 'NewValue', 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!; const data = response.data!;
expect(data).toEqual({123: 'NewValue'}); 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) => { return async (dispatch) => {
try { try {
const values = {[attributeID]: attributeValue.trim()}; const values = {[attributeID]: attributeValue || ''};
const data = await Client4.updateCustomProfileAttributeValues(values); const data = await Client4.updateCustomProfileAttributeValues(values);
return {data}; return {data};
} catch (error) { } catch (error) {

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

@@ -354,3 +354,20 @@ export function parseLink(href: string, defaultSecure = location.protocol === 'h
return outHref; 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>) => { updateCustomProfileAttributeValues = (attributeValues: Record<string, string | string[]>) => {
return this.doFetch<Record<string, string>>( return this.doFetch<Record<string, string | string[]>>(
`${this.getCustomProfileAttributeValuesRoute()}`, `${this.getCustomProfileAttributeValuesRoute()}`,
{method: 'PATCH', body: JSON.stringify(attributeValues)}, {method: 'PATCH', body: JSON.stringify(attributeValues)},
); );

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

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

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

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