diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 07cd448..f6c0db6 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -39,9 +39,10 @@ "badges.sidebar.title": "Badges", "badges.popover.title": "Badges", - "badges.admin.label": "Achievements Admin:", - "badges.admin.placeholder": "username", - "badges.admin.help_text": "This user will be considered the achievements plugin administrator. They can create types, as well as modify and grant any badges.", + "badges.admin.label": "Achievements Administrators:", + "badges.admin.placeholder": "Start typing a name...", + "badges.admin.help_text": "These users will be considered achievements plugin administrators. They can create types, as well as modify and grant any badges.", + "badges.admin.no_results": "No users found", "badges.rhs.create_badge": "+ Create badge", "badges.rhs.edit_badge": "Edit", diff --git a/webapp/i18n/ru.json b/webapp/i18n/ru.json index d593c51..d69f068 100644 --- a/webapp/i18n/ru.json +++ b/webapp/i18n/ru.json @@ -39,9 +39,10 @@ "badges.sidebar.title": "Значки", "badges.popover.title": "Значки", - "badges.admin.label": "Администратор достижений:", - "badges.admin.placeholder": "имя пользователя", - "badges.admin.help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки.", + "badges.admin.label": "Администраторы достижений:", + "badges.admin.placeholder": "Начните вводить имя...", + "badges.admin.help_text": "Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые значки.", + "badges.admin.no_results": "Пользователь не найден", "badges.rhs.create_badge": "+ Создать значок", "badges.rhs.edit_badge": "Редактировать", diff --git a/webapp/src/components/admin/badges_admin_setting.scss b/webapp/src/components/admin/badges_admin_setting.scss new file mode 100644 index 0000000..21ced86 --- /dev/null +++ b/webapp/src/components/admin/badges_admin_setting.scss @@ -0,0 +1,161 @@ +.admin-user-select { + position: relative; + + &__container { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 4px 8px; + min-height: 34px; + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-radius: 4px; + background: var(--center-channel-bg, #fff); + cursor: text; + + &:focus-within { + border-color: var(--button-bg, #166de0); + box-shadow: 0 0 0 1px var(--button-bg, #166de0); + } + } + + &__icon { + flex-shrink: 0; + color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56); + } + + &__spinner { + flex-shrink: 0; + width: 18px; + height: 18px; + border: 2px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-top-color: var(--button-bg, #166de0); + border-radius: 50%; + animation: admin-user-select-spin 0.6s linear infinite; + } + + &__chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + border-radius: 12px; + background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.1); + color: var(--center-channel-color, #3d3c40); + font-size: 13px; + line-height: 20px; + min-width: 0; + } + + &__chip-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + flex-shrink: 0; + } + + &__chip-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 50%; + background: none; + color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56); + font-size: 14px; + line-height: 1; + cursor: pointer; + flex-shrink: 0; + + &:hover { + background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + color: var(--center-channel-color, #3d3c40); + } + } + + &__input { + flex: 1 1 60px; + min-width: 60px; + padding: 2px 0; + border: none; + outline: none; + background: transparent; + color: var(--center-channel-color, #3d3c40); + font-size: 14px; + line-height: 24px; + + &::placeholder { + color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56); + } + } + + &__dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + max-height: 200px; + overflow-y: auto; + background: var(--center-channel-bg, #fff); + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: 100; + } + + &__option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + font-size: 14px; + color: var(--center-channel-color, #3d3c40); + + &:hover { + background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + } + } + + &__avatar { + width: 24px; + height: 24px; + border-radius: 50%; + flex-shrink: 0; + } + + &__option-name { + font-weight: 600; + } + + &__option-fullname { + color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56); + } + + &__no-results { + padding: 8px 12px; + font-size: 14px; + color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56); + font-style: italic; + + &--loading { + color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.32); + } + } +} + +@keyframes admin-user-select-spin { + to { + transform: rotate(360deg); + } +} diff --git a/webapp/src/components/admin/badges_admin_setting.tsx b/webapp/src/components/admin/badges_admin_setting.tsx index ba3f4f4..9d95b06 100644 --- a/webapp/src/components/admin/badges_admin_setting.tsx +++ b/webapp/src/components/admin/badges_admin_setting.tsx @@ -1,7 +1,22 @@ -/* eslint-disable react/prop-types */ -import React, {useCallback} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; +import {Client4} from 'mattermost-redux/client'; +import {UserProfile} from 'mattermost-redux/types/users'; + +import {debounce, getUserDisplayName} from 'utils/helpers'; +import CloseIcon from 'components/icons/close_icon'; +import SearchIcon from 'components/icons/search_icon'; + +import './badges_admin_setting.scss'; + +type SelectedUser = { + id: string; + username: string; + fullName: string; + avatarUrl: string; +} + type Props = { id: string; value: string; @@ -17,36 +32,203 @@ type Props = { const BadgesAdminSetting: React.FC = ({id, value, disabled, onChange, setSaveNeeded}) => { const intl = useIntl(); + const containerRef = useRef(null); + const inputRef = useRef(null); - const handleChange = useCallback((e: React.ChangeEvent) => { - onChange(id, e.target.value); + const [searchTerm, setSearchTerm] = useState(''); + const [results, setResults] = useState([]); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + + useEffect(() => { + if (!value) { + return; + } + const usernames = value.split(',').map((u) => u.trim()).filter(Boolean); + Promise.all(usernames.map(async (username) => { + try { + const user = await Client4.getUserByUsername(username); + return { + id: user.id, + username: user.username, + fullName: getUserDisplayName(user), + avatarUrl: Client4.getProfilePictureUrl(user.id, user.last_picture_update), + }; + } catch { + return {id: '', username, fullName: '', avatarUrl: ''}; + } + })).then(setSelectedUsers); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + setSearchTerm(''); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const performSearch = async (term: string, excluded: Set) => { + if (!term) { + setResults([]); + setDropdownOpen(false); + setLoading(false); + return; + } + setLoading(true); + try { + const data = await Client4.autocompleteUsers(term, '', '', {limit: 20}); + setResults(data.users.filter((u: UserProfile) => !excluded.has(u.username))); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }; + + const doSearch = useMemo(() => debounce(performSearch, 400), []); // eslint-disable-line react-hooks/exhaustive-deps + + const saveValue = (users: SelectedUser[]) => { + onChange(id, users.map((u) => u.username).join(',')); setSaveNeeded(); - }, [id, onChange, setSaveNeeded]); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const term = e.target.value; + setSearchTerm(term); + if (term) { + setDropdownOpen(true); + } + doSearch(term, new Set(selectedUsers.map((u) => u.username))); + }; + + const handleSelect = (user: UserProfile) => { + const next = [...selectedUsers, { + id: user.id, + username: user.username, + fullName: getUserDisplayName(user), + avatarUrl: Client4.getProfilePictureUrl(user.id, user.last_picture_update), + }]; + setSelectedUsers(next); + saveValue(next); + setSearchTerm(''); + setResults([]); + setDropdownOpen(false); + inputRef.current?.focus(); + }; + + const handleRemove = (username: string) => { + const next = selectedUsers.filter((u) => u.username !== username); + setSelectedUsers(next); + saveValue(next); + }; return (
- +
+
inputRef.current?.focus()} + > + {loading ? ( +
+ ) : ( + + )} + {selectedUsers.map((user) => ( + + {user.avatarUrl && ( + {user.username} + )} + + {user.fullName || user.username} + + {!disabled && ( + + )} + + ))} + +
+ {dropdownOpen && ( +
+ {results.length === 0 && searchTerm && ( +
+ +
+ )} + {results.map((user) => ( +
handleSelect(user)} + > + {user.username} + + {user.username} + + {(user.first_name || user.last_name) && ( + + {'— '}{`${user.first_name} ${user.last_name}`.trim()} + + )} +
+ ))} +
+ )} +
diff --git a/webapp/src/components/badge_modal/index.tsx b/webapp/src/components/badge_modal/index.tsx index bc7115b..e8565fe 100644 --- a/webapp/src/components/badge_modal/index.tsx +++ b/webapp/src/components/badge_modal/index.tsx @@ -11,6 +11,7 @@ import {closeCreateBadgeModal, closeEditBadgeModal} from 'actions/actions'; import {BadgeTypeDefinition} from 'types/badges'; import Client from 'client/api'; import {getServerErrorId} from 'utils/helpers'; +import CloseIcon from 'components/icons/close_icon'; import TypeSelect from './type_select'; @@ -216,7 +217,7 @@ const BadgeModal: React.FC = () => { className='close-btn' onClick={handleClose} > - {'\u00D7'} +
diff --git a/webapp/src/components/icons/close_icon.tsx b/webapp/src/components/icons/close_icon.tsx new file mode 100644 index 0000000..25afdc9 --- /dev/null +++ b/webapp/src/components/icons/close_icon.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +type Props = { + size?: number; +} + +const CloseIcon: React.FC = ({size = 16}) => ( + + + + + +); + +export default CloseIcon; diff --git a/webapp/src/components/icons/search_icon.tsx b/webapp/src/components/icons/search_icon.tsx new file mode 100644 index 0000000..c1375ad --- /dev/null +++ b/webapp/src/components/icons/search_icon.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +type Props = { + size?: number; +} + +const SearchIcon: React.FC = ({size = 18}) => ( + + + + + +); + +export default SearchIcon; diff --git a/webapp/src/components/utils/tooltip_wrapper.tsx b/webapp/src/components/utils/tooltip_wrapper.tsx index bdc8aab..049755d 100644 --- a/webapp/src/components/utils/tooltip_wrapper.tsx +++ b/webapp/src/components/utils/tooltip_wrapper.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import React, {ReactNode, useState, useRef, useEffect} from 'react'; import ReactDOM from 'react-dom'; diff --git a/webapp/src/utils/helpers.ts b/webapp/src/utils/helpers.ts index d3cd72b..ad9698f 100644 --- a/webapp/src/utils/helpers.ts +++ b/webapp/src/utils/helpers.ts @@ -1,3 +1,23 @@ +import {UserProfile} from 'mattermost-redux/types/users'; + +export function getUserDisplayName(user: UserProfile): string { + if (user.nickname) { + return user.nickname; + } + if (user.first_name || user.last_name) { + return `${user.first_name} ${user.last_name}`.trim(); + } + return user.username; +} + +export function debounce void>(fn: T, delay: number): T { + let timer: ReturnType; + return ((...args: any[]) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }) as unknown as T; +} + export function getServerErrorId(err: unknown): string { const msg = (err as {message?: string})?.message || ''; try {