replaced admin text input with a searchable multi-select

This commit is contained in:
Дмитрий Пиченикин 2026-02-24 14:04:16 +03:00
parent 7c976233a7
commit dffe0685bb
9 changed files with 449 additions and 26 deletions

View File

@ -39,9 +39,10 @@
"badges.sidebar.title": "Badges", "badges.sidebar.title": "Badges",
"badges.popover.title": "Badges", "badges.popover.title": "Badges",
"badges.admin.label": "Achievements Admin:", "badges.admin.label": "Achievements Administrators:",
"badges.admin.placeholder": "username", "badges.admin.placeholder": "Start typing a name...",
"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.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.create_badge": "+ Create badge",
"badges.rhs.edit_badge": "Edit", "badges.rhs.edit_badge": "Edit",

View File

@ -39,9 +39,10 @@
"badges.sidebar.title": "Значки", "badges.sidebar.title": "Значки",
"badges.popover.title": "Значки", "badges.popover.title": "Значки",
"badges.admin.label": "Администратор достижений:", "badges.admin.label": "Администраторы достижений:",
"badges.admin.placeholder": "имя пользователя", "badges.admin.placeholder": "Начните вводить имя...",
"badges.admin.help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки.", "badges.admin.help_text": "Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые значки.",
"badges.admin.no_results": "Пользователь не найден",
"badges.rhs.create_badge": "+ Создать значок", "badges.rhs.create_badge": "+ Создать значок",
"badges.rhs.edit_badge": "Редактировать", "badges.rhs.edit_badge": "Редактировать",

View File

@ -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);
}
}

View File

@ -1,7 +1,22 @@
/* eslint-disable react/prop-types */ import React, {useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl'; 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 = { type Props = {
id: string; id: string;
value: string; value: string;
@ -17,36 +32,203 @@ type Props = {
const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, setSaveNeeded}) => { const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, setSaveNeeded}) => {
const intl = useIntl(); const intl = useIntl();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const [searchTerm, setSearchTerm] = useState('');
onChange(id, e.target.value); const [results, setResults] = useState<UserProfile[]>([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
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<string>) => {
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(); setSaveNeeded();
}, [id, onChange, setSaveNeeded]); };
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 ( return (
<div className='form-group'> <div className='form-group'>
<label className='control-label col-sm-4'> <label className='control-label col-sm-4'>
<FormattedMessage <FormattedMessage
id='badges.admin.label' id='badges.admin.label'
defaultMessage='Администратор достижений:' defaultMessage='Администраторы достижений:'
/> />
</label> </label>
<div className='col-sm-8'> <div className='col-sm-8'>
<input <div
className='form-control' className='admin-user-select'
type='text' ref={containerRef}
value={value || ''} >
disabled={disabled} <div
onChange={handleChange} className='admin-user-select__container'
placeholder={intl.formatMessage({ onClick={() => inputRef.current?.focus()}
id: 'badges.admin.placeholder', >
defaultMessage: 'username', {loading ? (
})} <div className='admin-user-select__spinner'/>
) : (
<SearchIcon/>
)}
{selectedUsers.map((user) => (
<span
key={user.username}
className='admin-user-select__chip'
>
{user.avatarUrl && (
<img
className='admin-user-select__chip-avatar'
src={user.avatarUrl}
alt={user.username}
/> />
)}
<span className='admin-user-select__chip-name'>
{user.fullName || user.username}
</span>
{!disabled && (
<button
type='button'
className='admin-user-select__chip-remove'
onClick={(e) => {
e.stopPropagation();
handleRemove(user.username);
}}
>
<CloseIcon size={12}/>
</button>
)}
</span>
))}
<input
ref={inputRef}
className='admin-user-select__input'
type='text'
value={searchTerm}
disabled={disabled}
onChange={handleInputChange}
placeholder={selectedUsers.length === 0 ? intl.formatMessage({
id: 'badges.admin.placeholder',
defaultMessage: 'Начните вводить имя...',
}) : ''}
/>
</div>
{dropdownOpen && (
<div className='admin-user-select__dropdown'>
{results.length === 0 && searchTerm && (
<div className={`admin-user-select__no-results${loading ? ' admin-user-select__no-results--loading' : ''}`}>
<FormattedMessage
id='badges.admin.no_results'
defaultMessage='Пользователь не найден'
/>
</div>
)}
{results.map((user) => (
<div
key={user.id}
className='admin-user-select__option'
onClick={() => handleSelect(user)}
>
<img
className='admin-user-select__avatar'
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
alt={user.username}
/>
<span className='admin-user-select__option-name'>
{user.username}
</span>
{(user.first_name || user.last_name) && (
<span className='admin-user-select__option-fullname'>
{'— '}{`${user.first_name} ${user.last_name}`.trim()}
</span>
)}
</div>
))}
</div>
)}
</div>
<div className='help-text'> <div className='help-text'>
<FormattedMessage <FormattedMessage
id='badges.admin.help_text' id='badges.admin.help_text'
defaultMessage='Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки.' defaultMessage='Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые значки.'
/> />
</div> </div>
</div> </div>

View File

@ -11,6 +11,7 @@ import {closeCreateBadgeModal, closeEditBadgeModal} from 'actions/actions';
import {BadgeTypeDefinition} from 'types/badges'; import {BadgeTypeDefinition} from 'types/badges';
import Client from 'client/api'; import Client from 'client/api';
import {getServerErrorId} from 'utils/helpers'; import {getServerErrorId} from 'utils/helpers';
import CloseIcon from 'components/icons/close_icon';
import TypeSelect from './type_select'; import TypeSelect from './type_select';
@ -216,7 +217,7 @@ const BadgeModal: React.FC = () => {
className='close-btn' className='close-btn'
onClick={handleClose} onClick={handleClose}
> >
{'\u00D7'} <CloseIcon/>
</button> </button>
</div> </div>
<div className='BadgeModal__body'> <div className='BadgeModal__body'>

View File

@ -0,0 +1,29 @@
import React from 'react';
type Props = {
size?: number;
}
const CloseIcon: React.FC<Props> = ({size = 16}) => (
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path
stroke='none'
d='M0 0h24v24H0z'
fill='none'
/>
<path d='M18 6l-12 12'/>
<path d='M6 6l12 12'/>
</svg>
);
export default CloseIcon;

View File

@ -0,0 +1,29 @@
import React from 'react';
type Props = {
size?: number;
}
const SearchIcon: React.FC<Props> = ({size = 18}) => (
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path
stroke='none'
d='M0 0h24v24H0z'
fill='none'
/>
<path d='M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0'/>
<path d='M21 21l-6 -6'/>
</svg>
);
export default SearchIcon;

View File

@ -1,4 +1,3 @@
/* eslint-disable react/prop-types */
import React, {ReactNode, useState, useRef, useEffect} from 'react'; import React, {ReactNode, useState, useRef, useEffect} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';

View File

@ -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<T extends(...args: any[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout>;
return ((...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}) as unknown as T;
}
export function getServerErrorId(err: unknown): string { export function getServerErrorId(err: unknown): string {
const msg = (err as {message?: string})?.message || ''; const msg = (err as {message?: string})?.message || '';
try { try {