From 95ac957fa433263c550a4efecd996b0b2d77aa24 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Thu, 8 Dec 2022 12:40:31 +0100 Subject: [PATCH] add-privileged-users-in-room (#9596) --- res/css/_components.pcss | 1 + res/css/structures/_AutocompleteInput.pcss | 129 +++++++++ res/img/element-icons/roomlist/search.svg | 2 +- .../structures/AutocompleteInput.tsx | 248 ++++++++++++++++++ .../views/elements/PowerSelector.tsx | 11 +- .../views/settings/AddPrivilegedUsers.tsx | 132 ++++++++++ .../tabs/room/RolesRoomSettingsTab.tsx | 6 + src/i18n/strings/en_EN.json | 6 +- .../structures/AutocompleteInput-test.tsx | 244 +++++++++++++++++ .../settings/AddPrivilegedUsers-test.tsx | 151 +++++++++++ 10 files changed, 927 insertions(+), 3 deletions(-) create mode 100644 res/css/structures/_AutocompleteInput.pcss create mode 100644 src/components/structures/AutocompleteInput.tsx create mode 100644 src/components/views/settings/AddPrivilegedUsers.tsx create mode 100644 test/components/structures/AutocompleteInput-test.tsx create mode 100644 test/components/views/settings/AddPrivilegedUsers-test.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 2630ad1bc7c..cec02b53f30 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -46,6 +46,7 @@ @import "./components/views/typography/_Caption.pcss"; @import "./compound/_Icon.pcss"; @import "./structures/_AutoHideScrollbar.pcss"; +@import "./structures/_AutocompleteInput.pcss"; @import "./structures/_BackdropPanel.pcss"; @import "./structures/_CompatibilityPage.pcss"; @import "./structures/_ContextualMenu.pcss"; diff --git a/res/css/structures/_AutocompleteInput.pcss b/res/css/structures/_AutocompleteInput.pcss new file mode 100644 index 00000000000..754c8ae1944 --- /dev/null +++ b/res/css/structures/_AutocompleteInput.pcss @@ -0,0 +1,129 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_AutocompleteInput { + position: relative; +} + +.mx_AutocompleteInput_search_icon { + margin-left: $spacing-8; + fill: $secondary-content; +} + +.mx_AutocompleteInput_editor { + flex: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + overflow-x: hidden; + overflow-y: auto; + border: 1px solid $input-border-color; + border-radius: 4px; + transition: border-color 0.25s; + + > input { + flex: 1; + min-width: 40%; + resize: none; + // `!important` is required to bypass global input styles. + margin: 0 !important; + padding: $spacing-8 9px; + border: none !important; + color: $primary-content !important; + font-weight: normal !important; + + &::placeholder { + color: $primary-content !important; + font-weight: normal !important; + } + } +} + +.mx_AutocompleteInput_editor--focused { + border-color: $links; +} + +.mx_AutocompleteInput_editor--has-suggestions { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.mx_AutocompleteInput_editor_selection { + display: flex; + margin-left: $spacing-8; +} + +.mx_AutocompleteInput_editor_selection_pill { + display: flex; + align-items: center; + border-radius: 12px; + padding-left: $spacing-8; + padding-right: $spacing-8; + background-color: $username-variant1-color; + color: #ffffff; + font-size: $font-12px; +} + +.mx_AutocompleteInput_editor_selection_remove_button { + padding: 0 $spacing-4; +} + +.mx_AutocompleteInput_matches { + position: absolute; + left: 0; + right: 0; + background-color: $background; + border: 1px solid $links; + border-top-color: $input-border-color; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + z-index: 1000; +} + +.mx_AutocompleteInput_suggestion { + display: flex; + align-items: center; + padding: $spacing-8; + cursor: pointer; + + > * { + user-select: none; + } + + &:hover { + background-color: $quinary-content; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } +} + +.mx_AutocompleteInput_suggestion--selected { + background-color: $quinary-content; + + &:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } +} + +.mx_AutocompleteInput_suggestion_title { + margin-right: $spacing-8; +} + +.mx_AutocompleteInput_suggestion_description { + color: $secondary-content; + font-size: $font-12px; +} diff --git a/res/img/element-icons/roomlist/search.svg b/res/img/element-icons/roomlist/search.svg index b706092a5cd..b6a1ad100f5 100644 --- a/res/img/element-icons/roomlist/search.svg +++ b/res/img/element-icons/roomlist/search.svg @@ -1,3 +1,3 @@ - + diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx new file mode 100644 index 00000000000..1088f6a3790 --- /dev/null +++ b/src/components/structures/AutocompleteInput.tsx @@ -0,0 +1,248 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState, ReactNode, ChangeEvent, KeyboardEvent, useRef, ReactElement } from 'react'; +import classNames from 'classnames'; + +import Autocompleter from "../../autocomplete/AutocompleteProvider"; +import { Key } from '../../Keyboard'; +import { ICompletion } from '../../autocomplete/Autocompleter'; +import AccessibleButton from '../../components/views/elements/AccessibleButton'; +import { Icon as PillRemoveIcon } from '../../../res/img/icon-pill-remove.svg'; +import { Icon as SearchIcon } from '../../../res/img/element-icons/roomlist/search.svg'; +import useFocus from "../../hooks/useFocus"; + +interface AutocompleteInputProps { + provider: Autocompleter; + placeholder: string; + selection: ICompletion[]; + onSelectionChange: (selection: ICompletion[]) => void; + maxSuggestions?: number; + renderSuggestion?: (s: ICompletion) => ReactElement; + renderSelection?: (m: ICompletion) => ReactElement; + additionalFilter?: (suggestion: ICompletion) => boolean; +} + +export const AutocompleteInput: React.FC = ({ + provider, + renderSuggestion, + renderSelection, + maxSuggestions = 5, + placeholder, + onSelectionChange, + selection, + additionalFilter, +}) => { + const [query, setQuery] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [isFocused, onFocusChangeHandlerFunctions] = useFocus(); + const editorContainerRef = useRef(null); + const editorRef = useRef(null); + + const focusEditor = () => { + editorRef?.current?.focus(); + }; + + const onQueryChange = async (e: ChangeEvent) => { + const value = e.target.value.trim(); + setQuery(value); + + let matches = await provider.getCompletions( + query, + { start: query.length, end: query.length }, + true, + maxSuggestions, + ); + + if (additionalFilter) { + matches = matches.filter(additionalFilter); + } + + setSuggestions(matches); + }; + + const onClickInputArea = () => { + focusEditor(); + }; + + const onKeyDown = (e: KeyboardEvent) => { + const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey; + + // when the field is empty and the user hits backspace remove the right-most target + if (!query && selection.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) { + removeSelection(selection[selection.length - 1]); + } + }; + + const toggleSelection = (completion: ICompletion) => { + const newSelection = [...selection]; + const index = selection.findIndex(selection => selection.completionId === completion.completionId); + + if (index >= 0) { + newSelection.splice(index, 1); + } else { + newSelection.push(completion); + } + + onSelectionChange(newSelection); + focusEditor(); + }; + + const removeSelection = (completion: ICompletion) => { + const newSelection = [...selection]; + const index = selection.findIndex(selection => selection.completionId === completion.completionId); + + if (index >= 0) { + newSelection.splice(index, 1); + onSelectionChange(newSelection); + } + }; + + const hasPlaceholder = (): boolean => selection.length === 0 && query.length === 0; + + return ( +
+
0, + })} + onClick={onClickInputArea} + data-testid="autocomplete-editor" + > + + { + selection.map(item => ( + + )) + } + +
+ { + (isFocused && suggestions.length) ? ( +
+ { + suggestions.map((item) => ( + + )) + } +
+ ) : null + } +
+ ); +}; + +type SelectionItemProps = { + item: ICompletion; + onClick: (completion: ICompletion) => void; + render?: (completion: ICompletion) => ReactElement; +}; + +const SelectionItem: React.FC = ({ item, onClick, render }) => { + const withContainer = (children: ReactNode): ReactElement => ( + + + { children } + + onClick(item)} + data-testid={`autocomplete-selection-remove-button-${item.completionId}`} + > + + + + ); + + if (render) { + return withContainer(render(item)); + } + + return withContainer( + { item.completion }, + ); +}; + +type SuggestionItemProps = { + item: ICompletion; + selection: ICompletion[]; + onClick: (completion: ICompletion) => void; + render?: (completion: ICompletion) => ReactElement; +}; + +const SuggestionItem: React.FC = ({ item, selection, onClick, render }) => { + const isSelected = selection.some(selection => selection.completionId === item.completionId); + const classes = classNames({ + 'mx_AutocompleteInput_suggestion': true, + 'mx_AutocompleteInput_suggestion--selected': isSelected, + }); + + const withContainer = (children: ReactNode): ReactElement => ( +
{ + event.preventDefault(); + onClick(item); + }} + data-testid={`autocomplete-suggestion-item-${item.completionId}`} + > + { children } +
+ ); + + if (render) { + return withContainer(render(item)); + } + + return withContainer( + <> + { item.completion } + { item.completionId } + , + ); +}; diff --git a/src/components/views/elements/PowerSelector.tsx b/src/components/views/elements/PowerSelector.tsx index 396e071bdb0..3fca57d3d25 100644 --- a/src/components/views/elements/PowerSelector.tsx +++ b/src/components/views/elements/PowerSelector.tsx @@ -174,7 +174,15 @@ export default class PowerSelector extends React.Component { }); options.push({ value: CUSTOM_VALUE, text: _t("Custom level") }); const optionsElements = options.map((op) => { - return ; + return ( + + ); }); picker = ( @@ -184,6 +192,7 @@ export default class PowerSelector extends React.Component { onChange={this.onSelectChange} value={String(this.state.selectValue)} disabled={this.props.disabled} + data-testid='power-level-select-element' > { optionsElements } diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx new file mode 100644 index 00000000000..f85699c7413 --- /dev/null +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -0,0 +1,132 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FormEvent, useCallback, useContext, useRef, useState } from 'react'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { _t } from "../../../languageHandler"; +import { ICompletion } from '../../../autocomplete/Autocompleter'; +import UserProvider from "../../../autocomplete/UserProvider"; +import { AutocompleteInput } from "../../structures/AutocompleteInput"; +import PowerSelector from "../elements/PowerSelector"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import AccessibleButton from "../elements/AccessibleButton"; +import Modal from "../../../Modal"; +import ErrorDialog from "../dialogs/ErrorDialog"; +import SettingsFieldset from "./SettingsFieldset"; + +interface AddPrivilegedUsersProps { + room: Room; + defaultUserLevel: number; +} + +export const AddPrivilegedUsers: React.FC = ({ room, defaultUserLevel }) => { + const client = useContext(MatrixClientContext); + const userProvider = useRef(new UserProvider(room)); + const [isLoading, setIsLoading] = useState(false); + const [powerLevel, setPowerLevel] = useState(defaultUserLevel); + const [selectedUsers, setSelectedUsers] = useState([]); + const hasLowerOrEqualLevelThanDefaultLevelFilter = useCallback( + (user: ICompletion) => hasLowerOrEqualLevelThanDefaultLevel(room, user, defaultUserLevel), + [room, defaultUserLevel], + ); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsLoading(true); + + const userIds = getUserIdsFromCompletions(selectedUsers); + const powerLevelEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + + // `RoomPowerLevels` event should exist, but technically it is not guaranteed. + if (powerLevelEvent === null) { + Modal.createDialog(ErrorDialog, { + title: _t("Error"), + description: _t("Failed to change power level"), + }); + + return; + } + + try { + await client.setPowerLevel(room.roomId, userIds, powerLevel, powerLevelEvent); + setSelectedUsers([]); + setPowerLevel(defaultUserLevel); + } catch (error) { + Modal.createDialog(ErrorDialog, { + title: _t("Error"), + description: _t("Failed to change power level"), + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + + + { _t('Apply') } + + +
+ ); +}; + +export const hasLowerOrEqualLevelThanDefaultLevel = ( + room: Room, + user: ICompletion, + defaultUserLevel: number, +) => { + if (user.completionId === undefined) { + return false; + } + + const member = room.getMember(user.completionId); + + if (member === null) { + return false; + } + + return member.powerLevel <= defaultUserLevel; +}; + +export const getUserIdsFromCompletions = (completions: ICompletion[]) => { + const completionsWithId = completions.filter(completion => completion.completionId !== undefined); + + // undefined completionId's are filtered out above but TypeScript does not seem to understand. + return completionsWithId.map(completion => completion.completionId!); +}; diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index 3a273f4561c..a013e11724b 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -33,6 +33,7 @@ import SettingsStore from "../../../../../settings/SettingsStore"; import { VoiceBroadcastInfoEventType } from '../../../../../voice-broadcast'; import { ElementCall } from "../../../../../models/Call"; import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; +import { AddPrivilegedUsers } from "../../AddPrivilegedUsers"; interface IEventShowOpts { isState?: boolean; @@ -470,6 +471,11 @@ export default class RolesRoomSettingsTab extends React.Component {
{ _t("Roles & Permissions") }
{ privilegedUsersSection } + { + (canChangeLevels && room !== null) && ( + + ) + } { mutedUsersSection } { bannedUsersSection } .": "This bridge was provisioned by .", "This bridge is managed by .": "This bridge is managed by .", @@ -2227,7 +2232,6 @@ "Failed to mute user": "Failed to mute user", "Unmute": "Unmute", "Mute": "Mute", - "Failed to change power level": "Failed to change power level", "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.": "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.", "Are you sure?": "Are you sure?", "Deactivate user?": "Deactivate user?", diff --git a/test/components/structures/AutocompleteInput-test.tsx b/test/components/structures/AutocompleteInput-test.tsx new file mode 100644 index 00000000000..e7593ebb4b1 --- /dev/null +++ b/test/components/structures/AutocompleteInput-test.tsx @@ -0,0 +1,244 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { screen, render, fireEvent, waitFor, within, act } from '@testing-library/react'; + +import * as TestUtils from '../../test-utils'; +import AutocompleteProvider from '../../../src/autocomplete/AutocompleteProvider'; +import { ICompletion } from '../../../src/autocomplete/Autocompleter'; +import { AutocompleteInput } from "../../../src/components/structures/AutocompleteInput"; + +describe('AutocompleteInput', () => { + const mockCompletion: ICompletion[] = [ + { type: 'user', completion: 'user_1', completionId: '@user_1:host.local', range: { start: 1, end: 1 } }, + { type: 'user', completion: 'user_2', completionId: '@user_2:host.local', range: { start: 1, end: 1 } }, + ]; + + const constructMockProvider = (data: ICompletion[]) => ({ + getCompletions: jest.fn().mockImplementation(async () => data), + }) as unknown as AutocompleteProvider; + + beforeEach(() => { + TestUtils.stubClient(); + }); + + const getEditorInput = () => { + const input = screen.getByTestId('autocomplete-input'); + expect(input).toBeDefined(); + + return input; + }; + + it('should render suggestions when a query is set', async () => { + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + + render( + , + ); + + const input = getEditorInput(); + + act(() => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'user' } }); + }); + + await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('autocomplete-matches').childNodes).toHaveLength(mockCompletion.length); + }); + + it('should render selected items passed in via props', () => { + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + + render( + , + ); + + const editor = screen.getByTestId('autocomplete-editor'); + const selection = within(editor).getAllByTestId("autocomplete-selection-item", { exact: false }); + expect(selection).toHaveLength(mockCompletion.length); + }); + + it('should call onSelectionChange() when an item is removed from selection', () => { + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + + render( + , + ); + + const editor = screen.getByTestId('autocomplete-editor'); + const removeButtons = within(editor).getAllByTestId("autocomplete-selection-remove-button", { exact: false }); + expect(removeButtons).toHaveLength(mockCompletion.length); + + act(() => { + fireEvent.click(removeButtons[0]); + }); + + expect(onSelectionChangeMock).toHaveBeenCalledTimes(1); + expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[1]]); + }); + + it('should render custom selection element when renderSelection() is defined', () => { + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + + const renderSelection = () => ( + custom selection element + ); + + render( + , + ); + + expect(screen.getAllByTestId('custom-selection-element')).toHaveLength(mockCompletion.length); + }); + + it('should render custom suggestion element when renderSuggestion() is defined', async () => { + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + + const renderSuggestion = () => ( + custom suggestion element + ); + + render( + , + ); + + const input = getEditorInput(); + + act(() => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'user' } }); + }); + + await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1)); + expect(screen.getAllByTestId('custom-suggestion-element')).toHaveLength(mockCompletion.length); + }); + + it('should mark selected suggestions as selected', async () => { + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + + const { container } = render( + , + ); + + const input = getEditorInput(); + + act(() => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'user' } }); + }); + + await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1)); + const suggestions = await within(container).findAllByTestId('autocomplete-suggestion-item', { exact: false }); + expect(suggestions).toHaveLength(mockCompletion.length); + suggestions.map(suggestion => expect(suggestion).toHaveClass('mx_AutocompleteInput_suggestion--selected')); + }); + + it('should remove the last added selection when backspace is pressed in empty input', () => { + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + + render( + , + ); + + const input = getEditorInput(); + + act(() => { + fireEvent.keyDown(input, { key: 'Backspace' }); + }); + + expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]); + }); + + it('should toggle a selected item when a suggestion is clicked', async () => { + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + + const { container } = render( + , + ); + + const input = getEditorInput(); + + act(() => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'user' } }); + }); + + const suggestions = await within(container).findAllByTestId('autocomplete-suggestion-item', { exact: false }); + + act(() => { + fireEvent.mouseDown(suggestions[0]); + }); + + expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]); + }); + + afterAll(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); +}); diff --git a/test/components/views/settings/AddPrivilegedUsers-test.tsx b/test/components/views/settings/AddPrivilegedUsers-test.tsx new file mode 100644 index 00000000000..67258e47df8 --- /dev/null +++ b/test/components/views/settings/AddPrivilegedUsers-test.tsx @@ -0,0 +1,151 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from "@testing-library/user-event"; +import { mocked } from "jest-mock"; +import { RoomMember, EventType } from "matrix-js-sdk/src/matrix"; + +import { + getMockClientWithEventEmitter, + makeRoomWithStateEvents, + mkEvent, +} from "../../../test-utils"; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; +import { + AddPrivilegedUsers, + getUserIdsFromCompletions, hasLowerOrEqualLevelThanDefaultLevel, +} from "../../../../src/components/views/settings/AddPrivilegedUsers"; +import UserProvider from "../../../../src/autocomplete/UserProvider"; +import { ICompletion } from "../../../../src/autocomplete/Autocompleter"; + +jest.mock('../../../../src/autocomplete/UserProvider'); + +const completions: ICompletion[] = [ + { type: 'user', completion: 'user_1', completionId: '@user_1:host.local', range: { start: 1, end: 1 } }, + { type: 'user', completion: 'user_2', completionId: '@user_2:host.local', range: { start: 1, end: 1 } }, + { type: 'user', completion: 'user_without_completion_id', range: { start: 1, end: 1 } }, +]; + +describe('', () => { + const provider = mocked(UserProvider, { shallow: true }); + provider.prototype.getCompletions.mockResolvedValue(completions); + + const mockClient = getMockClientWithEventEmitter({ + // `makeRoomWithStateEvents` only work's if `getRoom` is present. + getRoom: jest.fn(), + setPowerLevel: jest.fn(), + }); + + const room = makeRoomWithStateEvents([], { roomId: 'room_id', mockClient: mockClient }); + room.getMember = (userId: string) => { + const member = new RoomMember('room_id', userId); + member.powerLevel = 0; + + return member; + }; + (room.currentState.getStateEvents as unknown) = (_eventType: string, _stateKey: string) => { + return mkEvent({ + type: EventType.RoomPowerLevels, + content: {}, + user: 'user_id', + }); + }; + + const getComponent = () => + + + ; + + it('checks whether form submit works as intended', async () => { + const { getByTestId, queryAllByTestId } = render(getComponent()); + + // Verify that the submit button is disabled initially. + const submitButton = getByTestId('add-privileged-users-submit-button'); + expect(submitButton).toBeDisabled(); + + // Find some suggestions and select them. + const autocompleteInput = getByTestId('autocomplete-input'); + + act(() => { + fireEvent.focus(autocompleteInput); + fireEvent.change(autocompleteInput, { target: { value: 'u' } }); + }); + + await waitFor(() => expect(provider.mock.instances[0].getCompletions).toHaveBeenCalledTimes(1)); + const matchOne = getByTestId('autocomplete-suggestion-item-@user_1:host.local'); + const matchTwo = getByTestId('autocomplete-suggestion-item-@user_2:host.local'); + + act(() => { + fireEvent.mouseDown(matchOne); + }); + + act(() => { + fireEvent.mouseDown(matchTwo); + }); + + // Check that `defaultUserLevel` is initially set and select a higher power level. + expect((getByTestId('power-level-option-0') as HTMLOptionElement).selected).toBeTruthy(); + expect((getByTestId('power-level-option-50') as HTMLOptionElement).selected).toBeFalsy(); + expect((getByTestId('power-level-option-100') as HTMLOptionElement).selected).toBeFalsy(); + + const powerLevelSelect = getByTestId('power-level-select-element'); + await userEvent.selectOptions(powerLevelSelect, "100"); + + expect((getByTestId('power-level-option-0') as HTMLOptionElement).selected).toBeFalsy(); + expect((getByTestId('power-level-option-50') as HTMLOptionElement).selected).toBeFalsy(); + expect((getByTestId('power-level-option-100') as HTMLOptionElement).selected).toBeTruthy(); + + // The submit button should be enabled now. + expect(submitButton).toBeEnabled(); + + // Submit the form. + act(() => { + fireEvent.submit(submitButton); + }); + + await waitFor(() => expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1)); + + // Verify that the submit button is disabled again. + expect(submitButton).toBeDisabled(); + + // Verify that previously selected items are reset. + const selectionItems = queryAllByTestId('autocomplete-selection-item', { exact: false }); + expect(selectionItems).toHaveLength(0); + + // Verify that power level select is reset to `defaultUserLevel`. + expect((getByTestId('power-level-option-0') as HTMLOptionElement).selected).toBeTruthy(); + expect((getByTestId('power-level-option-50') as HTMLOptionElement).selected).toBeFalsy(); + expect((getByTestId('power-level-option-100') as HTMLOptionElement).selected).toBeFalsy(); + }); + + it('getUserIdsFromCompletions() should map completions to user id\'s', () => { + expect(getUserIdsFromCompletions(completions)).toStrictEqual(['@user_1:host.local', '@user_2:host.local']); + }); + + it.each([ + { defaultUserLevel: -50, expectation: false }, + { defaultUserLevel: 0, expectation: true }, + { defaultUserLevel: 50, expectation: true }, + ])('hasLowerOrEqualLevelThanDefaultLevel() should return $expectation for default level $defaultUserLevel', + ({ defaultUserLevel, expectation }) => { + expect(hasLowerOrEqualLevelThanDefaultLevel(room, completions[0], defaultUserLevel)).toBe(expectation); + }, + ); +});