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