Mm 63903 handle undefined options (#30887)

* fixes for deleted/changed select/multiselect attributes

* add testing for prepending scheme to url

* test: add unit tests for select and multiselect with removed options

* trim trailing '/'

* update location property

* lint fixes

---------

Co-authored-by: Caleb Roseland <caleb@calebroseland.com>
Этот коммит содержится в:
Scott Bishel
2025-05-02 10:58:57 -06:00
коммит произвёл GitHub
родитель 141cfe36d1
Коммит ea4ab9aa90
6 изменённых файлов: 313 добавлений и 14 удалений

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

@@ -105,6 +105,9 @@ describe('components/ProfilePopoverCustomAttributes', () => {
url_attribute_id: urlAttribute,
select_attribute_id: selectAttribute,
},
license: {
Cloud: 'false',
},
},
users: {
profiles: {
@@ -137,6 +140,12 @@ describe('components/ProfilePopoverCustomAttributes', () => {
expect(screen.getByText('text value')).toBeInTheDocument();
expect(screen.getByText('+1 (555) 123-4567')).toBeInTheDocument();
expect(screen.getByText('https://example.com')).toBeInTheDocument();
// URL attribute should be rendered as a link
const urlLink = screen.getByRole('link', {name: 'https://example.com'});
expect(urlLink).toBeInTheDocument();
expect(urlLink).toHaveAttribute('href', 'https://example.com');
expect(screen.getByText('Option 1')).toBeInTheDocument();
});

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

@@ -47,6 +47,25 @@ const ProfilePopoverCustomAttributes = ({
if (!hasValue && visibility === 'when_set') {
return null;
} else if (visibility === 'when_set' && (attribute.type === 'multiselect' || attribute.type === 'select')) {
const attributeValue = userProfile.custom_profile_attributes[attribute.id];
// make sure attribute contains legitimate values
if (Array.isArray(attributeValue)) {
// Handle multiselect
const options = attributeValue.map((value) => {
return attribute.attrs.options?.find((o) => o.id === value);
}).filter((o) => o != null);
if (options.length === 0) {
return null;
}
} else {
// Handle single select
const option = attribute.attrs.options?.find((o) => o.id === attributeValue);
if (option === undefined) {
return null;
}
}
}
const valueType = (attribute.attrs?.value_type as UserPropertyValueType) || '';

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

@@ -91,4 +91,14 @@ describe('components/ProfilePopoverUrl', () => {
expect(container).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveTextContent(url);
});
test('should render url with ExternalLink component', () => {
renderWithContext(<ProfilePopoverUrl {...baseProps}/>);
const url = 'https://example.com';
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', url);
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});

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

@@ -6,6 +6,8 @@ import React from 'react';
import type {UserPropertyField} from '@mattermost/types/properties';
import type {UserProfile} from '@mattermost/types/users';
import ExternalLink from 'components/external_link';
type Props = {
attribute: UserPropertyField;
userProfile: UserProfile;
@@ -28,11 +30,12 @@ const ProfilePopoverUrl = ({attribute, userProfile}: Props) => {
aria-hidden='true'
data-testid='url-icon'
/>
<a
<ExternalLink
location='profile_popover_url'
href={url}
>
{url}
</a>
</ExternalLink>
</div>
);
};

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

@@ -506,6 +506,174 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
expect(props.actions.saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', '');
});
test('should handle select with removed options', 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: ''},
// opt2 has been removed from options
],
},
};
// User has a value for an option that no longer exists
const testUser = {...user, custom_profile_attributes: {field1: 'opt2'}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [selectAttribute],
user: testUser,
activeSection: '',
actions: {
...requiredProps.actions,
saveCustomProfileAttribute,
},
};
renderWithContext(<UserSettingsGeneral {...props}/>);
// Should not display any value since the option no longer exists
expect(screen.queryByText('Option 2')).not.toBeInTheDocument();
expect(await screen.findByText('Click \'Edit\' to add your custom attribute')).toBeInTheDocument();
});
test('should handle multiselect with removed options', 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: ''},
// opt2 and opt3 have been removed from options
],
},
};
// User has values for options that no longer exist
const testUser = {...user, custom_profile_attributes: {field1: ['opt1', 'opt2', 'opt3']}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [multiselectAttribute],
user: testUser,
activeSection: '',
actions: {
...requiredProps.actions,
saveCustomProfileAttribute,
},
};
renderWithContext(<UserSettingsGeneral {...props}/>);
// Should only display the option that still exists
expect(await screen.findByText('Option 1')).toBeInTheDocument();
expect(screen.queryByText('Option 2')).not.toBeInTheDocument();
expect(screen.queryByText('Option 3')).not.toBeInTheDocument();
});
test('should handle editing select with removed options', 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: ''},
// opt2 has been removed from options
],
},
};
// User has a value for an option that no longer exists
const testUser = {...user, custom_profile_attributes: {field1: 'opt2'}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [selectAttribute],
user: testUser,
activeSection: 'customAttribute_field1',
actions: {
...requiredProps.actions,
saveCustomProfileAttribute,
},
};
renderWithContext(<UserSettingsGeneral {...props}/>);
// Should show empty select since the option no longer exists
expect(await screen.findByText('Select')).toBeInTheDocument();
// Select a valid option and save
userEvent.click(screen.getByText('Select'));
userEvent.click(await screen.findByText('Option 1'));
userEvent.click(screen.getByRole('button', {name: 'Save'}));
expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'opt1');
});
test('should handle editing multiselect with removed options', 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: 'opt3', name: 'Option 3', color: ''},
// opt2 has been removed from options
],
},
};
// User has values including one for an option that no longer exists
const testUser = {...user, custom_profile_attributes: {field1: ['opt1', 'opt2']}};
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [multiselectAttribute],
user: testUser,
activeSection: 'customAttribute_field1',
actions: {
...requiredProps.actions,
saveCustomProfileAttribute,
},
};
renderWithContext(<UserSettingsGeneral {...props}/>);
// Should only show the valid option
expect(await screen.findByText('Option 1')).toBeInTheDocument();
expect(screen.queryByText('Option 2')).not.toBeInTheDocument();
// Add another valid option and save
userEvent.click(await screen.findByText('Option 1'));
userEvent.click(await screen.findByText('Option 3'));
userEvent.click(screen.getByRole('button', {name: 'Save'}));
// Should save with only the valid options
expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', ['opt1', 'opt3']);
});
test('should not show custom attribute input field when LDAP attribute is set', async () => {
const props = {
...requiredProps,
@@ -573,4 +741,78 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
expect(await screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
expect(screen.queryByRole('textbox', {name: customProfileAttribute.name})).toBeInTheDocument();
});
test('should validate URL custom attribute field value', async () => {
const urlAttribute: UserPropertyField = {
...customProfileAttribute,
attrs: {
...customProfileAttribute.attrs,
value_type: 'url',
},
};
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({});
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [urlAttribute],
user: {...user},
activeSection: 'customAttribute_field1',
actions: {
...requiredProps.actions,
saveCustomProfileAttribute,
},
};
renderWithContext(<UserSettingsGeneral {...props}/>);
userEvent.type(screen.getByRole('textbox', {name: urlAttribute.name}), 'ftp://invalid-scheme');
userEvent.click(screen.getByRole('button', {name: 'Save'}));
expect(await screen.findByText('Please enter a valid url.')).toBeInTheDocument();
expect(saveCustomProfileAttribute).not.toHaveBeenCalled();
userEvent.clear(screen.getByRole('textbox', {name: urlAttribute.name}));
userEvent.type(screen.getByRole('textbox', {name: urlAttribute.name}), 'example.com');
userEvent.click(screen.getByRole('button', {name: 'Save'}));
expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'http://example.com');
});
test('should validate email custom attribute field value', async () => {
const emailAttribute: UserPropertyField = {
...customProfileAttribute,
attrs: {
...customProfileAttribute.attrs,
value_type: 'email',
},
};
const saveCustomProfileAttribute = jest.fn().mockResolvedValue({});
const props = {
...requiredProps,
enableCustomProfileAttributes: true,
customProfileAttributeFields: [emailAttribute],
user: {...user},
activeSection: 'customAttribute_field1',
actions: {
...requiredProps.actions,
saveCustomProfileAttribute,
},
};
renderWithContext(<UserSettingsGeneral {...props}/>);
userEvent.type(screen.getByRole('textbox', {name: emailAttribute.name}), 'invalid-email');
userEvent.click(screen.getByRole('button', {name: 'Save'}));
expect(await screen.findByText('Please enter a valid email address.')).toBeInTheDocument();
expect(saveCustomProfileAttribute).not.toHaveBeenCalled();
userEvent.clear(screen.getByRole('textbox', {name: emailAttribute.name}));
userEvent.type(screen.getByRole('textbox', {name: emailAttribute.name}), 'test@example.com');
userEvent.click(screen.getByRole('button', {name: 'Save'}));
expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'test@example.com');
});
});

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

@@ -459,9 +459,17 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
}
}
if (attributeField.attrs.value_type === 'url') {
if (attributeValue !== '' && !validHttpUrl(attributeValue)) {
this.setState({clientError: formatMessage(holders.validUrl), emailError: '', serverError: ''});
return;
if (attributeValue !== '') {
const validURL = validHttpUrl(attributeValue);
if (!validURL) {
this.setState({clientError: formatMessage(holders.validUrl), emailError: '', serverError: ''});
return;
}
let validLink = validURL.toString();
if (validLink.endsWith('/')) {
validLink = validLink.slice(0, -1);
}
attributeValue = validLink;
}
}
}
@@ -1419,13 +1427,19 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
if (Array.isArray(attributeValue)) {
return attributeValue.map((value) => {
const option = attribOptions.find((o) => o.id === value);
return {label: option?.name, value: option?.id};
});
if (option) {
return {label: option?.name, value: option?.id};
}
return null;
}).filter((value) => value != null);
}
// Handle single select
const option = attribOptions.find((o) => o.id === attributeValue);
return {label: option?.name, value: option?.id};
if (option) {
return {label: option?.name, value: option?.id};
}
return '';
}
return attributeValue as string;
@@ -1535,12 +1549,14 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
let describe: JSX.Element|string = '';
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 (attributeValue) {
if (typeof attributeValue === 'string') {
describe = attributeValue;
} else if (Array.isArray(attributeValue) && attributeValue.length > 0) {
describe = <FormattedList value={attributeValue.map((attrib) => attrib?.label || null)}/>;
} else if (!Array.isArray(attributeValue) && Object.hasOwn(attributeValue, 'label')) {
describe = attributeValue.label || '';
}
}
}
if (!describe) {