diff --git a/server/channels/app/migrations.go b/server/channels/app/migrations.go index c7d1aa78127..a23fa4a3cb4 100644 --- a/server/channels/app/migrations.go +++ b/server/channels/app/migrations.go @@ -640,7 +640,7 @@ func (s *Server) doSetupContentFlaggingProperties() error { contentFlaggingPropertyNameStatus: { GroupID: group.ID, Name: contentFlaggingPropertyNameStatus, - Type: model.PropertyFieldTypeText, + Type: model.PropertyFieldTypeSelect, }, contentFlaggingPropertyNameReportingUserID: { GroupID: group.ID, @@ -650,7 +650,7 @@ func (s *Server) doSetupContentFlaggingProperties() error { contentFlaggingPropertyNameReportingReason: { GroupID: group.ID, Name: contentFlaggingPropertyNameReportingReason, - Type: model.PropertyFieldTypeText, + Type: model.PropertyFieldTypeSelect, }, contentFlaggingPropertyNameReportingComment: { GroupID: group.ID, diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.test.tsx index 8255018fa9d..dcbb7a06cda 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.test.tsx +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.test.tsx @@ -14,15 +14,16 @@ import ContentFlaggingContentReviewers from './content_reviewers'; // Mock the UserMultiSelector component jest.mock('../../content_flagging/user_multiselector/user_multiselector', () => ({ - UserMultiSelector: ({id, initialValue, onChange}: {id: string; initialValue: string[]; onChange: (userIds: string[]) => void}) => ( + __esModule: true, + UserSelector: ({id, multiSelectInitialValue, multiSelectOnChange}: {id: string; multiSelectInitialValue: string[]; multiSelectOnChange: (userIds: string[]) => void}) => (
- {initialValue.join(',')} + {multiSelectInitialValue.join(',')}
), })); diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.tsx index dbd4b4c1ebc..1f1354ad407 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.tsx +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.tsx @@ -16,7 +16,7 @@ import { SectionHeader, } from 'components/admin_console/system_properties/controls'; -import {UserMultiSelector} from '../../content_flagging/user_multiselector/user_multiselector'; +import {UserSelector} from '../../content_flagging/user_multiselector/user_multiselector'; import type {SystemConsoleCustomSettingsComponentProps} from '../../schema_admin_settings'; import './content_reviewers.scss'; @@ -148,10 +148,11 @@ export default function ContentFlaggingContentReviewers(props: SystemConsoleCust
-
diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.test.tsx index e8526879e18..a37ce332c6b 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.test.tsx +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.test.tsx @@ -18,21 +18,6 @@ jest.mock('mattermost-redux/actions/teams', () => ({ searchTeams: jest.fn(), })); -jest.mock('../../user_multiselector/user_multiselector', () => ({ - UserMultiSelector: ({id, initialValue, onChange}: {id: string; initialValue: string[]; onChange: (ids: string[]) => void}) => ( -
- {`Selected: {${initialValue.join(', ')}`} - -
- ), -})); - -jest.mock('components/widgets/team_icon/team_icon', () => ({ - TeamIcon: ({content}: {content: string}) =>
{content}
, -})); - const mockSearchTeams = jest.mocked(searchTeams); describe('TeamReviewersSection', () => { @@ -265,40 +250,6 @@ describe('TeamReviewersSection', () => { }); }); - test('should handle reviewer selection changes', async () => { - const onChange = jest.fn(); - renderWithContext( - , - ); - - await waitFor(() => { - expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10}); - }); - - await waitFor(() => { - const teamNameCells = screen.getAllByTestId('teamName'); - expect(teamNameCells).toHaveLength(2); - expect(teamNameCells[0]).toBeVisible(); - expect(teamNameCells[0]).toHaveTextContent('Team One'); - - expect(teamNameCells[1]).toBeVisible(); - expect(teamNameCells[1]).toHaveTextContent('Team Two'); - }); - - const changeReviewersButton = screen.getAllByText('Change Reviewers')[0]; - fireEvent.click(changeReviewersButton); - - expect(onChange).toHaveBeenCalledWith({ - team1: { - Enabled: false, - ReviewerIds: ['user1', 'user2'], - }, - }); - }); - test('should display existing reviewer settings', async () => { const teamReviewersSetting = { team1: { @@ -354,17 +305,6 @@ describe('TeamReviewersSection', () => { }); test('should reset page to 0 when searching', async () => { - // mockSearchTeams. - // mockReturnValueOnce({ - // data: {teams: mockTeams, total_count: 20}, - // } as never). - // mockResolvedValueOnce({ - // data: {teams: mockTeams, total_count: 20}, - // } as never). - // mockResolvedValueOnce({ - // data: {teams: mockTeams, total_count: 5}, - // } as never); - mockSearchTeams.mockReturnValueOnce(async () => ({ data: {teams: mockTeams, total_count: 20}, })). @@ -480,10 +420,6 @@ describe('TeamReviewersSection', () => { expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10}); }); - await waitFor(() => { - expect(screen.getAllByText('Team One')).toHaveLength(4); - }); - const updatedToggle = screen.getAllByRole('button', {name: /enable or disable content reviewers for this team/i})[0]; // Second click - disable diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx index 57fe019a2d4..0ce5b572f67 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx @@ -16,7 +16,7 @@ import {TeamIcon} from 'components/widgets/team_icon/team_icon'; import * as Utils from 'utils/utils'; -import {UserMultiSelector} from '../../user_multiselector/user_multiselector'; +import {UserSelector} from '../../user_multiselector/user_multiselector'; import './team_reviewers_section.scss'; @@ -141,10 +141,11 @@ export default function TeamReviewers({teamReviewersSetting, onChange}: Props): ), reviewers: ( - ), enabled: ( diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselect.scss b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselect.scss index 5b4ac977d0d..b67d23f9466 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselect.scss +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselect.scss @@ -13,4 +13,10 @@ display: flex; gap: 8px; } + + &.singleSelect { + .UserMultiSelector__value-container { + display: flex; + } + } } diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselector.tsx b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselector.tsx index 9ec451d7a47..d3a32e0ab65 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselector.tsx +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselector.tsx @@ -5,7 +5,7 @@ import classNames from 'classnames'; import React, {type ReactElement, useCallback, useEffect, useMemo, useRef} from 'react'; import {useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; -import type {MultiValue} from 'react-select'; +import type {MultiValue, SingleValue} from 'react-select'; import AsyncSelect from 'react-select/async'; import type {UserProfile} from '@mattermost/types/users'; @@ -16,9 +16,9 @@ import {getUsersByIDs} from 'mattermost-redux/selectors/entities/users'; import type {GlobalState} from 'types/store'; -import {UserProfilePill} from './user_profile_pill'; +import {MultiUserProfilePill, SingleUserProfilePill} from './user_profile_pill'; -import {UserOptionComponent} from '../../content_flagging/user_multiselector/user_profile_option'; +import {MultiUserOptionComponent, SingleUserOptionComponent} from '../../content_flagging/user_multiselector/user_profile_option'; import {LoadingIndicator} from '../../system_users/system_users_filters_popover/system_users_filter_team'; import './user_multiselect.scss'; @@ -29,29 +29,51 @@ export type AutocompleteOptionType = { raw?: T; } -type Props = { - id: string; - className?: string; - onChange: (selectedUserIds: string[]) => void; - initialValue?: string[]; - hasError?: boolean; +const BASE_SELECT_COMPONENTS = { + LoadingIndicator, + DropdownIndicator: () => null, + IndicatorSeparator: () => null, +}; + +type MultiSelectProps = { + multiSelectOnChange?: (selectedUserIds: string[]) => void; + multiSelectInitialValue?: string[]; } -export function UserMultiSelector({id, className, onChange, initialValue, hasError}: Props) { +type SingleSelectProps = { + singleSelectOnChange?: (selectedUserIds: string) => void; + singleSelectInitialValue?: string; +} + +type Props = MultiSelectProps & SingleSelectProps & { + id: string; + isMulti: boolean; + className?: string; + hasError?: boolean; + placeholder?: React.ReactNode; + showDropdownIndicator?: boolean; +}; + +export function UserSelector({id, isMulti, className, multiSelectOnChange, multiSelectInitialValue, singleSelectOnChange, singleSelectInitialValue, hasError, placeholder, showDropdownIndicator}: Props) { const dispatch = useDispatch(); const {formatMessage} = useIntl(); const initialDataLoaded = useRef(false); + const initialValue = useMemo(() => { + return isMulti ? multiSelectInitialValue : [singleSelectInitialValue || '']; + }, [isMulti, multiSelectInitialValue, singleSelectInitialValue]); + useEffect(() => { const fetchInitialData = async () => { - await dispatch(getMissingProfilesByIds(initialValue || [])); + const param = isMulti ? multiSelectInitialValue : [singleSelectInitialValue || '']; + await dispatch(getMissingProfilesByIds(param || [])); initialDataLoaded.current = true; }; - if (initialValue && !initialDataLoaded.current) { + if (Boolean(initialValue) && !initialDataLoaded.current) { fetchInitialData(); } - }, [dispatch, initialValue]); + }, [dispatch, initialValue, isMulti, multiSelectInitialValue, singleSelectInitialValue]); const initialUsers = useSelector((state: GlobalState) => getUsersByIDs(state, initialValue || [])); const selectInitialValue = initialUsers. @@ -64,7 +86,7 @@ export function UserMultiSelector({id, className, onChange, initialValue, hasErr const userLoadingMessage = useCallback(() => formatMessage({id: 'admin.userMultiSelector.loading', defaultMessage: 'Loading users'}), [formatMessage]); const noUsersMessage = useCallback(() => formatMessage({id: 'admin.userMultiSelector.noUsers', defaultMessage: 'No users found'}), [formatMessage]); - const placeholder = formatMessage({id: 'admin.userMultiSelector.placeholder', defaultMessage: 'Start typing to search for users...'}); + const defaultPlaceholder = formatMessage({id: 'admin.userMultiSelector.placeholder', defaultMessage: 'Start typing to search for users...'}); const searchUsers = useMemo(() => debounce(async (searchTerm: string, callback) => { try { @@ -89,35 +111,87 @@ export function UserMultiSelector({id, className, onChange, initialValue, hasErr } }, 200), [dispatch]); - function handleOnChange(value: MultiValue>) { + const multiSelectHandleOnChange = useCallback((value: MultiValue>) => { const selectedUserIds = value.map((option) => option.value); - onChange?.(selectedUserIds); + multiSelectOnChange?.(selectedUserIds); + }, [multiSelectOnChange]); + + const singleSelectHandleOnChange = useCallback((value: SingleValue>) => { + const selectedUserIds = value?.value || ''; + singleSelectOnChange?.(selectedUserIds); + }, [singleSelectOnChange]); + + const multiSelectComponents = useMemo(() => { + const componentObj = { + ...BASE_SELECT_COMPONENTS, + Option: MultiUserOptionComponent, + MultiValue: MultiUserProfilePill, + }; + + if (showDropdownIndicator) { + // @ts-expect-error doing this any other way runs into TypeScript nightmares due to very complex ReactSelect types + delete componentObj.DropdownIndicator; + } + + return componentObj; + }, [showDropdownIndicator]); + + const singleSelectComponents = useMemo(() => { + const componentObj = { + ...BASE_SELECT_COMPONENTS, + Option: SingleUserOptionComponent, + SingleValue: SingleUserProfilePill, + }; + + if (showDropdownIndicator) { + // @ts-expect-error doing this any other way runs into TypeScript nightmares due to very complex ReactSelect types + delete componentObj.DropdownIndicator; + } + + return componentObj; + }, [showDropdownIndicator]); + + const baseProps = useMemo(() => { + return { + id, + inputId: `${id}_input`, + classNamePrefix: 'UserMultiSelector', + className: classNames('Input Input__focus', className, {error: hasError}), + isClearable: false, + hideSelectedOptions: true, + cacheOptions: true, + placeholder: placeholder || defaultPlaceholder, + loadingMessage: userLoadingMessage, + noOptionsMessage: noUsersMessage, + loadOptions: searchUsers, + menuPortalTarget: document.body, + }; + }, [className, defaultPlaceholder, hasError, id, noUsersMessage, placeholder, searchUsers, userLoadingMessage]); + + const containerClassName = classNames('UserMultiSelector', {multiSelect: isMulti, singleSelect: !isMulti}); + + if (isMulti) { + return ( +
+ +
+ ); } return ( -
+
null, - IndicatorSeparator: () => null, - Option: UserOptionComponent, - MultiValue: UserProfilePill, - }} + {...baseProps} + isMulti={false} + onChange={singleSelectHandleOnChange} + value={selectInitialValue ? selectInitialValue[0] : null} + components={singleSelectComponents} />
); diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.tsx b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.tsx index 83124747dae..2ce251270b7 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.tsx +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.tsx @@ -17,7 +17,29 @@ import type {AutocompleteOptionType} from './user_multiselector'; import './user_profile_option.scss'; -export function UserOptionComponent(props: OptionProps, true>) { +export function MultiUserOptionComponent(props: OptionProps, true>) { + const {data, innerProps} = props; + + const userProfile = data.raw; + const userDisplayName = useSelector((state: GlobalState) => getDisplayNameByUser(state, userProfile)); + + return ( +
+ + + {userDisplayName} +
+ ); +} + +export function SingleUserOptionComponent(props: OptionProps, false>) { const {data, innerProps} = props; const userProfile = data.raw; diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.test.tsx index 505888dcebb..650587396eb 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.test.tsx +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.test.tsx @@ -10,7 +10,7 @@ import {fireEvent, renderWithContext} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; import type {AutocompleteOptionType} from './user_multiselector'; -import {UserProfilePill} from './user_profile_pill'; +import {MultiUserProfilePill} from './user_profile_pill'; describe('components/admin_console/content_flagging/user_multiselector/UserProfilePill', () => { const baseProps = { @@ -70,7 +70,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi test('should render user profile pill with avatar and display name', () => { const {container} = renderWithContext( - , + , initialState, ); @@ -79,7 +79,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi test('should render with correct user display name', () => { const {container} = renderWithContext( - , + , initialState, ); @@ -90,7 +90,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi test('should render Avatar component with correct props', () => { const {container} = renderWithContext( - , + , initialState, ); @@ -100,7 +100,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi test('should render Remove component with close icon', () => { const {container} = renderWithContext( - , + , initialState, ); @@ -118,7 +118,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi }; const {container} = renderWithContext( - , + , initialState, ); @@ -143,7 +143,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi } as unknown as MultiValueProps, true>; const {container} = renderWithContext( - , + , initialState, ); @@ -165,7 +165,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi }; const {container} = renderWithContext( - , + , stateWithUsernameDisplay, ); @@ -175,7 +175,7 @@ describe('components/admin_console/content_flagging/user_multiselector/UserProfi test('should apply correct CSS classes', () => { const {container} = renderWithContext( - , + , initialState, ); diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.tsx b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.tsx index b0e3f5e6961..d6b058e9f71 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.tsx +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.tsx @@ -1,8 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {JSX} from 'react'; import React from 'react'; import {useSelector} from 'react-redux'; +import type {SingleValueProps} from 'react-select'; import type {MultiValueProps} from 'react-select/dist/declarations/src/components/MultiValue'; import type {UserProfile} from '@mattermost/types/users'; @@ -32,9 +34,39 @@ function Remove(props: any) { ); } -export function UserProfilePill(props: MultiValueProps, true>) { +export function MultiUserProfilePill(props: MultiValueProps, true>) { const {data, innerProps, selectProps, removeProps} = props; + return ( + + ); +} + +export function SingleUserProfilePill(props: SingleValueProps, false>) { + const {data, innerProps, selectProps} = props; + + return ( + + ); +} + +type Props = { + data: AutocompleteOptionType; + innerProps: JSX.IntrinsicElements['div']; + selectProps: unknown; + removeProps?: JSX.IntrinsicElements['div']; +} + +function BaseUserProfilePill({data, innerProps, selectProps, removeProps}: Props) { const userProfile = data.raw; const userDisplayName = useSelector((state: GlobalState) => getDisplayNameByUser(state, userProfile)); @@ -51,12 +83,15 @@ export function UserProfilePill(props: MultiValueProps + { + removeProps && + + }
); } diff --git a/webapp/channels/src/components/channel_invite_modal/group_option/__snapshots__/group_option.test.tsx.snap b/webapp/channels/src/components/channel_invite_modal/group_option/__snapshots__/group_option.test.tsx.snap index 5aebc108033..258367774a2 100644 --- a/webapp/channels/src/components/channel_invite_modal/group_option/__snapshots__/group_option.test.tsx.snap +++ b/webapp/channels/src/components/channel_invite_modal/group_option/__snapshots__/group_option.test.tsx.snap @@ -184,6 +184,13 @@ Object { "queryByTitle": [Function], "replaceStoreState": [Function], "rerender": [Function], + "store": Object { + "@@observable": [Function], + "dispatch": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + }, "unmount": [Function], "updateStoreState": [Function], } diff --git a/webapp/channels/src/components/channel_notifications_modal/__snapshots__/channel_notifications_modal.test.tsx.snap b/webapp/channels/src/components/channel_notifications_modal/__snapshots__/channel_notifications_modal.test.tsx.snap deleted file mode 100644 index c8635b16c00..00000000000 --- a/webapp/channels/src/components/channel_notifications_modal/__snapshots__/channel_notifications_modal.test.tsx.snap +++ /dev/null @@ -1,3181 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ChannelNotificationsModal should check the options in the desktop notifications 1`] = ` -Object { - "asFragment": [Function], - "baseElement": -