From 9e200f41f6b8efd3d09d7f932b1268652e4b3063 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Fri, 18 Nov 2022 10:46:09 +0100 Subject: [PATCH 01/15] adds a new section under 'Room Settings' > 'Roles & Permissions' which adds the possibility to multiselect users from this room and grant them more permissions --- res/css/_components.pcss | 1 + res/css/structures/_AutocompleteInput.pcss | 118 +++++++++ .../structures/AutocompleteInput.tsx | 242 ++++++++++++++++++ .../views/settings/AddPrivilegedUsers.tsx | 89 +++++++ .../tabs/room/RolesRoomSettingsTab.tsx | 2 + src/i18n/strings/en_EN.json | 6 +- .../structures/AutocompleteInput-test.tsx | 220 ++++++++++++++++ 7 files changed, 677 insertions(+), 1 deletion(-) 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 diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 2630ad1bc7c..8c02882eaa7 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -45,6 +45,7 @@ @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./components/views/typography/_Caption.pcss"; @import "./compound/_Icon.pcss"; +@import "./structures/_AutocompleteInput.pcss"; @import "./structures/_AutoHideScrollbar.pcss"; @import "./structures/_BackdropPanel.pcss"; @import "./structures/_CompatibilityPage.pcss"; diff --git a/res/css/structures/_AutocompleteInput.pcss b/res/css/structures/_AutocompleteInput.pcss new file mode 100644 index 00000000000..3e3d9b86950 --- /dev/null +++ b/res/css/structures/_AutocompleteInput.pcss @@ -0,0 +1,118 @@ +$icon-size: 22px; + +.mx_AutocompleteInput { + position: relative; + + &_matches { + position: absolute; + left: 0; + right: 0; + background-color: $background; + border: 1px solid $input-border-color; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + z-index: 1000; + } + + &_suggestion { + display: flex; + align-items: center; + padding: 8px; + position: relative; + cursor: pointer; + + > * { + user-select: none; + } + + &:hover { + background-color: $focus-bg-color; + } + + &_selected { + background-color: $focus-bg-color; + + &::before { + content: ''; + mask-repeat: no-repeat; + mask-position: center; + mask-image: url("$(res)/img/element-icons/roomlist/checkmark.svg"); + mask-size: $icon-size; + display: block; + width: $icon-size; + height: $icon-size; + margin: 0 auto; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: $username-variant1-color; + } + } + + &_title { + margin-right: 8px; + } + + &_description { + color: grey; + font-size: 1.2rem; + } + } + + &_editor { + flex: 1; + border-radius: 4px; + min-height: 25px; + overflow-x: hidden; + overflow-y: auto; + display: flex; + flex-wrap: wrap; + transition: border-color 0.25s; + border: 1px solid $input-border-color; + + > input[type="text"] { + margin: 6px 0 !important; + height: 24px; + line-height: $font-24px; + font-size: $font-14px; + padding-left: 12px; + border: 0 !important; + outline: 0 !important; + resize: none; + box-sizing: border-box; + min-width: 40%; + flex: 1 !important; + color: $primary-content !important; + + &::placeholder { + color: $primary-content !important; + font-weight: normal !important; + font-size: 1.4rem; + } + } + + &_selection { + margin: 6px 6px 0 6px; + display: flex; + min-width: max-content; + + &_pill { + background-color: $username-variant1-color; + border-radius: 12px; + height: 24px; + padding-left: 8px; + padding-right: 8px; + color: #ffffff; + display: flex; + align-items: center; + } + + &_remove { + margin-left: 4px; + max-height: 24px; + line-height: 24px; + } + } + } +} diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx new file mode 100644 index 00000000000..4e58c7f5d97 --- /dev/null +++ b/src/components/structures/AutocompleteInput.tsx @@ -0,0 +1,242 @@ +/* +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, useEffect, useCallback, useRef } 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'; + +interface AutocompleteInputProps { + provider: Autocompleter; + placeholder: string; + maxSuggestions?: number; + renderSuggestion?: (s: ICompletion) => ReactNode; + selection?: ICompletion[]; + onSelectionChange?: (selection: ICompletion[]) => void; + renderSelection?: (m: ICompletion) => ReactNode; + additionalFilter?: (suggestion: ICompletion) => boolean; +} + +export const AutocompleteInput: React.FC = ({ + provider, + renderSuggestion, + renderSelection, + maxSuggestions = 5, + placeholder, + onSelectionChange, + selection, + additionalFilter, +}) => { + const [_selection, setSelection] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [query, setQuery] = useState(''); + const [focusedElement, setFocusedElement] = useState(null); + + const editorContainerRef = useRef(); + const editorRef = useRef(); + + const getSuggestions = useCallback(async () => { + if (query) { + let matches = await provider.getCompletions( + query, + { start: query.length, end: query.length }, + true, + maxSuggestions, + ); + if (additionalFilter) { + matches = matches.filter(additionalFilter); + } + setSuggestions(matches); + } + }, [query, maxSuggestions, provider, additionalFilter]); + + useEffect(() => { + getSuggestions(); + + if (selection) { + setSelection(selection); + } + }, [getSuggestions, selection]); + + const focusEditor = () => { + if (editorRef && editorRef.current) { + editorRef.current.focus(); + } + }; + + const removeSelection = (t: ICompletion) => { + const newSelection = _selection.map(s => s); + const idx = _selection.findIndex(s => s.completionId === t.completionId); + + if (idx >= 0) { + newSelection.splice(idx, 1); + if (onSelectionChange) { + onSelectionChange(newSelection); + } + setSelection(newSelection); + } + + setQuery(''); + }; + + const onFilterChange = async (e: ChangeEvent) => { + e.stopPropagation(); + e.preventDefault(); + + setQuery(e.target.value.trim()); + }; + + const onClickInputArea = (e) => { + e.stopPropagation(); + e.preventDefault(); + + 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) { + e.preventDefault(); + removeSelection(_selection[_selection.length - 1]); + } + }; + + const toggleSelection = (e: ICompletion) => { + const newSelection = _selection.map(t => t); + const idx = _selection.findIndex(s => s.completionId === e.completionId); + if (idx >= 0) { + newSelection.splice(idx, 1); + } else { + newSelection.push(e); + } + + if (onSelectionChange) { + onSelectionChange(newSelection); + } + setSelection(newSelection); + setQuery(''); + + focusEditor(); + }; + + const _renderSuggestion = (s: ICompletion): ReactNode => { + const classes = classNames({ + 'mx_AutocompleteInput_suggestion': true, + 'mx_AutocompleteInput_suggestion_selected': + _selection.findIndex(selection => selection.completionId === s.completionId) >= 0, + }); + + const withContainer = (children: ReactNode): ReactNode => ( +
{ + e.preventDefault(); + e.stopPropagation(); + + toggleSelection(s); + }} + key={s.completionId} + data-testid={`autocomplete-suggestion-item-${s.completionId}`} + > + { children } +
+ ); + + if (renderSuggestion) { + return withContainer(renderSuggestion(s)); + } + + return withContainer( + <> + { s.completion } + { s.completionId } + , + ); + }; + + const _renderSelection = (s: ICompletion): ReactNode => { + const withContainer = (children: ReactNode): ReactNode => ( + + + { children } + + removeSelection(s)} + data-testid={`autocomplete-selection-remove-button-${s.completionId}`} + > + + + + ); + + if (renderSelection) { + return withContainer(renderSelection(s)); + } + + return withContainer( + { s.completion }, + ); + }; + + const hasPlaceholder = (): boolean => _selection.length === 0 && query.length === 0; + + return ( +
+
+ { _selection.map(s => _renderSelection(s)) } + setFocusedElement(e.target)} + onBlur={() => setFocusedElement(null)} + data-testid="autocomplete-input" + /> +
+ { + (focusedElement === editorRef.current && suggestions.length) ? ( +
+ { + suggestions.map((s) => _renderSuggestion(s)) + } +
+ ) : null + } +
+ ); +}; diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx new file mode 100644 index 00000000000..74881c062df --- /dev/null +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -0,0 +1,89 @@ +/* +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, { 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 SettingsFieldSet from '../../../components/views/settings/SettingsFieldset'; +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"; + +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 filterSuggestions = useCallback( + (user: ICompletion) => room.getMember(user.completionId)?.powerLevel <= defaultUserLevel, + [room, defaultUserLevel], + ); + + const onSubmit = async () => { + setIsLoading(true); + const userIds = selectedUsers.map(selectedUser => selectedUser.completionId); + const powerLevelEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + + 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') } + + + ); +}; diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index 3a273f4561c..cc2b749bd65 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,7 @@ export default class RolesRoomSettingsTab extends React.Component {
{ _t("Roles & Permissions") }
{ privilegedUsersSection } + { canChangeLevels && } { 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..3950a17050e --- /dev/null +++ b/test/components/structures/AutocompleteInput-test.tsx @@ -0,0 +1,220 @@ +/* +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 mockProvider = (data) => ({ + getCompletions: jest.fn().mockImplementation(async (query) => query ? data : []), + }) as unknown as AutocompleteProvider; + + beforeEach(async () => { + 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 = mockProvider(mockCompletion); + 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 selection when used as a controlled input', async () => { + const _mockProvider = mockProvider(mockCompletion); + 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', async () => { + const _mockProvider = mockProvider(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', async () => { + const _mockProvider = mockProvider(mockCompletion); + 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 = mockProvider(mockCompletion); + 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 = mockProvider(mockCompletion); + 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', async () => { + const _mockProvider = mockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + render( + , + ); + const input = getEditorInput(); + + act(() => { + fireEvent.keyDown(input, { key: 'Backspace' }); + }); + + expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]); + }); + + it('should toggle a selection when a suggestion is clicked', async () => { + const _mockProvider = mockProvider(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 }); + expect(suggestions).toHaveLength(mockCompletion.length); + + act(() => { + fireEvent.mouseDown(suggestions[0]); + }); + + expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]); + + act(() => { + fireEvent.mouseDown(suggestions[0]); + }); + + expect(onSelectionChangeMock).toHaveBeenCalledWith([]); + }); + + afterAll(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); +}); From d8aa22ab9adad57a03a9a63717d8c61afe47d0b6 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Mon, 21 Nov 2022 09:57:27 +0100 Subject: [PATCH 02/15] fixes rethemendex, style lint and i18n ci checks --- res/css/_components.pcss | 2 +- res/css/structures/_AutocompleteInput.pcss | 196 ++++++++++----------- 2 files changed, 99 insertions(+), 99 deletions(-) diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 8c02882eaa7..cec02b53f30 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -45,8 +45,8 @@ @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./components/views/typography/_Caption.pcss"; @import "./compound/_Icon.pcss"; -@import "./structures/_AutocompleteInput.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 index 3e3d9b86950..2ba8eca1298 100644 --- a/res/css/structures/_AutocompleteInput.pcss +++ b/res/css/structures/_AutocompleteInput.pcss @@ -2,117 +2,117 @@ $icon-size: 22px; .mx_AutocompleteInput { position: relative; +} - &_matches { - position: absolute; - left: 0; - right: 0; - background-color: $background; - border: 1px solid $input-border-color; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - z-index: 1000; - } +.mx_AutocompleteInput_matches { + position: absolute; + left: 0; + right: 0; + background-color: $background; + border: 1px solid $input-border-color; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + z-index: 1000; +} - &_suggestion { - display: flex; - align-items: center; - padding: 8px; - position: relative; - cursor: pointer; +.mx_AutocompleteInput_suggestion { + display: flex; + align-items: center; + padding: 8px; + position: relative; + cursor: pointer; - > * { - user-select: none; - } + > * { + user-select: none; + } - &:hover { - background-color: $focus-bg-color; - } + &:hover { + background-color: $focus-bg-color; + } +} - &_selected { - background-color: $focus-bg-color; +.mx_AutocompleteInput_suggestion_selected { + background-color: $focus-bg-color; - &::before { - content: ''; - mask-repeat: no-repeat; - mask-position: center; - mask-image: url("$(res)/img/element-icons/roomlist/checkmark.svg"); - mask-size: $icon-size; - display: block; - width: $icon-size; - height: $icon-size; - margin: 0 auto; - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - background: $username-variant1-color; - } - } + &::before { + content: ''; + mask-repeat: no-repeat; + mask-position: center; + mask-image: url("$(res)/img/element-icons/roomlist/checkmark.svg"); + mask-size: $icon-size; + display: block; + width: $icon-size; + height: $icon-size; + margin: 0 auto; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: $username-variant1-color; + } +} - &_title { - margin-right: 8px; - } +.mx_AutocompleteInput_suggestion_title { + margin-right: 8px; +} - &_description { - color: grey; - font-size: 1.2rem; - } - } +.mx_AutocompleteInput_suggestion_description { + color: grey; + font-size: 1.2rem; +} - &_editor { - flex: 1; - border-radius: 4px; - min-height: 25px; - overflow-x: hidden; - overflow-y: auto; - display: flex; - flex-wrap: wrap; - transition: border-color 0.25s; - border: 1px solid $input-border-color; +.mx_AutocompleteInput_editor { + flex: 1; + border-radius: 4px; + min-height: 25px; + overflow-x: hidden; + overflow-y: auto; + display: flex; + flex-wrap: wrap; + transition: border-color 0.25s; + border: 1px solid $input-border-color; - > input[type="text"] { - margin: 6px 0 !important; - height: 24px; - line-height: $font-24px; - font-size: $font-14px; - padding-left: 12px; - border: 0 !important; - outline: 0 !important; - resize: none; - box-sizing: border-box; - min-width: 40%; - flex: 1 !important; - color: $primary-content !important; + > input[type="text"] { + margin: 6px 0 !important; + height: 24px; + line-height: $font-24px; + font-size: $font-14px; + padding-left: 12px; + border: 0 !important; + outline: 0 !important; + resize: none; + box-sizing: border-box; + min-width: 40%; + flex: 1 !important; + color: $primary-content !important; - &::placeholder { - color: $primary-content !important; - font-weight: normal !important; - font-size: 1.4rem; - } + &::placeholder { + color: $primary-content !important; + font-weight: normal !important; + font-size: 1.4rem; } + } +} - &_selection { - margin: 6px 6px 0 6px; - display: flex; - min-width: max-content; +.mx_AutocompleteInput_editor_selection { + margin: 6px 6px 0 6px; + display: flex; + min-width: max-content; +} - &_pill { - background-color: $username-variant1-color; - border-radius: 12px; - height: 24px; - padding-left: 8px; - padding-right: 8px; - color: #ffffff; - display: flex; - align-items: center; - } +.mx_AutocompleteInput_editor_selection_pill { + background-color: $username-variant1-color; + border-radius: 12px; + height: 24px; + padding-left: 8px; + padding-right: 8px; + color: #ffffff; + display: flex; + align-items: center; +} - &_remove { - margin-left: 4px; - max-height: 24px; - line-height: 24px; - } - } - } +.mx_AutocompleteInput_editor_selection_remove { + margin-left: 4px; + max-height: 24px; + line-height: 24px; } From 3e7e362b1787db52a4a7c17f314dd83590a2a035 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Mon, 21 Nov 2022 12:38:05 +0100 Subject: [PATCH 03/15] uses spacing, size and color variables instead of hardcoded values, adds checkmark svg as react component instead of pseudo element --- res/css/structures/_AutocompleteInput.pcss | 57 +++++++------------ .../structures/AutocompleteInput.tsx | 10 +++- 2 files changed, 29 insertions(+), 38 deletions(-) diff --git a/res/css/structures/_AutocompleteInput.pcss b/res/css/structures/_AutocompleteInput.pcss index 2ba8eca1298..2a3ca585a25 100644 --- a/res/css/structures/_AutocompleteInput.pcss +++ b/res/css/structures/_AutocompleteInput.pcss @@ -1,5 +1,3 @@ -$icon-size: 22px; - .mx_AutocompleteInput { position: relative; } @@ -13,12 +11,14 @@ $icon-size: 22px; border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; z-index: 1000; + box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; } .mx_AutocompleteInput_suggestion { display: flex; align-items: center; - padding: 8px; + justify-content: space-between; + padding: $spacing-8; position: relative; cursor: pointer; @@ -31,44 +31,32 @@ $icon-size: 22px; } } -.mx_AutocompleteInput_suggestion_selected { +.mx_AutocompleteInput_suggestion--selected { background-color: $focus-bg-color; - &::before { - content: ''; - mask-repeat: no-repeat; - mask-position: center; - mask-image: url("$(res)/img/element-icons/roomlist/checkmark.svg"); - mask-size: $icon-size; - display: block; - width: $icon-size; - height: $icon-size; - margin: 0 auto; - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - background: $username-variant1-color; + svg { + color: $username-variant1-color; } } .mx_AutocompleteInput_suggestion_title { - margin-right: 8px; + margin-right: $spacing-8; } .mx_AutocompleteInput_suggestion_description { - color: grey; - font-size: 1.2rem; + color: $secondary-content; + font-size: $font-12px; } .mx_AutocompleteInput_editor { flex: 1; + display: flex; + flex-wrap: wrap; + align-items: center; border-radius: 4px; min-height: 25px; overflow-x: hidden; overflow-y: auto; - display: flex; - flex-wrap: wrap; transition: border-color 0.25s; border: 1px solid $input-border-color; @@ -77,7 +65,7 @@ $icon-size: 22px; height: 24px; line-height: $font-24px; font-size: $font-14px; - padding-left: 12px; + padding-left: $spacing-12; border: 0 !important; outline: 0 !important; resize: none; @@ -95,24 +83,23 @@ $icon-size: 22px; } .mx_AutocompleteInput_editor_selection { - margin: 6px 6px 0 6px; display: flex; - min-width: max-content; + margin-left: $spacing-8; } .mx_AutocompleteInput_editor_selection_pill { - background-color: $username-variant1-color; + display: flex; + align-items: center; border-radius: 12px; height: 24px; - padding-left: 8px; - padding-right: 8px; + padding-left: $spacing-8; + padding-right: $spacing-8; + background-color: $username-variant1-color; color: #ffffff; - display: flex; - align-items: center; } .mx_AutocompleteInput_editor_selection_remove { - margin-left: 4px; - max-height: 24px; - line-height: 24px; + padding: 0 $spacing-4; + max-height: $font-24px; + line-height: $font-24px; } diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx index 4e58c7f5d97..9656b4a78fd 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -22,6 +22,7 @@ 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 CheckmarkIcon } from '../../../res/img/element-icons/roomlist/checkmark.svg'; interface AutocompleteInputProps { provider: Autocompleter; @@ -138,10 +139,10 @@ export const AutocompleteInput: React.FC = ({ }; const _renderSuggestion = (s: ICompletion): ReactNode => { + const isSelected = _selection.findIndex(selection => selection.completionId === s.completionId) >= 0; const classes = classNames({ 'mx_AutocompleteInput_suggestion': true, - 'mx_AutocompleteInput_suggestion_selected': - _selection.findIndex(selection => selection.completionId === s.completionId) >= 0, + 'mx_AutocompleteInput_suggestion--selected': isSelected, }); const withContainer = (children: ReactNode): ReactNode => ( @@ -155,7 +156,10 @@ export const AutocompleteInput: React.FC = ({ key={s.completionId} data-testid={`autocomplete-suggestion-item-${s.completionId}`} > - { children } +
+ { children } +
+ { isSelected && }
); From 102f6bd567b859c35a4de514522434345b4c5010 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Mon, 21 Nov 2022 13:02:24 +0100 Subject: [PATCH 04/15] uses ellipsis character, introduces form element, removes unwanted box-shadow --- res/css/structures/_AutocompleteInput.pcss | 1 - .../structures/AutocompleteInput.tsx | 4 +--- .../views/settings/AddPrivilegedUsers.tsx | 18 +++++++++++------- src/i18n/strings/en_EN.json | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/res/css/structures/_AutocompleteInput.pcss b/res/css/structures/_AutocompleteInput.pcss index 2a3ca585a25..7a54261913c 100644 --- a/res/css/structures/_AutocompleteInput.pcss +++ b/res/css/structures/_AutocompleteInput.pcss @@ -11,7 +11,6 @@ border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; z-index: 1000; - box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; } .mx_AutocompleteInput_suggestion { diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx index 9656b4a78fd..5495af3e4ec 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -77,9 +77,7 @@ export const AutocompleteInput: React.FC = ({ }, [getSuggestions, selection]); const focusEditor = () => { - if (editorRef && editorRef.current) { - editorRef.current.focus(); - } + editorRef?.current?.focus(); }; const removeSelection = (t: ICompletion) => { diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx index 74881c062df..49eab87e312 100644 --- a/src/components/views/settings/AddPrivilegedUsers.tsx +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -45,8 +45,10 @@ export const AddPrivilegedUsers: React.FC = ({ room, de [room, defaultUserLevel], ); - const onSubmit = async () => { + const onSubmit = async (event) => { + event.preventDefault(); setIsLoading(true); + const userIds = selectedUsers.map(selectedUser => selectedUser.completionId); const powerLevelEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); @@ -65,25 +67,27 @@ export const AddPrivilegedUsers: React.FC = ({ room, de }; return ( - +
+ { _t('Add privileged users') } +
+ { _t('Give one or multiple users in this room more privileges') } +
{ _t('Apply') } - + ); }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a32bc389a92..d008c8e44e2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1301,7 +1301,7 @@ "Failed to change power level": "Failed to change power level", "Add privileged users": "Add privileged users", "Give one or multiple users in this room more privileges": "Give one or multiple users in this room more privileges", - "Search users in this room ...": "Search users in this room ...", + "Search users in this room …": "Search users in this room …", "Apply": "Apply", "Remove": "Remove", "This bridge was provisioned by .": "This bridge was provisioned by .", From 1f9a04592b86931fcf34be69fd0cae4043d5f9fe Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Tue, 22 Nov 2022 08:23:48 +0100 Subject: [PATCH 05/15] changes selection to be completely controlled by parent component --- .../structures/AutocompleteInput.tsx | 133 +++++++----------- .../views/settings/AddPrivilegedUsers.tsx | 45 +++--- .../structures/AutocompleteInput-test.tsx | 98 ++++++++----- 3 files changed, 138 insertions(+), 138 deletions(-) diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx index 5495af3e4ec..db7d1b1cb25 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useState, ReactNode, ChangeEvent, KeyboardEvent, useEffect, useCallback, useRef } from 'react'; +import React, { useState, ReactNode, ChangeEvent, KeyboardEvent, useRef } from 'react'; import classNames from 'classnames'; import Autocompleter from "../../autocomplete/AutocompleteProvider"; @@ -27,10 +27,10 @@ import { Icon as CheckmarkIcon } from '../../../res/img/element-icons/roomlist/c interface AutocompleteInputProps { provider: Autocompleter; placeholder: string; + selection: ICompletion[]; + onSelectionChange: (selection: ICompletion[]) => void; maxSuggestions?: number; renderSuggestion?: (s: ICompletion) => ReactNode; - selection?: ICompletion[]; - onSelectionChange?: (selection: ICompletion[]) => void; renderSelection?: (m: ICompletion) => ReactNode; additionalFilter?: (suggestion: ICompletion) => boolean; } @@ -45,99 +45,74 @@ export const AutocompleteInput: React.FC = ({ selection, additionalFilter, }) => { - const [_selection, setSelection] = useState([]); - const [suggestions, setSuggestions] = useState([]); const [query, setQuery] = useState(''); - const [focusedElement, setFocusedElement] = useState(null); - + const [suggestions, setSuggestions] = useState([]); + const [isFocused, setFocused] = useState(false); const editorContainerRef = useRef(); const editorRef = useRef(); - const getSuggestions = useCallback(async () => { - if (query) { - let matches = await provider.getCompletions( - query, - { start: query.length, end: query.length }, - true, - maxSuggestions, - ); - if (additionalFilter) { - matches = matches.filter(additionalFilter); - } - setSuggestions(matches); - } - }, [query, maxSuggestions, provider, additionalFilter]); - - useEffect(() => { - getSuggestions(); - - if (selection) { - setSelection(selection); - } - }, [getSuggestions, selection]); - const focusEditor = () => { editorRef?.current?.focus(); }; - const removeSelection = (t: ICompletion) => { - const newSelection = _selection.map(s => s); - const idx = _selection.findIndex(s => s.completionId === t.completionId); + const onQueryChange = async (e: ChangeEvent) => { + const value = e.target.value.trim(); - if (idx >= 0) { - newSelection.splice(idx, 1); - if (onSelectionChange) { - onSelectionChange(newSelection); - } - setSelection(newSelection); - } + setQuery(value); - setQuery(''); - }; + let matches = await provider.getCompletions( + query, + { start: query.length, end: query.length }, + true, + maxSuggestions, + ); - const onFilterChange = async (e: ChangeEvent) => { - e.stopPropagation(); - e.preventDefault(); + if (additionalFilter) { + matches = matches.filter(additionalFilter); + } - setQuery(e.target.value.trim()); + setSuggestions(matches); }; - const onClickInputArea = (e) => { - e.stopPropagation(); - e.preventDefault(); - + 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) { - e.preventDefault(); - removeSelection(_selection[_selection.length - 1]); + if (!query && selection.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) { + removeSelection(selection[selection.length - 1]); } }; - const toggleSelection = (e: ICompletion) => { - const newSelection = _selection.map(t => t); - const idx = _selection.findIndex(s => s.completionId === e.completionId); - if (idx >= 0) { - newSelection.splice(idx, 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(e); + newSelection.push(completion); } - if (onSelectionChange) { + 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); } - setSelection(newSelection); - setQuery(''); - - focusEditor(); }; - const _renderSuggestion = (s: ICompletion): ReactNode => { - const isSelected = _selection.findIndex(selection => selection.completionId === s.completionId) >= 0; + const _renderSuggestion = (completion: ICompletion): ReactNode => { + const isSelected = selection.findIndex(selection => selection.completionId === completion.completionId) >= 0; const classes = classNames({ 'mx_AutocompleteInput_suggestion': true, 'mx_AutocompleteInput_suggestion--selected': isSelected, @@ -149,10 +124,10 @@ export const AutocompleteInput: React.FC = ({ e.preventDefault(); e.stopPropagation(); - toggleSelection(s); + toggleSelection(completion); }} - key={s.completionId} - data-testid={`autocomplete-suggestion-item-${s.completionId}`} + key={completion.completionId} + data-testid={`autocomplete-suggestion-item-${completion.completionId}`} >
{ children } @@ -162,13 +137,13 @@ export const AutocompleteInput: React.FC = ({ ); if (renderSuggestion) { - return withContainer(renderSuggestion(s)); + return withContainer(renderSuggestion(completion)); } return withContainer( <> - { s.completion } - { s.completionId } + { completion.completion } + { completion.completionId } , ); }; @@ -202,7 +177,7 @@ export const AutocompleteInput: React.FC = ({ ); }; - const hasPlaceholder = (): boolean => _selection.length === 0 && query.length === 0; + const hasPlaceholder = (): boolean => selection.length === 0 && query.length === 0; return (
@@ -212,22 +187,22 @@ export const AutocompleteInput: React.FC = ({ onClick={onClickInputArea} data-testid="autocomplete-editor" > - { _selection.map(s => _renderSelection(s)) } + { selection.map(s => _renderSelection(s)) } setFocusedElement(e.target)} - onBlur={() => setFocusedElement(null)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} data-testid="autocomplete-input" />
{ - (focusedElement === editorRef.current && suggestions.length) ? ( + (isFocused && suggestions.length) ? (
= ({ room, de }; return ( -
- { _t('Add privileged users') } -
- { _t('Give one or multiple users in this room more privileges') } -
- - - - { _t('Apply') } - + +
+ { _t('Add privileged users') } +
+ { _t('Give one or multiple users in this room more privileges') } +
+ + + + { _t('Apply') } + +
); }; diff --git a/test/components/structures/AutocompleteInput-test.tsx b/test/components/structures/AutocompleteInput-test.tsx index 3950a17050e..d0ee11dbcf9 100644 --- a/test/components/structures/AutocompleteInput-test.tsx +++ b/test/components/structures/AutocompleteInput-test.tsx @@ -28,8 +28,8 @@ describe('AutocompleteInput', () => { { type: 'user', completion: 'user_2', completionId: '@user_2:host.local', range: { start: 1, end: 1 } }, ]; - const mockProvider = (data) => ({ - getCompletions: jest.fn().mockImplementation(async (query) => query ? data : []), + const constructMockProvider = (data) => ({ + getCompletions: jest.fn().mockImplementation(async () => data), }) as unknown as AutocompleteProvider; beforeEach(async () => { @@ -44,8 +44,17 @@ describe('AutocompleteInput', () => { }; it('should render suggestions when a query is set', async () => { - const _mockProvider = mockProvider(mockCompletion); - render(); + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + + render( + , + ); const input = getEditorInput(); @@ -54,14 +63,21 @@ describe('AutocompleteInput', () => { fireEvent.change(input, { target: { value: 'user' } }); }); - await waitFor(() => expect(_mockProvider.getCompletions).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockProvider.getCompletions).toHaveBeenCalledTimes(1)); expect(screen.getByTestId('autocomplete-matches').childNodes).toHaveLength(mockCompletion.length); }); - it('should render selection when used as a controlled input', async () => { - const _mockProvider = mockProvider(mockCompletion); + it('should render selected items passed in via props', async () => { + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + render( - , + , ); const editor = screen.getByTestId('autocomplete-editor'); @@ -70,12 +86,13 @@ describe('AutocompleteInput', () => { }); it('should call onSelectionChange() when an item is removed from selection', async () => { - const _mockProvider = mockProvider(mockCompletion); + const mockProvider = constructMockProvider(mockCompletion); const onSelectionChangeMock = jest.fn(); + render( , @@ -94,16 +111,19 @@ describe('AutocompleteInput', () => { }); it('should render custom selection element when renderSelection() is defined', async () => { - const _mockProvider = mockProvider(mockCompletion); + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + const renderSelection = () => ( custom selection element ); render( , ); @@ -112,15 +132,19 @@ describe('AutocompleteInput', () => { }); it('should render custom suggestion element when renderSuggestion() is defined', async () => { - const _mockProvider = mockProvider(mockCompletion); + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + const renderSuggestion = () => ( custom suggestion element ); render( , ); @@ -132,17 +156,20 @@ describe('AutocompleteInput', () => { fireEvent.change(input, { target: { value: 'user' } }); }); - await waitFor(() => expect(_mockProvider.getCompletions).toHaveBeenCalledTimes(1)); + 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 = mockProvider(mockCompletion); + const mockProvider = constructMockProvider(mockCompletion); + const onSelectionChangeMock = jest.fn(); + const { container } = render( , ); @@ -153,23 +180,25 @@ describe('AutocompleteInput', () => { fireEvent.change(input, { target: { value: 'user' } }); }); - await waitFor(() => expect(_mockProvider.getCompletions).toHaveBeenCalledTimes(1)); + 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')); + suggestions.map(suggestion => expect(suggestion).toHaveClass('mx_AutocompleteInput_suggestion--selected')); }); it('should remove the last added selection when backspace is pressed in empty input', async () => { - const _mockProvider = mockProvider(mockCompletion); + const mockProvider = constructMockProvider(mockCompletion); const onSelectionChangeMock = jest.fn(); + render( , ); + const input = getEditorInput(); act(() => { @@ -179,13 +208,15 @@ describe('AutocompleteInput', () => { expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]); }); - it('should toggle a selection when a suggestion is clicked', async () => { - const _mockProvider = mockProvider(mockCompletion); + it('should toggle a selected item when a suggestion is clicked', async () => { + const mockProvider = constructMockProvider(mockCompletion); const onSelectionChangeMock = jest.fn(); + const { container } = render( , ); @@ -198,19 +229,12 @@ describe('AutocompleteInput', () => { }); const suggestions = await within(container).findAllByTestId('autocomplete-suggestion-item', { exact: false }); - expect(suggestions).toHaveLength(mockCompletion.length); act(() => { fireEvent.mouseDown(suggestions[0]); }); expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[0]]); - - act(() => { - fireEvent.mouseDown(suggestions[0]); - }); - - expect(onSelectionChangeMock).toHaveBeenCalledWith([]); }); afterAll(() => { From 84bc8d409066bd6683ca88dbc392f202a7d6aa7e Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Tue, 22 Nov 2022 11:53:50 +0100 Subject: [PATCH 06/15] reuses SettingsFieldSet component with some inline-style hacks --- .../views/settings/AddPrivilegedUsers.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx index 364e3898c5e..1c5497fb5fe 100644 --- a/src/components/views/settings/AddPrivilegedUsers.tsx +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -27,6 +27,7 @@ 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; @@ -66,12 +67,12 @@ export const AddPrivilegedUsers: React.FC = ({ room, de }; return ( -
-
- { _t('Add privileged users') } -
- { _t('Give one or multiple users in this room more privileges') } -
+ + = ({ room, de { _t('Apply') } -
+
); }; From 4c00725ad8eb9081f08e5338362dc0480d5173d3 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Wed, 23 Nov 2022 11:16:18 +0100 Subject: [PATCH 07/15] utilizes existing useFocus hook, fixes TypeScript issues in strict mode, outsources selection & suggestion render function to separate components --- res/css/structures/_AutocompleteInput.pcss | 35 ++-- .../structures/AutocompleteInput.tsx | 182 ++++++++++-------- .../views/settings/AddPrivilegedUsers.tsx | 16 +- .../tabs/room/RolesRoomSettingsTab.tsx | 6 +- 4 files changed, 149 insertions(+), 90 deletions(-) diff --git a/res/css/structures/_AutocompleteInput.pcss b/res/css/structures/_AutocompleteInput.pcss index 7a54261913c..a851ab3628b 100644 --- a/res/css/structures/_AutocompleteInput.pcss +++ b/res/css/structures/_AutocompleteInput.pcss @@ -1,3 +1,19 @@ +/* +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; } @@ -59,24 +75,21 @@ transition: border-color 0.25s; border: 1px solid $input-border-color; - > input[type="text"] { - margin: 6px 0 !important; - height: 24px; + > input { + flex: 1; + height: $font-24px; line-height: $font-24px; - font-size: $font-14px; - padding-left: $spacing-12; - border: 0 !important; - outline: 0 !important; - resize: none; - box-sizing: border-box; min-width: 40%; - flex: 1 !important; + resize: none; + // `!important` is required to bypass global input styles. + margin: 6px 0 !important; + border-color: transparent !important; color: $primary-content !important; + font-weight: normal !important; &::placeholder { color: $primary-content !important; font-weight: normal !important; - font-size: 1.4rem; } } } diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx index db7d1b1cb25..97118d36aa1 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useState, ReactNode, ChangeEvent, KeyboardEvent, useRef } from 'react'; +import React, { useState, ReactNode, ChangeEvent, KeyboardEvent, useRef, ReactElement } from 'react'; import classNames from 'classnames'; import Autocompleter from "../../autocomplete/AutocompleteProvider"; @@ -23,6 +23,7 @@ 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 CheckmarkIcon } from '../../../res/img/element-icons/roomlist/checkmark.svg'; +import useFocus from "../../hooks/useFocus"; interface AutocompleteInputProps { provider: Autocompleter; @@ -30,8 +31,8 @@ interface AutocompleteInputProps { selection: ICompletion[]; onSelectionChange: (selection: ICompletion[]) => void; maxSuggestions?: number; - renderSuggestion?: (s: ICompletion) => ReactNode; - renderSelection?: (m: ICompletion) => ReactNode; + renderSuggestion?: (s: ICompletion) => ReactElement; + renderSelection?: (m: ICompletion) => ReactElement; additionalFilter?: (suggestion: ICompletion) => boolean; } @@ -47,9 +48,9 @@ export const AutocompleteInput: React.FC = ({ }) => { const [query, setQuery] = useState(''); const [suggestions, setSuggestions] = useState([]); - const [isFocused, setFocused] = useState(false); - const editorContainerRef = useRef(); - const editorRef = useRef(); + const [isFocused, onFocusChangeHandlerFunctions] = useFocus(); + const editorContainerRef = useRef(null); + const editorRef = useRef(null); const focusEditor = () => { editorRef?.current?.focus(); @@ -111,72 +112,6 @@ export const AutocompleteInput: React.FC = ({ } }; - const _renderSuggestion = (completion: ICompletion): ReactNode => { - const isSelected = selection.findIndex(selection => selection.completionId === completion.completionId) >= 0; - const classes = classNames({ - 'mx_AutocompleteInput_suggestion': true, - 'mx_AutocompleteInput_suggestion--selected': isSelected, - }); - - const withContainer = (children: ReactNode): ReactNode => ( -
{ - e.preventDefault(); - e.stopPropagation(); - - toggleSelection(completion); - }} - key={completion.completionId} - data-testid={`autocomplete-suggestion-item-${completion.completionId}`} - > -
- { children } -
- { isSelected && } -
- ); - - if (renderSuggestion) { - return withContainer(renderSuggestion(completion)); - } - - return withContainer( - <> - { completion.completion } - { completion.completionId } - , - ); - }; - - const _renderSelection = (s: ICompletion): ReactNode => { - const withContainer = (children: ReactNode): ReactNode => ( - - - { children } - - removeSelection(s)} - data-testid={`autocomplete-selection-remove-button-${s.completionId}`} - > - - - - ); - - if (renderSelection) { - return withContainer(renderSelection(s)); - } - - return withContainer( - { s.completion }, - ); - }; - const hasPlaceholder = (): boolean => selection.length === 0 && query.length === 0; return ( @@ -187,7 +122,16 @@ export const AutocompleteInput: React.FC = ({ onClick={onClickInputArea} data-testid="autocomplete-editor" > - { selection.map(s => _renderSelection(s)) } + { + selection.map(item => ( + + )) + } = ({ onChange={onQueryChange} value={query} autoComplete="off" - placeholder={hasPlaceholder() ? placeholder : null} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} + placeholder={hasPlaceholder() ? placeholder : undefined} data-testid="autocomplete-input" + {...onFocusChangeHandlerFunctions} />
{ @@ -209,7 +152,15 @@ export const AutocompleteInput: React.FC = ({ data-testid="autocomplete-matches" > { - suggestions.map((s) => _renderSuggestion(s)) + suggestions.map((item) => ( + + )) }
) : null @@ -217,3 +168,80 @@ export const AutocompleteInput: React.FC = ({ ); }; + +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 } +
+ { isSelected && } +
+ ); + + if (render) { + return withContainer(render(item)); + } + + return withContainer( + <> + { item.completion } + { item.completionId } + , + ); +}; diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx index 1c5497fb5fe..a5e8252cd3e 100644 --- a/src/components/views/settings/AddPrivilegedUsers.tsx +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -41,7 +41,19 @@ export const AddPrivilegedUsers: React.FC = ({ room, de const [powerLevel, setPowerLevel] = useState(defaultUserLevel); const [selectedUsers, setSelectedUsers] = useState([]); const filterSuggestions = useCallback( - (user: ICompletion) => room.getMember(user.completionId)?.powerLevel <= defaultUserLevel, + (user: ICompletion) => { + if (user.completionId === undefined) { + return false; + } + + const member = room.getMember(user.completionId); + + if (member === null) { + return false; + } + + return member.powerLevel <= defaultUserLevel; + }, [room, defaultUserLevel], ); @@ -53,6 +65,8 @@ export const AddPrivilegedUsers: React.FC = ({ room, de const powerLevelEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); try { + // TODO: Remove @ts-ignore as soon as https://github.com/matrix-org/matrix-js-sdk/pull/2892 is merged. + // @ts-ignore await client.setPowerLevel(room.roomId, userIds, powerLevel, powerLevelEvent); setSelectedUsers([]); setPowerLevel(defaultUserLevel); diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index cc2b749bd65..a013e11724b 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -471,7 +471,11 @@ export default class RolesRoomSettingsTab extends React.Component {
{ _t("Roles & Permissions") }
{ privilegedUsersSection } - { canChangeLevels && } + { + (canChangeLevels && room !== null) && ( + + ) + } { mutedUsersSection } { bannedUsersSection } Date: Wed, 30 Nov 2022 11:27:54 +0100 Subject: [PATCH 08/15] resolves TODO, resolves remaining typescript strict type checks --- src/components/views/settings/AddPrivilegedUsers.tsx | 6 ++---- test/components/structures/AutocompleteInput-test.tsx | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx index a5e8252cd3e..6413d345ff0 100644 --- a/src/components/views/settings/AddPrivilegedUsers.tsx +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useContext, useRef, useState } from 'react'; +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"; @@ -57,7 +57,7 @@ export const AddPrivilegedUsers: React.FC = ({ room, de [room, defaultUserLevel], ); - const onSubmit = async (event) => { + const onSubmit = async (event: FormEvent) => { event.preventDefault(); setIsLoading(true); @@ -65,8 +65,6 @@ export const AddPrivilegedUsers: React.FC = ({ room, de const powerLevelEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); try { - // TODO: Remove @ts-ignore as soon as https://github.com/matrix-org/matrix-js-sdk/pull/2892 is merged. - // @ts-ignore await client.setPowerLevel(room.roomId, userIds, powerLevel, powerLevelEvent); setSelectedUsers([]); setPowerLevel(defaultUserLevel); diff --git a/test/components/structures/AutocompleteInput-test.tsx b/test/components/structures/AutocompleteInput-test.tsx index d0ee11dbcf9..224ae350b1a 100644 --- a/test/components/structures/AutocompleteInput-test.tsx +++ b/test/components/structures/AutocompleteInput-test.tsx @@ -28,7 +28,7 @@ describe('AutocompleteInput', () => { { type: 'user', completion: 'user_2', completionId: '@user_2:host.local', range: { start: 1, end: 1 } }, ]; - const constructMockProvider = (data) => ({ + const constructMockProvider = (data: ICompletion[]) => ({ getCompletions: jest.fn().mockImplementation(async () => data), }) as unknown as AutocompleteProvider; From 8956d9f20ce411af62dbe39d6bf4da11fe63b5e5 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Fri, 2 Dec 2022 13:10:01 +0100 Subject: [PATCH 09/15] adds magnifier icon, aligned look and feel to regular input component, updates suggestion background color --- res/css/structures/_AutocompleteInput.pcss | 126 ++++++++++-------- res/img/element-icons/roomlist/search.svg | 2 +- .../structures/AutocompleteInput.tsx | 11 +- .../views/settings/AddPrivilegedUsers.tsx | 2 +- src/i18n/strings/en_EN.json | 2 +- 5 files changed, 83 insertions(+), 60 deletions(-) diff --git a/res/css/structures/_AutocompleteInput.pcss b/res/css/structures/_AutocompleteInput.pcss index a851ab3628b..b54250e1554 100644 --- a/res/css/structures/_AutocompleteInput.pcss +++ b/res/css/structures/_AutocompleteInput.pcss @@ -18,49 +18,9 @@ limitations under the License. position: relative; } -.mx_AutocompleteInput_matches { - position: absolute; - left: 0; - right: 0; - background-color: $background; - border: 1px solid $input-border-color; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - z-index: 1000; -} - -.mx_AutocompleteInput_suggestion { - display: flex; - align-items: center; - justify-content: space-between; - padding: $spacing-8; - position: relative; - cursor: pointer; - - > * { - user-select: none; - } - - &:hover { - background-color: $focus-bg-color; - } -} - -.mx_AutocompleteInput_suggestion--selected { - background-color: $focus-bg-color; - - svg { - color: $username-variant1-color; - } -} - -.mx_AutocompleteInput_suggestion_title { - margin-right: $spacing-8; -} - -.mx_AutocompleteInput_suggestion_description { - color: $secondary-content; - font-size: $font-12px; +.mx_AutocompleteInput_search_icon { + margin-left: $spacing-8; + fill: $secondary-content; } .mx_AutocompleteInput_editor { @@ -68,22 +28,20 @@ limitations under the License. display: flex; flex-wrap: wrap; align-items: center; - border-radius: 4px; - min-height: 25px; overflow-x: hidden; overflow-y: auto; - transition: border-color 0.25s; border: 1px solid $input-border-color; + border-radius: 4px; + transition: border-color 0.25s; > input { flex: 1; - height: $font-24px; - line-height: $font-24px; min-width: 40%; resize: none; // `!important` is required to bypass global input styles. - margin: 6px 0 !important; - border-color: transparent !important; + margin: 0 !important; + padding: $spacing-8 9px; + border: none !important; color: $primary-content !important; font-weight: normal !important; @@ -94,6 +52,15 @@ limitations under the License. } } +.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; @@ -103,15 +70,66 @@ limitations under the License. display: flex; align-items: center; border-radius: 12px; - height: 24px; padding-left: $spacing-8; padding-right: $spacing-8; background-color: $username-variant1-color; color: #ffffff; + font-size: $font-12px; } -.mx_AutocompleteInput_editor_selection_remove { +.mx_AutocompleteInput_editor_selection_remove_button { padding: 0 $spacing-4; - max-height: $font-24px; - line-height: $font-24px; +} + +.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; + justify-content: space-between; + padding: $spacing-8; + position: relative; + 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; + + svg { + color: $username-variant1-color; + } + + &: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 index 97118d36aa1..2ba1548ed83 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -23,6 +23,7 @@ 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 CheckmarkIcon } from '../../../res/img/element-icons/roomlist/checkmark.svg'; +import { Icon as SearchIcon } from '../../../res/img/element-icons/roomlist/search.svg'; import useFocus from "../../hooks/useFocus"; interface AutocompleteInputProps { @@ -58,7 +59,6 @@ export const AutocompleteInput: React.FC = ({ const onQueryChange = async (e: ChangeEvent) => { const value = e.target.value.trim(); - setQuery(value); let matches = await provider.getCompletions( @@ -118,10 +118,15 @@ export const AutocompleteInput: React.FC = ({
0, + })} onClick={onClickInputArea} data-testid="autocomplete-editor" > + { selection.map(item => ( = ({ item, onClick, render }) { children } onClick(item)} data-testid={`autocomplete-selection-remove-button-${item.completionId}`} > diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx index 6413d345ff0..584b18db499 100644 --- a/src/components/views/settings/AddPrivilegedUsers.tsx +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -87,7 +87,7 @@ export const AddPrivilegedUsers: React.FC = ({ room, de > .": "This bridge was provisioned by .", From 9b3c90bc505388c84135b5958801b134b1230b0c Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Fri, 2 Dec 2022 14:59:08 +0100 Subject: [PATCH 10/15] removes check icon as suggested by design review --- res/css/structures/_AutocompleteInput.pcss | 6 ------ src/components/structures/AutocompleteInput.tsx | 6 +----- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/res/css/structures/_AutocompleteInput.pcss b/res/css/structures/_AutocompleteInput.pcss index b54250e1554..754c8ae1944 100644 --- a/res/css/structures/_AutocompleteInput.pcss +++ b/res/css/structures/_AutocompleteInput.pcss @@ -96,9 +96,7 @@ limitations under the License. .mx_AutocompleteInput_suggestion { display: flex; align-items: center; - justify-content: space-between; padding: $spacing-8; - position: relative; cursor: pointer; > * { @@ -115,10 +113,6 @@ limitations under the License. .mx_AutocompleteInput_suggestion--selected { background-color: $quinary-content; - svg { - color: $username-variant1-color; - } - &:last-child { border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; diff --git a/src/components/structures/AutocompleteInput.tsx b/src/components/structures/AutocompleteInput.tsx index 2ba1548ed83..1088f6a3790 100644 --- a/src/components/structures/AutocompleteInput.tsx +++ b/src/components/structures/AutocompleteInput.tsx @@ -22,7 +22,6 @@ 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 CheckmarkIcon } from '../../../res/img/element-icons/roomlist/checkmark.svg'; import { Icon as SearchIcon } from '../../../res/img/element-icons/roomlist/search.svg'; import useFocus from "../../hooks/useFocus"; @@ -232,10 +231,7 @@ const SuggestionItem: React.FC = ({ item, selection, onClic }} data-testid={`autocomplete-suggestion-item-${item.completionId}`} > -
- { children } -
- { isSelected && } + { children }
); From 027a044d539684b2474419e19062431c5fbe8e72 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Mon, 5 Dec 2022 15:32:27 +0100 Subject: [PATCH 11/15] filters out undefined values --- src/components/views/settings/AddPrivilegedUsers.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx index 584b18db499..cf030b1e34b 100644 --- a/src/components/views/settings/AddPrivilegedUsers.tsx +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -61,7 +61,9 @@ export const AddPrivilegedUsers: React.FC = ({ room, de event.preventDefault(); setIsLoading(true); - const userIds = selectedUsers.map(selectedUser => selectedUser.completionId); + const userIds = selectedUsers + .map(selectedUser => selectedUser.completionId) + .filter(userId => userId !== undefined); const powerLevelEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); try { From bd9e453c69f02d43640553d295892a7e1d999cbd Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Tue, 6 Dec 2022 13:22:14 +0100 Subject: [PATCH 12/15] adds happy path test for component --- .../views/elements/PowerSelector.tsx | 11 +- .../views/settings/AddPrivilegedUsers.tsx | 42 ++++--- .../settings/AddPrivilegedUsers-test.tsx | 114 ++++++++++++++++++ 3 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 test/components/views/settings/AddPrivilegedUsers-test.tsx 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 index cf030b1e34b..031e3df58ac 100644 --- a/src/components/views/settings/AddPrivilegedUsers.tsx +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FormEvent, useCallback, useContext, useRef, useState } from 'react'; +import React, { FormEvent, useContext, useRef, useState } from 'react'; import { Room } from 'matrix-js-sdk/src/models/room'; import { EventType } from "matrix-js-sdk/src/@types/event"; @@ -40,30 +40,31 @@ export const AddPrivilegedUsers: React.FC = ({ room, de const [isLoading, setIsLoading] = useState(false); const [powerLevel, setPowerLevel] = useState(defaultUserLevel); const [selectedUsers, setSelectedUsers] = useState([]); - const filterSuggestions = useCallback( - (user: ICompletion) => { - if (user.completionId === undefined) { - return false; - } - - const member = room.getMember(user.completionId); - - if (member === null) { - return false; - } - - return member.powerLevel <= defaultUserLevel; - }, - [room, defaultUserLevel], - ); + // const filterSuggestions = useCallback( + // (user: ICompletion) => { + // if (user.completionId === undefined) { + // return false; + // } + // + // const member = room.getMember(user.completionId); + // + // if (member === null) { + // return false; + // } + // + // return member.powerLevel <= defaultUserLevel; + // }, + // [room, defaultUserLevel], + // ); const onSubmit = async (event: FormEvent) => { event.preventDefault(); setIsLoading(true); const userIds = selectedUsers - .map(selectedUser => selectedUser.completionId) - .filter(userId => userId !== undefined); + .filter(selectedUser => selectedUser.completionId !== undefined) + // undefined completionId's are filtered out but TypeScript does not seem to understand. + .map(selectedUser => selectedUser.completionId!); const powerLevelEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); try { @@ -92,7 +93,7 @@ export const AddPrivilegedUsers: React.FC = ({ room, de placeholder={_t("Search users in this room…")} onSelectionChange={setSelectedUsers} selection={selectedUsers} - additionalFilter={filterSuggestions} + // additionalFilter={filterSuggestions} /> = ({ room, de kind='primary' disabled={!selectedUsers.length || isLoading} onClick={null} + data-testid='add-privileged-users-submit-button' > { _t('Apply') } diff --git a/test/components/views/settings/AddPrivilegedUsers-test.tsx b/test/components/views/settings/AddPrivilegedUsers-test.tsx new file mode 100644 index 00000000000..d228b2377de --- /dev/null +++ b/test/components/views/settings/AddPrivilegedUsers-test.tsx @@ -0,0 +1,114 @@ +/* +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 { + getMockClientWithEventEmitter, + makeRoomWithStateEvents, +} from "../../../test-utils"; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; +import { AddPrivilegedUsers } from "../../../../src/components/views/settings/AddPrivilegedUsers"; +import UserProvider from "../../../../src/autocomplete/UserProvider"; + +jest.mock('../../../../src/autocomplete/UserProvider'); + +describe('', () => { + const provider = mocked(UserProvider, { shallow: true }); + provider.prototype.getCompletions.mockResolvedValue([ + { 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 mockClient = getMockClientWithEventEmitter({ + // `makeRoomWithStateEvents` only work's if `getRoom` is present. + getRoom: jest.fn(), + setPowerLevel: jest.fn(), + }); + const room = makeRoomWithStateEvents([], { roomId: 'room_id', mockClient: mockClient }); + + 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(); + }); +}); From 360a7bd62634d72511e101a79295beaebeee8909 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Tue, 6 Dec 2022 16:27:10 +0100 Subject: [PATCH 13/15] restructures code to improve testability --- .../views/settings/AddPrivilegedUsers.tsx | 54 +++++++++++-------- .../settings/AddPrivilegedUsers-test.tsx | 39 ++++++++++++-- 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx index 031e3df58ac..cf9099c522c 100644 --- a/src/components/views/settings/AddPrivilegedUsers.tsx +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FormEvent, useContext, useRef, useState } from 'react'; +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"; @@ -40,31 +40,16 @@ export const AddPrivilegedUsers: React.FC = ({ room, de const [isLoading, setIsLoading] = useState(false); const [powerLevel, setPowerLevel] = useState(defaultUserLevel); const [selectedUsers, setSelectedUsers] = useState([]); - // const filterSuggestions = useCallback( - // (user: ICompletion) => { - // if (user.completionId === undefined) { - // return false; - // } - // - // const member = room.getMember(user.completionId); - // - // if (member === null) { - // return false; - // } - // - // return member.powerLevel <= defaultUserLevel; - // }, - // [room, defaultUserLevel], - // ); + const hasLowerOrEqualLevelThanDefaultLevelFilter = useCallback( + (user: ICompletion) => hasLowerOrEqualLevelThanDefaultLevel(room, user, defaultUserLevel), + [room, defaultUserLevel], + ); const onSubmit = async (event: FormEvent) => { event.preventDefault(); setIsLoading(true); - const userIds = selectedUsers - .filter(selectedUser => selectedUser.completionId !== undefined) - // undefined completionId's are filtered out but TypeScript does not seem to understand. - .map(selectedUser => selectedUser.completionId!); + const userIds = getUserIdsFromCompletions(selectedUsers); const powerLevelEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); try { @@ -93,7 +78,7 @@ export const AddPrivilegedUsers: React.FC = ({ room, de placeholder={_t("Search users in this room…")} onSelectionChange={setSelectedUsers} selection={selectedUsers} - // additionalFilter={filterSuggestions} + additionalFilter={hasLowerOrEqualLevelThanDefaultLevelFilter} /> = ({ room, de ); }; + +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/test/components/views/settings/AddPrivilegedUsers-test.tsx b/test/components/views/settings/AddPrivilegedUsers-test.tsx index d228b2377de..21d1b6b0ca2 100644 --- a/test/components/views/settings/AddPrivilegedUsers-test.tsx +++ b/test/components/views/settings/AddPrivilegedUsers-test.tsx @@ -17,30 +17,45 @@ 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 } from "matrix-js-sdk/src/matrix"; import { getMockClientWithEventEmitter, makeRoomWithStateEvents, } from "../../../test-utils"; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; -import { AddPrivilegedUsers } from "../../../../src/components/views/settings/AddPrivilegedUsers"; +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([ - { 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 } }, - ]); + 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; + }; const getComponent = () => @@ -111,4 +126,18 @@ describe('', () => { 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); + }, + ); }); From 8de4f27bcb92f2722c74d0398c921a3f291fc044 Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Wed, 7 Dec 2022 11:05:04 +0100 Subject: [PATCH 14/15] handles not existing RoomPowerLevels event gracefully --- src/components/views/settings/AddPrivilegedUsers.tsx | 10 ++++++++++ .../views/settings/AddPrivilegedUsers-test.tsx | 10 +++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/views/settings/AddPrivilegedUsers.tsx b/src/components/views/settings/AddPrivilegedUsers.tsx index cf9099c522c..f85699c7413 100644 --- a/src/components/views/settings/AddPrivilegedUsers.tsx +++ b/src/components/views/settings/AddPrivilegedUsers.tsx @@ -52,6 +52,16 @@ export const AddPrivilegedUsers: React.FC = ({ room, de 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([]); diff --git a/test/components/views/settings/AddPrivilegedUsers-test.tsx b/test/components/views/settings/AddPrivilegedUsers-test.tsx index 21d1b6b0ca2..67258e47df8 100644 --- a/test/components/views/settings/AddPrivilegedUsers-test.tsx +++ b/test/components/views/settings/AddPrivilegedUsers-test.tsx @@ -17,11 +17,12 @@ 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 } from "matrix-js-sdk/src/matrix"; +import { RoomMember, EventType } from "matrix-js-sdk/src/matrix"; import { getMockClientWithEventEmitter, makeRoomWithStateEvents, + mkEvent, } from "../../../test-utils"; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; import { @@ -56,6 +57,13 @@ describe('', () => { return member; }; + (room.currentState.getStateEvents as unknown) = (_eventType: string, _stateKey: string) => { + return mkEvent({ + type: EventType.RoomPowerLevels, + content: {}, + user: 'user_id', + }); + }; const getComponent = () => From 990ea94a84eddf580b17ccfbceda5de7b46c3cce Mon Sep 17 00:00:00 2001 From: Marco Bartelt Date: Thu, 8 Dec 2022 10:48:00 +0100 Subject: [PATCH 15/15] removes async from tests where not required --- test/components/structures/AutocompleteInput-test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/components/structures/AutocompleteInput-test.tsx b/test/components/structures/AutocompleteInput-test.tsx index 224ae350b1a..e7593ebb4b1 100644 --- a/test/components/structures/AutocompleteInput-test.tsx +++ b/test/components/structures/AutocompleteInput-test.tsx @@ -32,7 +32,7 @@ describe('AutocompleteInput', () => { getCompletions: jest.fn().mockImplementation(async () => data), }) as unknown as AutocompleteProvider; - beforeEach(async () => { + beforeEach(() => { TestUtils.stubClient(); }); @@ -67,7 +67,7 @@ describe('AutocompleteInput', () => { expect(screen.getByTestId('autocomplete-matches').childNodes).toHaveLength(mockCompletion.length); }); - it('should render selected items passed in via props', async () => { + it('should render selected items passed in via props', () => { const mockProvider = constructMockProvider(mockCompletion); const onSelectionChangeMock = jest.fn(); @@ -85,7 +85,7 @@ describe('AutocompleteInput', () => { expect(selection).toHaveLength(mockCompletion.length); }); - it('should call onSelectionChange() when an item is removed from selection', async () => { + it('should call onSelectionChange() when an item is removed from selection', () => { const mockProvider = constructMockProvider(mockCompletion); const onSelectionChangeMock = jest.fn(); @@ -110,7 +110,7 @@ describe('AutocompleteInput', () => { expect(onSelectionChangeMock).toHaveBeenCalledWith([mockCompletion[1]]); }); - it('should render custom selection element when renderSelection() is defined', async () => { + it('should render custom selection element when renderSelection() is defined', () => { const mockProvider = constructMockProvider(mockCompletion); const onSelectionChangeMock = jest.fn(); @@ -186,7 +186,7 @@ describe('AutocompleteInput', () => { suggestions.map(suggestion => expect(suggestion).toHaveClass('mx_AutocompleteInput_suggestion--selected')); }); - it('should remove the last added selection when backspace is pressed in empty input', async () => { + it('should remove the last added selection when backspace is pressed in empty input', () => { const mockProvider = constructMockProvider(mockCompletion); const onSelectionChangeMock = jest.fn();