LP-5613 #2

Open
dmitrii.pichenikin wants to merge 37 commits from LP-5613 into dev
29 changed files with 1846 additions and 467 deletions
Showing only changes of commit 9f4b2218b0 - Show all commits

View File

@ -54,17 +54,31 @@ type CreateTypeRequest struct {
Name string `json:"name"`
EveryoneCanCreate bool `json:"everyone_can_create"`
EveryoneCanGrant bool `json:"everyone_can_grant"`
AllowlistCanCreate string `json:"allowlist_can_create"`
AllowlistCanGrant string `json:"allowlist_can_grant"`
ChannelID string `json:"channel_id"`
}
type UpdateTypeRequest struct {
ID string `json:"id"`
Name string `json:"name"`
EveryoneCanCreate bool `json:"everyone_can_create"`
EveryoneCanGrant bool `json:"everyone_can_grant"`
AllowlistCanCreate string `json:"allowlist_can_create"`
AllowlistCanGrant string `json:"allowlist_can_grant"`
}
type TypeWithBadgeCount struct {
*badgesmodel.BadgeTypeDefinition
BadgeCount int `json:"badge_count"`
AllowlistCanCreate string `json:"allowlist_can_create"`
AllowlistCanGrant string `json:"allowlist_can_grant"`
}
type GetTypesResponse struct {
Types []TypeWithBadgeCount `json:"types"`
CanCreateType bool `json:"can_create_type"`
CanEditType bool `json:"can_edit_type"`
}
func (p *Plugin) initializeAPI() {
@ -83,6 +97,7 @@ func (p *Plugin) initializeAPI() {
apiRouter.HandleFunc("/createBadge", p.extractUserMiddleWare(p.apiCreateBadge, ResponseTypeJSON)).Methods(http.MethodPost)
apiRouter.HandleFunc("/createType", p.extractUserMiddleWare(p.apiCreateType, ResponseTypeJSON)).Methods(http.MethodPost)
apiRouter.HandleFunc("/updateBadge", p.extractUserMiddleWare(p.apiUpdateBadge, ResponseTypeJSON)).Methods(http.MethodPut)
apiRouter.HandleFunc("/updateType", p.extractUserMiddleWare(p.apiUpdateType, ResponseTypeJSON)).Methods(http.MethodPut)
apiRouter.HandleFunc("/deleteBadge/{badgeID}", p.extractUserMiddleWare(p.apiDeleteBadge, ResponseTypeJSON)).Methods(http.MethodDelete)
apiRouter.HandleFunc("/deleteType/{typeID}", p.extractUserMiddleWare(p.apiDeleteType, ResponseTypeJSON)).Methods(http.MethodDelete)
@ -167,12 +182,15 @@ func (p *Plugin) getTypes(w http.ResponseWriter, r *http.Request, userID string)
result[i] = TypeWithBadgeCount{
BadgeTypeDefinition: t,
BadgeCount: badgeCountByType[t.ID],
AllowlistCanCreate: p.resolveUserIDList(t.CanCreate.AllowList),
AllowlistCanGrant: p.resolveUserIDList(t.CanGrant.AllowList),
}
}
resp := GetTypesResponse{
Types: result,
CanCreateType: canCreateType(u, p.badgeAdminUserIDs, false),
CanEditType: p.badgeAdminUserIDs[u.Id] || u.IsSystemAdmin(),
}
b, _ := json.Marshal(resp)
@ -303,6 +321,28 @@ func (p *Plugin) apiCreateType(w http.ResponseWriter, r *http.Request, userID st
toCreate.CanCreate.Everyone = req.EveryoneCanCreate
toCreate.CanGrant.Everyone = req.EveryoneCanGrant
if req.AllowlistCanCreate != "" {
allowList, aErr := p.resolveUsernameList(req.AllowlistCanCreate)
if aErr != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_request", Message: aErr.Error(), StatusCode: http.StatusBadRequest,
})
return
}
toCreate.CanCreate.AllowList = allowList
}
if req.AllowlistCanGrant != "" {
allowList, aErr := p.resolveUsernameList(req.AllowlistCanGrant)
if aErr != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_request", Message: aErr.Error(), StatusCode: http.StatusBadRequest,
})
return
}
toCreate.CanGrant.AllowList = allowList
}
created, err := p.store.AddType(toCreate)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
@ -395,6 +435,79 @@ func (p *Plugin) apiUpdateBadge(w http.ResponseWriter, r *http.Request, userID s
_, _ = w.Write(b)
}
func (p *Plugin) apiUpdateType(w http.ResponseWriter, r *http.Request, userID string) {
var req UpdateTypeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest,
})
return
}
user, err := p.mm.User.Get(userID)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError,
})
return
}
originalType, err := p.store.GetType(badgesmodel.BadgeType(req.ID))
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "type_not_found", Message: "Badge type not found", StatusCode: http.StatusNotFound,
})
return
}
if !canEditType(user, p.badgeAdminUserIDs, originalType) {
p.writeAPIError(w, &APIErrorResponse{
ID: "no_permission", Message: "No permission to edit this type", StatusCode: http.StatusForbidden,
})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_name", Message: "Name is required", StatusCode: http.StatusBadRequest,
})
return
}
originalType.Name = req.Name
originalType.CanCreate.Everyone = req.EveryoneCanCreate
originalType.CanGrant.Everyone = req.EveryoneCanGrant
createAllowList, aErr := p.resolveUsernameList(req.AllowlistCanCreate)
if aErr != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_request", Message: aErr.Error(), StatusCode: http.StatusBadRequest,
})
return
}
originalType.CanCreate.AllowList = createAllowList
grantAllowList, aErr := p.resolveUsernameList(req.AllowlistCanGrant)
if aErr != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_request", Message: aErr.Error(), StatusCode: http.StatusBadRequest,
})
return
}
originalType.CanGrant.AllowList = grantAllowList
if err := p.store.UpdateType(originalType); err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "cannot_update_type", Message: err.Error(), StatusCode: http.StatusInternalServerError,
})
return
}
b, _ := json.Marshal(originalType)
_, _ = w.Write(b)
}
func (p *Plugin) apiDeleteBadge(w http.ResponseWriter, r *http.Request, userID string) {
badgeID, ok := mux.Vars(r)["badgeID"]
if !ok {

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"strings"
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
"github.com/mattermost/mattermost-server/v5/model"
@ -208,6 +209,40 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
}
}
// resolveUsernameList parses a comma-separated list of usernames and returns a map of user IDs.
func (p *Plugin) resolveUsernameList(csv string) (map[string]bool, error) {
result := map[string]bool{}
usernames := strings.Split(csv, ",")
for _, username := range usernames {
username = strings.TrimSpace(username)
if username == "" {
continue
}
user, err := p.mm.User.GetByUsername(username)
if err != nil {
return nil, fmt.Errorf("user not found: %s", username)
}
result[user.Id] = true
}
return result, nil
}
// resolveUserIDList converts a map of user IDs to a comma-separated list of usernames.
func (p *Plugin) resolveUserIDList(ids map[string]bool) string {
var names []string
for id, allowed := range ids {
if !allowed {
continue
}
user, err := p.mm.User.Get(id)
if err != nil {
continue
}
names = append(names, user.Username)
}
return strings.Join(names, ", ")
}
func getBooleanString(in bool) string {
if in {
return TrueString

View File

@ -47,6 +47,8 @@
"badges.rhs.create_badge": "+ Create badge",
"badges.rhs.edit_badge": "Edit",
"badges.rhs.types": "Types",
"badges.rhs.create_type": "+ Create type",
"badges.modal.create_badge_title": "Create Badge",
"badges.modal.edit_badge_title": "Edit Badge",
@ -74,10 +76,28 @@
"badges.modal.error_generic": "An error occurred",
"badges.modal.error_type_name_required": "Enter type name",
"badges.modal.error_type_required": "Select badge type",
"badges.modal.create_type_title": "Create Type",
"badges.modal.edit_type_title": "Edit Type",
"badges.modal.btn_delete_type": "Delete type",
"badges.modal.delete_type": "Delete type",
"badges.modal.confirm_delete_type": "Delete type \"{name}\"?",
"badges.modal.btn_confirm_delete_type": "Yes, delete",
"badges.types.badge_count": "{count, plural, one {# badge} other {# badges}}",
"badges.types.everyone_can_create": "Everyone creates",
"badges.types.everyone_can_grant": "Everyone grants",
"badges.types.is_default": "Default",
"badges.types.confirm_delete": "Delete type \"{name}\" and all its badges?",
"badges.types.empty": "No types yet",
"badges.types.no_badges": "No badges in this type",
"badges.rhs.back_to_types": "Back to types",
"badges.modal.allowlist_create": "Allowlist for creation",
"badges.modal.allowlist_create_help": "Users who can create badges of this type.",
"badges.modal.allowlist_grant": "Allowlist for granting",
"badges.modal.allowlist_grant_help": "Users who can grant badges of this type.",
"badges.modal.allowlist_placeholder": "user-1, user-2, user-3",
"badges.error.unknown": "An error occurred",
"badges.error.cannot_get_user": "Failed to get user data",
"badges.error.cannot_get_types": "Failed to load types",
@ -94,6 +114,7 @@
"badges.error.cannot_create_type": "Failed to create type",
"badges.error.cannot_update_badge": "Failed to update badge",
"badges.error.cannot_delete_badge": "Failed to delete badge",
"badges.error.cannot_update_type": "Failed to update type",
"badges.error.cannot_delete_type": "Failed to delete type",
"emoji_picker.activities": "Activities",

View File

@ -47,6 +47,8 @@
"badges.rhs.create_badge": "+ Создать значок",
"badges.rhs.edit_badge": "Редактировать",
"badges.rhs.types": "Типы",
"badges.rhs.create_type": "+ Создать тип",
"badges.modal.create_badge_title": "Создать значок",
"badges.modal.edit_badge_title": "Редактировать значок",
@ -74,10 +76,28 @@
"badges.modal.error_generic": "Произошла ошибка",
"badges.modal.error_type_name_required": "Введите название типа",
"badges.modal.error_type_required": "Выберите тип значка",
"badges.modal.create_type_title": "Создать тип",
"badges.modal.edit_type_title": "Редактировать тип",
"badges.modal.btn_delete_type": "Удалить тип",
"badges.modal.delete_type": "Удалить тип",
"badges.modal.confirm_delete_type": "Удалить тип «{name}»?",
"badges.modal.btn_confirm_delete_type": "Да, удалить",
"badges.types.badge_count": "{count, plural, one {# значок} few {# значка} many {# значков} other {# значков}}",
"badges.types.everyone_can_create": "Все создают",
"badges.types.everyone_can_grant": "Все выдают",
"badges.types.is_default": "По умолчанию",
"badges.types.confirm_delete": "Удалить тип «{name}» и все его значки?",
"badges.types.empty": "Типов пока нет",
"badges.types.no_badges": "В этом типе нет значков",
"badges.rhs.back_to_types": "Назад к типам",
"badges.modal.allowlist_create": "Список допущенных к созданию",
"badges.modal.allowlist_create_help": "Пользователи, которые могут создавать значки этого типа.",
"badges.modal.allowlist_grant": "Список допущенных к выдаче",
"badges.modal.allowlist_grant_help": "Пользователи, которые могут выдавать значки этого типа.",
"badges.modal.allowlist_placeholder": "user-1, user-2, user-3",
"badges.error.unknown": "Произошла ошибка",
"badges.error.cannot_get_user": "Не удалось получить данные пользователя",
"badges.error.cannot_get_types": "Не удалось загрузить типы",
@ -94,6 +114,7 @@
"badges.error.cannot_create_type": "Не удалось создать тип",
"badges.error.cannot_update_badge": "Не удалось обновить значок",
"badges.error.cannot_delete_badge": "Не удалось удалить значок",
"badges.error.cannot_update_type": "Не удалось обновить тип",
"badges.error.cannot_delete_type": "Не удалось удалить тип",
"emoji_picker.activities": "Мероприятия",

View File

@ -72,6 +72,7 @@
"react": "17.0.2",
"react-custom-scrollbars": "^4.2.1",
"react-redux": "7.2.3",
"react-virtuoso": "^4.18.1",
"redux": "4.0.5",
"typescript": "4.2.4"
},

View File

@ -8,8 +8,13 @@ export default {
RECEIVED_RHS_VIEW: pluginId + '_received_rhs_view',
RECEIVED_RHS_USER: pluginId + '_received_rhs_user',
RECEIVED_RHS_BADGE: pluginId + '_received_rhs_badge',
RECEIVED_RHS_TYPE: pluginId + '_received_rhs_type',
OPEN_CREATE_BADGE_MODAL: pluginId + '_open_create_badge_modal',
CLOSE_CREATE_BADGE_MODAL: pluginId + '_close_create_badge_modal',
OPEN_EDIT_BADGE_MODAL: pluginId + '_open_edit_badge_modal',
CLOSE_EDIT_BADGE_MODAL: pluginId + '_close_edit_badge_modal',
OPEN_CREATE_TYPE_MODAL: pluginId + '_open_create_type_modal',
CLOSE_CREATE_TYPE_MODAL: pluginId + '_close_create_type_modal',
OPEN_EDIT_TYPE_MODAL: pluginId + '_open_edit_type_modal',
CLOSE_EDIT_TYPE_MODAL: pluginId + '_close_edit_type_modal',
};

View File

@ -7,7 +7,7 @@ import {Client4} from 'mattermost-redux/client';
import {IntegrationTypes} from 'mattermost-redux/action_types';
import ActionTypes from 'action_types/';
import {BadgeDetails, BadgeID} from 'types/badges';
import {BadgeDetails, BadgeID, BadgeTypeDefinition} from 'types/badges';
import {RHSState} from 'types/general';
/**
@ -42,6 +42,13 @@ export function setRHSView(view: RHSState) {
};
}
export function setRHSType(typeId: number | null, typeName: string | null) {
return {
type: ActionTypes.RECEIVED_RHS_TYPE,
data: {typeId, typeName},
};
}
export function setTriggerId(triggerId: string) {
return {
type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
@ -98,6 +105,22 @@ export function closeEditBadgeModal() {
return {type: ActionTypes.CLOSE_EDIT_BADGE_MODAL};
}
export function openCreateTypeModal() {
return {type: ActionTypes.OPEN_CREATE_TYPE_MODAL};
}
export function closeCreateTypeModal() {
return {type: ActionTypes.CLOSE_CREATE_TYPE_MODAL};
}
export function openEditTypeModal(badgeType: BadgeTypeDefinition) {
return {type: ActionTypes.OPEN_EDIT_TYPE_MODAL, data: badgeType};
}
export function closeEditTypeModal() {
return {type: ActionTypes.CLOSE_EDIT_TYPE_MODAL};
}
export function openAddSubscription() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
const command = '/badges subscription create';

View File

@ -5,7 +5,7 @@ import {Client4} from 'mattermost-redux/client';
import {ClientError} from 'mattermost-redux/client/client4';
import manifest from 'manifest';
import {AllBadgesBadge, Badge, BadgeDetails, BadgeID, BadgeTypeDefinition, CreateBadgeRequest, CreateTypeRequest, GetTypesResponse, UpdateBadgeRequest, UserBadge} from 'types/badges';
import {AllBadgesBadge, Badge, BadgeDetails, BadgeID, BadgeTypeDefinition, CreateBadgeRequest, CreateTypeRequest, GetTypesResponse, UpdateBadgeRequest, UpdateTypeRequest, UserBadge} from 'types/badges';
export default class Client {
private url: string;
@ -46,7 +46,7 @@ export default class Client {
const res = await this.doGet(`${this.url}/getTypes`);
return res as GetTypesResponse;
} catch {
return {types: [], can_create_type: false};
return {types: [], can_create_type: false, can_edit_type: false};
}
}
@ -66,6 +66,10 @@ export default class Client {
await this.doDelete(`${this.url}/deleteBadge/${badgeID}`);
}
async updateType(req: UpdateTypeRequest): Promise<BadgeTypeDefinition> {
return await this.doPut(`${this.url}/updateType`, req) as BadgeTypeDefinition;
}
async deleteType(typeID: string): Promise<void> {
await this.doDelete(`${this.url}/deleteType/${typeID}`);
}

View File

@ -1,21 +1,7 @@
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import React, {useCallback} from 'react';
import {FormattedMessage} 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;
}
import UserMultiSelect from 'components/user_multi_select';
type Props = {
id: string;
@ -31,101 +17,10 @@ type Props = {
}
const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, setSaveNeeded}) => {
const intl = useIntl();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [searchTerm, setSearchTerm] = useState('');
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(','));
const handleChange = useCallback((newValue: string) => {
onChange(id, newValue);
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);
};
}, [id, onChange, setSaveNeeded]);
return (
<div className='form-group'>
@ -136,95 +31,11 @@ const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, set
/>
</label>
<div className='col-sm-8'>
<div
className='admin-user-select'
ref={containerRef}
>
<div
className='admin-user-select__container'
onClick={() => inputRef.current?.focus()}
>
{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}
<UserMultiSelect
value={value}
onChange={handleChange}
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'>
<FormattedMessage
id='badges.admin.help_text'

View File

@ -1,4 +1,14 @@
.BadgeModal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
&__backdrop {
position: fixed;
top: 0;
@ -6,15 +16,11 @@
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
}
&__dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10001;
position: relative;
z-index: 1;
background: var(--center-channel-bg, #fff);
color: var(--center-channel-color, #3d3c40);
border-radius: 8px;
@ -80,9 +86,9 @@
opacity: 0.64;
}
input[type='text'],
select,
textarea {
> input[type='text'],
> select,
> textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
@ -97,7 +103,7 @@
}
}
textarea {
> textarea {
resize: vertical;
min-height: 60px;
}
@ -143,6 +149,7 @@
input[type='text'] {
flex: 1;
border: none;
background: transparent;
padding: 8px 12px 8px 0;
&:focus {

View File

@ -7,8 +7,9 @@ import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common';
import {GlobalState} from 'mattermost-redux/types/store';
import {isCreateBadgeModalVisible, getEditBadgeModalData} from 'selectors';
import {closeCreateBadgeModal, closeEditBadgeModal} from 'actions/actions';
import {BadgeTypeDefinition} from 'types/badges';
import {closeCreateBadgeModal, closeEditBadgeModal, setRHSView} from 'actions/actions';
import {RHS_STATE_ALL} from '../../constants';
import {BadgeFormData, BadgeTypeDefinition, TypeFormData} from 'types/badges';
import Client from 'client/api';
import {getServerErrorId} from 'utils/helpers';
import CloseIcon from 'components/icons/close_icon';
@ -16,12 +17,29 @@ import EmojiIcon from 'components/icons/emoji_icon';
import RenderEmoji from 'components/utils/emoji';
import EmojiPickerOverlay from './emoji_picker';
import InlineTypeForm from './inline_type_form';
import TypeSelect from './type_select';
import './badge_modal.scss';
const NEW_TYPE_VALUE = '__new__';
const emptyBadgeForm: BadgeFormData = {
name: '',
description: '',
image: '',
badgeType: '',
multiple: false,
};
const emptyTypeForm: TypeFormData = {
name: '',
everyoneCanCreate: false,
everyoneCanGrant: false,
allowlistCanCreate: '',
allowlistCanGrant: '',
};
const BadgeModal: React.FC = () => {
const dispatch = useDispatch();
const intl = useIntl();
@ -30,16 +48,11 @@ const BadgeModal: React.FC = () => {
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
const isOpen = createVisible || editData !== null;
const isEditMode = editData !== null;
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [image, setImage] = useState('');
const [badgeType, setBadgeType] = useState('');
const [multiple, setMultiple] = useState(false);
const [form, setForm] = useState<BadgeFormData>(emptyBadgeForm);
const [newTypeForm, setNewTypeForm] = useState<TypeFormData>(emptyTypeForm);
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
const [showCreateType, setShowCreateType] = useState(false);
const [newTypeName, setNewTypeName] = useState('');
const [newTypeEveryoneCanCreate, setNewTypeEveryoneCanCreate] = useState(false);
const [newTypeEveryoneCanGrant, setNewTypeEveryoneCanGrant] = useState(false);
const [canCreateType, setCanCreateType] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -47,8 +60,17 @@ const BadgeModal: React.FC = () => {
const [confirmDeleteTypeId, setConfirmDeleteTypeId] = useState<string | null>(null);
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const updateForm = useCallback((updates: Partial<BadgeFormData>) => {
setForm((prev) => ({...prev, ...updates}));
}, []);
const updateTypeForm = useCallback((updates: Partial<TypeFormData>) => {
setNewTypeForm((prev) => ({...prev, ...updates}));
}, []);
const emojiData = (window as any)?.useGetEmojiSelectorData?.();
const {
emojiButtonRef,
@ -66,28 +88,24 @@ const BadgeModal: React.FC = () => {
setCanCreateType(resp.can_create_type);
if (!isEditMode && resp.types.length > 0) {
const defaultType = resp.types.find((t) => t.is_default);
Review

может тогда лучше сделать client синглтоном прямо в файле api? Чтобы сразу экспортировать и не делать new Client когда нужен запрос?

может тогда лучше сделать client синглтоном прямо в файле api? Чтобы сразу экспортировать и не делать new Client когда нужен запрос?
setBadgeType(String(defaultType ? defaultType.id : resp.types[0].id));
setForm((prev) => ({...prev, badgeType: String(defaultType ? defaultType.id : resp.types[0].id)}));
}
};
fetchTypes();
if (isEditMode && editData) {
setName(editData.name);
setDescription(editData.description);
setImage(editData.image);
setBadgeType(String(editData.type));
setMultiple(editData.multiple);
setForm({
name: editData.name,
description: editData.description,
image: editData.image,
badgeType: String(editData.type),
multiple: editData.multiple,
});
} else {
setName('');
setDescription('');
setImage('');
setBadgeType('');
setMultiple(false);
setForm(emptyBadgeForm);
}
setShowCreateType(false);
setNewTypeName('');
setNewTypeEveryoneCanCreate(false);
setNewTypeEveryoneCanGrant(false);
setNewTypeForm(emptyTypeForm);
setError(null);
setConfirmDelete(false);
setConfirmDeleteTypeId(null);
@ -108,20 +126,20 @@ const BadgeModal: React.FC = () => {
const handleTypeSelect = useCallback((val: string) => {
if (val === NEW_TYPE_VALUE) {
setShowCreateType(true);
setBadgeType('');
updateForm({badgeType: ''});
} else {
setShowCreateType(false);
setBadgeType(val);
updateForm({badgeType: val});
}
setTypeDropdownOpen(false);
setConfirmDeleteTypeId(null);
}, []);
}, [updateForm]);
const handleEmojiSelect = (emoji: any) => {
if (emoji.short_name) {
setImage(emoji.short_name);
updateForm({image: emoji.short_name});
} else if (emoji.name) {
setImage(emoji.name);
updateForm({image: emoji.name});
}
setShowEmojiPicker(false);
};
@ -136,31 +154,33 @@ const BadgeModal: React.FC = () => {
await client.deleteType(typeId);
const removeById = (t: BadgeTypeDefinition) => String(t.id) !== typeId;
setTypes((prev) => prev.filter(removeById));
if (badgeType === typeId) {
setBadgeType('');
if (form.badgeType === typeId) {
updateForm({badgeType: ''});
}
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
}
setConfirmDeleteTypeId(null);
}, [confirmDeleteTypeId, badgeType, intl]);
}, [confirmDeleteTypeId, form.badgeType, updateForm, intl]);
Review

идентично

идентично
const handleSubmit = useCallback(async () => {
setLoading(true);
setError(null);
try {
const client = new Client();
let typeID = badgeType;
let typeID = form.badgeType;
if (showCreateType) {
if (!newTypeName.trim()) {
if (!newTypeForm.name.trim()) {
setError(intl.formatMessage({id: 'badges.modal.error_type_name_required', defaultMessage: 'Введите название типа'}));
setLoading(false);
return;
}
const createdType = await client.createType({
name: newTypeName.trim(),
everyone_can_create: newTypeEveryoneCanCreate,
everyone_can_grant: newTypeEveryoneCanGrant,
name: newTypeForm.name.trim(),
everyone_can_create: newTypeForm.everyoneCanCreate,
everyone_can_grant: newTypeForm.everyoneCanGrant,
allowlist_can_create: newTypeForm.allowlistCanCreate.trim(),
allowlist_can_grant: newTypeForm.allowlistCanGrant.trim(),
channel_id: channelId,
});
typeID = String(createdType.id);
@ -173,29 +193,30 @@ const BadgeModal: React.FC = () => {
if (isEditMode && editData) {
await client.updateBadge({
id: String(editData.id),
name: name.trim(),
description: description.trim(),
image: image.trim(),
name: form.name.trim(),
description: form.description.trim(),
image: form.image.trim(),
type: typeID,
multiple,
multiple: form.multiple,
});
} else {
await client.createBadge({
name: name.trim(),
description: description.trim(),
image: image.trim(),
name: form.name.trim(),
description: form.description.trim(),
image: form.image.trim(),
type: typeID,
multiple,
multiple: form.multiple,
channel_id: channelId,
});
}
handleClose();
dispatch(setRHSView(RHS_STATE_ALL));
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [badgeType, showCreateType, newTypeName, newTypeEveryoneCanCreate, newTypeEveryoneCanGrant, isEditMode, editData, name, description, image, multiple, handleClose, intl, channelId]);
}, [form, showCreateType, newTypeForm, isEditMode, editData, handleClose, intl, channelId, dispatch]);
const handleDelete = useCallback(async () => {
if (!editData) {
@ -211,22 +232,30 @@ const BadgeModal: React.FC = () => {
const client = new Client();
await client.deleteBadge(editData.id);
handleClose();
dispatch(setRHSView(RHS_STATE_ALL));
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [editData, confirmDelete, handleClose, intl]);
}, [editData, confirmDelete, handleClose, intl, dispatch]);
if (!isOpen) {
return null;
}
const title = isEditMode ? intl.formatMessage({id: 'badges.modal.edit_badge_title', defaultMessage: 'Редактировать значок'}) : intl.formatMessage({id: 'badges.modal.create_badge_title', defaultMessage: 'Создать значок'});
const submitLabel = isEditMode ? intl.formatMessage({id: 'badges.modal.btn_save', defaultMessage: 'Сохранить'}) : intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'});
const title = isEditMode
? intl.formatMessage({id: 'badges.modal.edit_badge_title', defaultMessage: 'Редактировать значок'})
: intl.formatMessage({id: 'badges.modal.create_badge_title', defaultMessage: 'Создать значок'});
const submitLabel = isEditMode
? intl.formatMessage({id: 'badges.modal.btn_save', defaultMessage: 'Сохранить'})
: intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'});
return (
<div className='BadgeModal'>
<div
className='BadgeModal'
ref={modalRef}
>
<div
className='BadgeModal__backdrop'
onClick={handleClose}
@ -254,8 +283,8 @@ const BadgeModal: React.FC = () => {
</label>
<input
type='text'
value={name}
onChange={(e) => setName(e.target.value)}
value={form.name}
onChange={(e) => updateForm({name: e.target.value})}
maxLength={20}
placeholder={intl.formatMessage({id: 'badges.modal.field_name_placeholder', defaultMessage: 'Название значка (макс. 20 символов)'})}
/>
@ -268,8 +297,8 @@ const BadgeModal: React.FC = () => {
/>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
value={form.description}
onChange={(e) => updateForm({description: e.target.value})}
maxLength={120}
placeholder={intl.formatMessage({id: 'badges.modal.field_description_placeholder', defaultMessage: 'Описание значка (макс. 120 символов)'})}
/>
@ -290,23 +319,23 @@ const BadgeModal: React.FC = () => {
>
<EmojiIcon/>
</button>
{image && (
{form.image && (
<RenderEmoji
emojiName={image}
emojiName={form.image}
size={20}
/>
)}
<input
type='text'
value={image}
onChange={(e) => setImage(e.target.value)}
value={form.image}
onChange={(e) => updateForm({image: e.target.value})}
placeholder={intl.formatMessage({id: 'badges.modal.field_image_placeholder', defaultMessage: 'Название эмодзи (напр. star)'})}
/>
</div>
{showEmojiPicker && (
<EmojiPickerOverlay
target={() => emojiButtonRef?.current}
container={() => dialogRef.current}
container={() => modalRef.current}
show={showEmojiPicker}
onHide={() => setShowEmojiPicker(false)}
onEmojiClick={handleEmojiSelect}
@ -325,7 +354,7 @@ const BadgeModal: React.FC = () => {
</label>
<TypeSelect
types={types}
badgeType={badgeType}
badgeType={form.badgeType}
showCreateType={showCreateType}
canCreateType={canCreateType}
typeDropdownOpen={typeDropdownOpen}
@ -336,59 +365,18 @@ const BadgeModal: React.FC = () => {
onCancelDeleteType={() => setConfirmDeleteTypeId(null)}
/>
{showCreateType && (
<div className='inline-type-section'>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.new_type_name'
defaultMessage='Название типа'
<InlineTypeForm
form={newTypeForm}
onChange={updateTypeForm}
/>
</label>
<input
type='text'
value={newTypeName}
onChange={(e) => setNewTypeName(e.target.value)}
maxLength={20}
placeholder={intl.formatMessage({id: 'badges.modal.new_type_name_placeholder', defaultMessage: 'Название типа (макс. 20 символов)'})}
/>
</div>
<div className='checkbox-group'>
<input
type='checkbox'
id='newTypeEveryoneCanCreate'
checked={newTypeEveryoneCanCreate}
onChange={(e) => setNewTypeEveryoneCanCreate(e.target.checked)}
/>
<label htmlFor='newTypeEveryoneCanCreate'>
<FormattedMessage
id='badges.modal.new_type_everyone_create'
defaultMessage='Все могут создавать значки'
/>
</label>
</div>
<div className='checkbox-group'>
<input
type='checkbox'
id='newTypeEveryoneCanGrant'
checked={newTypeEveryoneCanGrant}
onChange={(e) => setNewTypeEveryoneCanGrant(e.target.checked)}
/>
<label htmlFor='newTypeEveryoneCanGrant'>
<FormattedMessage
id='badges.modal.new_type_everyone_grant'
defaultMessage='Все могут выдавать значки'
/>
</label>
</div>
</div>
)}
</div>
<div className='checkbox-group'>
<input
type='checkbox'
id='badgeMultiple'
checked={multiple}
onChange={(e) => setMultiple(e.target.checked)}
checked={form.multiple}
onChange={(e) => updateForm({multiple: e.target.checked})}
/>
<label htmlFor='badgeMultiple'>
<FormattedMessage
@ -456,7 +444,7 @@ const BadgeModal: React.FC = () => {
<button
className='btn btn--primary'
onClick={handleSubmit}
disabled={loading || !name.trim() || !image.trim()}
disabled={loading || !form.name.trim() || !form.image.trim()}
>
{loading ? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'}) : submitLabel}
</button>

View File

@ -0,0 +1,93 @@
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {TypeFormData} from 'types/badges';
import UserMultiSelect from 'components/user_multi_select';
type Props = {
form: TypeFormData;
onChange: (updates: Partial<TypeFormData>) => void;
}
const InlineTypeForm: React.FC<Props> = ({form, onChange}) => {
const intl = useIntl();
return (
<div className='inline-type-section'>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.new_type_name'
defaultMessage='Название типа'
/>
</label>
<input
type='text'
value={form.name}
onChange={(e) => onChange({name: e.target.value})}
maxLength={20}
placeholder={intl.formatMessage({id: 'badges.modal.new_type_name_placeholder', defaultMessage: 'Название типа (макс. 20 символов)'})}
/>
</div>
<div className='checkbox-group'>
<input
type='checkbox'
id='newTypeEveryoneCanCreate'
checked={form.everyoneCanCreate}
onChange={(e) => onChange({everyoneCanCreate: e.target.checked})}
/>
<label htmlFor='newTypeEveryoneCanCreate'>
<FormattedMessage
id='badges.modal.new_type_everyone_create'
defaultMessage='Все могут создавать значки'
/>
</label>
</div>
{!form.everyoneCanCreate && (
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.allowlist_create'
defaultMessage='Список допущенных к созданию'
/>
</label>
<UserMultiSelect
value={form.allowlistCanCreate}
onChange={(v) => onChange({allowlistCanCreate: v})}
/>
</div>
)}
<div className='checkbox-group'>
<input
type='checkbox'
id='newTypeEveryoneCanGrant'
checked={form.everyoneCanGrant}
onChange={(e) => onChange({everyoneCanGrant: e.target.checked})}
/>
<label htmlFor='newTypeEveryoneCanGrant'>
<FormattedMessage
id='badges.modal.new_type_everyone_grant'
defaultMessage='Все могут выдавать значки'
/>
</label>
</div>
{!form.everyoneCanGrant && (
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.allowlist_grant'
defaultMessage='Список допущенных к выдаче'
/>
</label>
<UserMultiSelect
value={form.allowlistCanGrant}
onChange={(v) => onChange({allowlistCanGrant: v})}
/>
</div>
)}
</div>
);
};
export default InlineTypeForm;

View File

@ -14,17 +14,26 @@
}
}
&--empty {
&__loadingWrap {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
.spinner {
width: 48px;
height: 48px;
}
}
&__emptyContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
flex: 1;
}
&__emptyTitle {
@ -48,6 +57,66 @@
margin-bottom: 8px;
}
&__tabs {
display: flex;
gap: 0;
}
&__tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 4px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
&:hover {
color: var(--center-channel-color, #3d3c40);
}
&--active {
color: var(--button-bg, #166de0);
border-bottom-color: var(--button-bg, #166de0);
}
}
&__empty {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
font-size: 14px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
}
&__backHeader {
display: flex;
flex-direction: column;
gap: 4px;
}
&__backButton {
background: none;
border: none;
padding: 0;
font-size: 12px;
color: var(--button-bg, #166de0);
cursor: pointer;
text-align: left;
&:hover {
text-decoration: underline;
}
}
&__filterTitle {
font-size: 15px;
font-weight: 600;
color: var(--center-channel-color, #3d3c40);
}
&__createButton {
background: var(--button-bg, #166de0);
color: var(--button-color, #fff);

View File

@ -1,6 +1,8 @@
import React from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {Virtuoso} from 'react-virtuoso';
import {useSelector} from 'react-redux';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
@ -8,14 +10,16 @@ import {BadgeID, AllBadgesBadge} from '../../types/badges';
import Client from '../../client/api';
import {RHSState} from '../../types/general';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL} from '../../constants';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL, RHS_STATE_TYPES} from '../../constants';
import {isCreateBadgeModalVisible, getEditBadgeModalData} from '../../selectors';
import AllBadgesRow from './all_badges_row';
import RHSScrollbars from './rhs_scrollbars';
import './all_badges.scss';
type Props = {
filterTypeId?: number | null;
filterTypeName?: string | null;
actions: {
setRHSView: (view: RHSState) => void;
setRHSBadge: (badge: BadgeID | null) => void;
@ -24,54 +28,86 @@ type Props = {
};
}
type State = {
loading: boolean;
badges?: AllBadgesBadge[];
}
const AllBadges: React.FC<Props> = ({filterTypeId, filterTypeName, actions}) => {
const [loading, setLoading] = useState(true);
const [badges, setBadges] = useState<AllBadgesBadge[]>([]);
const isFiltered = filterTypeId != null;
class AllBadges extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
const createBadgeVisible = useSelector(isCreateBadgeModalVisible);
const editBadgeData = useSelector(getEditBadgeModalData);
const isModalOpen = createBadgeVisible || editBadgeData !== null;
const wasModalOpen = useRef(false);
this.state = {
loading: true,
};
}
const fetchBadges = useCallback(() => {
const client = new Client();
client.getAllBadges().then((data) => {
setBadges(data);
Review

идентично

идентично
setLoading(false);
componentDidMount() {
const c = new Client();
c.getAllBadges().then((badges) => {
this.setState({badges, loading: false});
});
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.badges !== prevState.badges) {
const names: string[] = [];
this.state.badges?.forEach((badge) => {
data.forEach((badge) => {
if (badge.image_type === IMAGE_TYPE_EMOJI) {
names.push(badge.image);
}
});
const toLoad = names.filter((v) => !systemEmojis.has(v));
this.props.actions.getCustomEmojisByName(toLoad);
}
}
actions.getCustomEmojisByName(toLoad);
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
onBadgeClick = (badge: AllBadgesBadge) => {
this.props.actions.setRHSBadge(badge.id);
this.props.actions.setRHSView(RHS_STATE_DETAIL);
}
useEffect(() => {
fetchBadges();
}, [fetchBadges]);
render() {
if (this.state.loading) {
return (<div className='AllBadges AllBadges--loading'>
useEffect(() => {
if (wasModalOpen.current && !isModalOpen) {
fetchBadges();
}
wasModalOpen.current = isModalOpen;
}, [isModalOpen, fetchBadges]);
const displayBadges = useMemo(() => {
if (!isFiltered) {
return badges;
}
return badges.filter((b) => b.type === filterTypeId);
}, [badges, isFiltered, filterTypeId]);
const onBadgeClick = useCallback((badge: AllBadgesBadge) => {
actions.setRHSBadge(badge.id);
actions.setRHSView(RHS_STATE_DETAIL);
}, [actions]);
if (loading) {
return (
<div className='AllBadges__loadingWrap'>
<div className='spinner'/>
</div>);
</div>
);
}
if (!this.state.badges || this.state.badges.length === 0) {
return (<div className='AllBadges AllBadges--empty'>
const isEmpty = !isFiltered && (!badges || badges.length === 0);
return (
<>
{isFiltered && (
<div className='AllBadges__header'>
<div className='AllBadges__backHeader'>
<button
className='AllBadges__backButton'
onClick={() => actions.setRHSView(RHS_STATE_TYPES)}
>
{'← '}
<FormattedMessage
id='badges.rhs.back_to_types'
defaultMessage='Назад к типам'
/>
</button>
<span className='AllBadges__filterTitle'>{filterTypeName}</span>
</div>
</div>
)}
{isEmpty && (
<div className='AllBadges__emptyContent'>
<div className='AllBadges__emptyTitle'>
<FormattedMessage
@ -85,51 +121,33 @@ class AllBadges extends React.PureComponent<Props, State> {
defaultMessage='Создайте первый значок, чтобы отмечать достижения и заслуги участников команды.'
/>
</div>
<button
className='AllBadges__createButton'
onClick={this.props.actions.openCreateBadgeModal}
>
<FormattedMessage
id='badges.rhs.create_badge'
defaultMessage='+ Создать значок'
/>
</button>
</div>
</div>);
}
const content = this.state.badges.map((badge) => {
return (
)}
{!isEmpty && displayBadges.length === 0 && (
<div className='AllBadges__empty'>
<FormattedMessage
id='badges.types.no_badges'
defaultMessage='В этом типе нет значков'
/>
</div>
)}
{!isEmpty && displayBadges.length > 0 && (
<Virtuoso
style={{flex: '1 1 auto'}}
data={displayBadges}
increaseViewportBy={300}
overscan={200}
itemContent={(_index, badge) => (
<AllBadgesRow
key={badge.id}
badge={badge}
onClick={this.onBadgeClick}
onClick={onBadgeClick}
/>
)}
/>
)}
</>
);
});
return (
<div className='AllBadges'>
<div className='AllBadges__header'>
<b>
<FormattedMessage
id='badges.rhs.all_badges'
defaultMessage='Все значки'
/>
</b>
<button
className='AllBadges__createButton'
onClick={this.props.actions.openCreateBadgeModal}
>
<FormattedMessage
id='badges.rhs.create_badge'
defaultMessage='+ Создать значок'
/>
</button>
</div>
<RHSScrollbars>{content}</RHSScrollbars>
</div>
);
}
}
};
export default AllBadges;

View File

@ -0,0 +1,72 @@
.AllTypes {
display: flex;
flex-flow: column;
height: 100%;
padding: 10px;
&--loading {
justify-content: center;
align-items: center;
.spinner {
width: 48px;
height: 48px;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__tabs {
display: flex;
gap: 0;
}
&__tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 4px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
&:hover {
color: var(--center-channel-color, #3d3c40);
}
&--active {
color: var(--button-bg, #166de0);
border-bottom-color: var(--button-bg, #166de0);
}
}
&__createButton {
background: var(--button-bg, #166de0);
color: var(--button-color, #fff);
border: none;
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
&:hover {
opacity: 0.88;
}
}
&__empty {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
font-size: 14px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
}
}

View File

@ -0,0 +1,99 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import {Virtuoso} from 'react-virtuoso';
import {BadgeTypeDefinition} from '../../types/badges';
import Client from '../../client/api';
import {RHS_STATE_TYPE_BADGES} from '../../constants';
import {isCreateTypeModalVisible, getEditTypeModalData} from '../../selectors';
import {setRHSView, setRHSType, openEditTypeModal} from '../../actions/actions';
import AllTypesRow from './all_types_row';
import './all_types.scss';
const AllTypes: React.FC = () => {
const dispatch = useDispatch();
const [loading, setLoading] = useState(true);
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
const createTypeVisible = useSelector(isCreateTypeModalVisible);
const editTypeData = useSelector(getEditTypeModalData);
const isModalOpen = createTypeVisible || editTypeData !== null;
const wasModalOpen = useRef(false);
const fetchTypes = useCallback(async () => {
const client = new Client();
const resp = await client.getTypes();
setTypes(resp.types);
setLoading(false);
}, []);
useEffect(() => {
fetchTypes();
}, [fetchTypes]);
// Refetch types when type modal closes (after save/delete)
useEffect(() => {
if (wasModalOpen.current && !isModalOpen) {
fetchTypes();
}
wasModalOpen.current = isModalOpen;
}, [isModalOpen, fetchTypes]);
const handleEdit = useCallback((badgeType: BadgeTypeDefinition) => {
dispatch(openEditTypeModal(badgeType));
}, [dispatch]);
Review

как будто и не надо в зависимости добавлять

как будто и не надо в зависимости добавлять
const handleDelete = useCallback(async (badgeType: BadgeTypeDefinition) => {
const client = new Client();
await client.deleteType(String(badgeType.id));
setTypes((prev) => prev.filter((t) => t.id !== badgeType.id));
}, []);
const handleClick = useCallback((badgeType: BadgeTypeDefinition) => {
dispatch(setRHSType(badgeType.id, badgeType.name));
dispatch(setRHSView(RHS_STATE_TYPE_BADGES));
}, [dispatch]);
if (loading) {
return (
<div className='AllTypes AllTypes--loading'>
<div className='spinner'/>
</div>
);
}
if (types.length === 0) {
return (
<div className='AllTypes__empty'>
<FormattedMessage
id='badges.types.empty'
defaultMessage='Типов пока нет'
/>
</div>
);
}
return (
<Virtuoso
style={{flex: '1 1 auto'}}
data={types}
increaseViewportBy={300}
overscan={200}
itemContent={(_index, t) => (
<AllTypesRow
key={t.id}
badgeType={t}
onClick={handleClick}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
/>
);
};
export default AllTypes;

View File

@ -0,0 +1,93 @@
.AllTypesRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: 4px;
cursor: default;
&:hover {
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
}
&__info {
flex: 1;
min-width: 0;
}
&__name {
font-size: 14px;
font-weight: 600;
color: var(--center-channel-color, #3d3c40);
display: flex;
align-items: center;
gap: 6px;
}
&__default {
font-size: 10px;
font-weight: 600;
color: var(--button-bg, #166de0);
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08);
padding: 1px 6px;
border-radius: 10px;
}
&__meta {
font-size: 12px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
margin-top: 2px;
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
&__btn {
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
&:hover {
color: var(--center-channel-color, #3d3c40);
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
}
&--edit:hover {
color: var(--button-bg, #166de0);
}
&--danger {
color: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.72);
&:hover {
color: var(--error-text, #d24b4e);
background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08);
}
}
&--cancel:hover {
color: var(--center-channel-color, #3d3c40);
}
}
&__confirmDelete {
display: flex;
align-items: center;
gap: 4px;
}
&__confirmText {
font-size: 12px;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
white-space: nowrap;
}
}

View File

@ -0,0 +1,132 @@
import React, {useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {BadgeTypeDefinition} from '../../types/badges';
import './all_types_row.scss';
type Props = {
badgeType: BadgeTypeDefinition;
onEdit: (badgeType: BadgeTypeDefinition) => void;
onDelete: (badgeType: BadgeTypeDefinition) => void;
onClick: (badgeType: BadgeTypeDefinition) => void;
}
const AllTypesRow: React.FC<Props> = ({badgeType, onEdit, onDelete, onClick}: Props) => {
const [confirmDelete, setConfirmDelete] = useState(false);
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
onDelete(badgeType);
};
return (
<div
className='AllTypesRow'
onClick={() => onClick(badgeType)}
>
<div className='AllTypesRow__info'>
<div className='AllTypesRow__name'>
{badgeType.name}
{badgeType.is_default && (
<span className='AllTypesRow__default'>
<FormattedMessage
id='badges.types.is_default'
defaultMessage='По умолчанию'
/>
</span>
)}
</div>
<div className='AllTypesRow__meta'>
<FormattedMessage
id='badges.types.badge_count'
defaultMessage='{count, plural, one {# значок} few {# значка} many {# значков} other {# значков}}'
values={{count: badgeType.badge_count}}
/>
{badgeType.can_create?.everyone && (
<>
{' · '}
<FormattedMessage
id='badges.types.everyone_can_create'
defaultMessage='Все создают'
/>
</>
)}
{badgeType.can_grant?.everyone && (
<>
{' · '}
<FormattedMessage
id='badges.types.everyone_can_grant'
defaultMessage='Все выдают'
/>
</>
)}
</div>
</div>
<div
className='AllTypesRow__actions'
onClick={(e) => e.stopPropagation()}
>
{!confirmDelete && (
<button
className='AllTypesRow__btn AllTypesRow__btn--edit'
onClick={() => onEdit(badgeType)}
>
<FormattedMessage
id='badges.rhs.edit_badge'
defaultMessage='Редактировать'
/>
</button>
)}
{!badgeType.is_default && (
<>
{confirmDelete ? (
<div className='AllTypesRow__confirmDelete'>
<span className='AllTypesRow__confirmText'>
<FormattedMessage
id='badges.modal.confirm_delete'
defaultMessage='Вы уверены?'
/>
</span>
<button
className='AllTypesRow__btn AllTypesRow__btn--danger'
onClick={handleDelete}
>
<FormattedMessage
id='badges.modal.btn_confirm_delete_type'
defaultMessage='Да, удалить'
/>
</button>
<button
className='AllTypesRow__btn AllTypesRow__btn--cancel'
onClick={() => setConfirmDelete(false)}
>
<FormattedMessage
id='badges.modal.btn_cancel'
defaultMessage='Отмена'
/>
</button>
</div>
) : (
<button
className='AllTypesRow__btn AllTypesRow__btn--danger'
onClick={handleDelete}
>
<FormattedMessage
id='badges.modal.delete_type'
defaultMessage='Удалить'
/>
</button>
)}
</>
)}
</div>
</div>
);
};
export default AllTypesRow;

View File

@ -5,7 +5,7 @@ import {useDispatch, useSelector} from 'react-redux';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import React from 'react';
import React, {useCallback, useEffect, useState} from 'react';
import {getUser} from 'mattermost-redux/selectors/entities/users';
@ -13,25 +13,125 @@ import {GlobalState} from 'mattermost-redux/types/store';
import {getCustomEmojiByName, getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
import {getRHSBadge, getRHSUser, getRHSView} from 'selectors';
import {RHS_STATE_ALL, RHS_STATE_DETAIL, RHS_STATE_OTHER, RHS_STATE_MY} from '../../constants';
import {FormattedMessage} from 'react-intl';
import {getRHSBadge, getRHSUser, getRHSView, getRHSTypeId, getRHSTypeName} from 'selectors';
import {RHS_STATE_ALL, RHS_STATE_DETAIL, RHS_STATE_OTHER, RHS_STATE_MY, RHS_STATE_TYPES, RHS_STATE_TYPE_BADGES} from '../../constants';
import {RHSState} from 'types/general';
import {openCreateBadgeModal, openEditBadgeModal, setRHSBadge, setRHSUser, setRHSView} from 'actions/actions';
import {openCreateBadgeModal, openCreateTypeModal, openEditBadgeModal, setRHSBadge, setRHSUser, setRHSView} from 'actions/actions';
import {BadgeDetails, BadgeID} from 'types/badges';
import Client from '../../client/api';
import UserBadges from './user_badges';
import BadgeDetailsComponent from './badge_details';
import AllBadges from './all_badges';
import AllTypes from './all_types';
import './all_badges.scss';
const RHS: React.FC = () => {
const dispatch = useDispatch();
const currentView = useSelector(getRHSView);
const currentBadge = useSelector(getRHSBadge);
const currentUserID = useSelector(getRHSUser);
const filterTypeId = useSelector(getRHSTypeId);
const filterTypeName = useSelector(getRHSTypeName);
const currentUser = useSelector((state: GlobalState) => getUser(state, (currentUserID as string)));
const myUser = useSelector(getCurrentUser);
const [canEditType, setCanEditType] = useState(false);
const [canCreateType, setCanCreateType] = useState(false);
useEffect(() => {
const client = new Client();
client.getTypes().then((resp) => {
setCanEditType(resp.can_edit_type);
setCanCreateType(resp.can_create_type);
});
}, []);
const showTabs = currentView === RHS_STATE_ALL || currentView === RHS_STATE_TYPES;
const handleCreateBadge = useCallback(() => {
dispatch(openCreateBadgeModal());
}, [dispatch]);
const handleCreateType = useCallback(() => {
dispatch(openCreateTypeModal());
}, [dispatch]);
const renderTabs = () => {
if (!showTabs) {
return null;
}
return (
<div className='AllBadges__header'>
<div className='AllBadges__tabs'>
<button
className={'AllBadges__tab' + (currentView === RHS_STATE_ALL ? ' AllBadges__tab--active' : '')}
onClick={() => dispatch(setRHSView(RHS_STATE_ALL))}
>
<FormattedMessage
id='badges.rhs.all_badges'
defaultMessage='Все значки'
/>
</button>
{canEditType && (
<button
className={'AllBadges__tab' + (currentView === RHS_STATE_TYPES ? ' AllBadges__tab--active' : '')}
onClick={() => dispatch(setRHSView(RHS_STATE_TYPES))}
>
<FormattedMessage
id='badges.rhs.types'
defaultMessage='Типы'
/>
</button>
)}
</div>
{currentView === RHS_STATE_ALL && (
<button
className='AllBadges__createButton'
onClick={handleCreateBadge}
>
<FormattedMessage
id='badges.rhs.create_badge'
defaultMessage='+ Создать значок'
/>
</button>
)}
{currentView === RHS_STATE_TYPES && canCreateType && (
<button
className='AllBadges__createButton'
onClick={handleCreateType}
>
<FormattedMessage
id='badges.rhs.create_type'
defaultMessage='+ Создать тип'
/>
</button>
)}
</div>
);
};
const renderContent = () => {
switch (currentView) {
case RHS_STATE_TYPES:
return <AllTypes/>;
case RHS_STATE_TYPE_BADGES:
return (
<AllBadges
filterTypeId={filterTypeId}
filterTypeName={filterTypeName}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
Review

выглядит как отдельный компонент или useMemo

выглядит как отдельный компонент или useMemo
openCreateBadgeModal: () => dispatch(openCreateBadgeModal()),
}}
/>
);
case RHS_STATE_ALL:
return (
<AllBadges
@ -82,6 +182,20 @@ const RHS: React.FC = () => {
/>
);
}
};
const needsWrapper = showTabs || currentView === RHS_STATE_TYPE_BADGES;
if (needsWrapper) {
return (
<div className='AllBadges'>
{renderTabs()}
{renderContent()}
</div>
);
}
return renderContent();
};
export default RHS;

View File

@ -0,0 +1,296 @@
import React, {useCallback, useEffect, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {FormattedMessage, useIntl} from 'react-intl';
import {TypeFormData} from 'types/badges';
import {isCreateTypeModalVisible, getEditTypeModalData} from 'selectors';
import {closeCreateTypeModal, closeEditTypeModal} from 'actions/actions';
import Client from 'client/api';
import {getServerErrorId} from 'utils/helpers';
import CloseIcon from 'components/icons/close_icon';
import UserMultiSelect from 'components/user_multi_select';
const emptyTypeForm: TypeFormData = {
name: '',
everyoneCanCreate: false,
everyoneCanGrant: false,
allowlistCanCreate: '',
allowlistCanGrant: '',
};
const TypeModal: React.FC = () => {
const dispatch = useDispatch();
const intl = useIntl();
const createVisible = useSelector(isCreateTypeModalVisible);
const editData = useSelector(getEditTypeModalData);
const isOpen = createVisible || editData !== null;
const isEditMode = editData !== null;
const [form, setForm] = useState<TypeFormData>(emptyTypeForm);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const updateForm = useCallback((updates: Partial<TypeFormData>) => {
setForm((prev) => ({...prev, ...updates}));
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
if (isEditMode && editData) {
setForm({
name: editData.name,
everyoneCanCreate: editData.can_create?.everyone || false,
everyoneCanGrant: editData.can_grant?.everyone || false,
allowlistCanCreate: editData.allowlist_can_create || '',
allowlistCanGrant: editData.allowlist_can_grant || '',
});
} else {
setForm(emptyTypeForm);
}
setError(null);
setConfirmDelete(false);
setLoading(false);
}, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps
const handleClose = useCallback(() => {
if (createVisible) {
dispatch(closeCreateTypeModal());
}
if (editData) {
dispatch(closeEditTypeModal());
}
}, [dispatch, createVisible, editData]);
const handleSubmit = useCallback(async () => {
setLoading(true);
setError(null);
try {
const client = new Client();
const payload = {
name: form.name.trim(),
everyone_can_create: form.everyoneCanCreate,
everyone_can_grant: form.everyoneCanGrant,
allowlist_can_create: form.allowlistCanCreate.trim(),
allowlist_can_grant: form.allowlistCanGrant.trim(),
};
if (isEditMode && editData) {
Review

опять клиент

опять клиент
await client.updateType({id: String(editData.id), ...payload});
} else {
await client.createType(payload);
}
handleClose();
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [isEditMode, editData, form, handleClose, intl]);
const handleDelete = useCallback(async () => {
if (!editData) {
return;
}
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
setLoading(true);
setError(null);
try {
const client = new Client();
await client.deleteType(String(editData.id));
handleClose();
} catch (err) {
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
} finally {
setLoading(false);
}
}, [editData, confirmDelete, handleClose, intl]);
Review

опять он

опять он
if (!isOpen) {
return null;
}
const title = isEditMode
? intl.formatMessage({id: 'badges.modal.edit_type_title', defaultMessage: 'Редактировать тип'})
: intl.formatMessage({id: 'badges.modal.create_type_title', defaultMessage: 'Создать тип'});
const submitLabel = isEditMode
? intl.formatMessage({id: 'badges.modal.btn_save', defaultMessage: 'Сохранить'})
: intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'});
return (
<div className='BadgeModal'>
<div
className='BadgeModal__backdrop'
onClick={handleClose}
/>
<div className='BadgeModal__dialog'>
<div className='BadgeModal__header'>
<h4>{title}</h4>
<button
className='close-btn'
onClick={handleClose}
>
<CloseIcon/>
</button>
</div>
<div className='BadgeModal__body'>
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.field_name'
defaultMessage='Название'
/>
</label>
<input
type='text'
value={form.name}
onChange={(e) => updateForm({name: e.target.value})}
maxLength={20}
placeholder={intl.formatMessage({id: 'badges.modal.new_type_name_placeholder', defaultMessage: 'Название типа (макс. 20 символов)'})}
/>
</div>
<div className='checkbox-group'>
<input
type='checkbox'
id='typeEveryoneCanCreate'
checked={form.everyoneCanCreate}
onChange={(e) => updateForm({everyoneCanCreate: e.target.checked})}
/>
<label htmlFor='typeEveryoneCanCreate'>
<FormattedMessage
id='badges.modal.new_type_everyone_create'
defaultMessage='Все могут создавать значки'
/>
</label>
</div>
{!form.everyoneCanCreate && (
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.allowlist_create'
defaultMessage='Список допущенных к созданию'
/>
</label>
<UserMultiSelect
value={form.allowlistCanCreate}
onChange={(v) => updateForm({allowlistCanCreate: v})}
/>
<span className='form-group__help'>
<FormattedMessage
id='badges.modal.allowlist_create_help'
defaultMessage='Пользователи, кото<D182><D0BE>ые могут создавать значки этого типа.'
/>
</span>
</div>
)}
<div className='checkbox-group'>
<input
type='checkbox'
id='typeEveryoneCanGrant'
checked={form.everyoneCanGrant}
onChange={(e) => updateForm({everyoneCanGrant: e.target.checked})}
/>
<label htmlFor='typeEveryoneCanGrant'>
<FormattedMessage
id='badges.modal.new_type_everyone_grant'
defaultMessage='Все могут выдавать значки'
/>
</label>
</div>
{!form.everyoneCanGrant && (
<div className='form-group'>
<label>
<FormattedMessage
id='badges.modal.allowlist_grant'
defaultMessage='Список допущенных к выдаче'
/>
</label>
<UserMultiSelect
value={form.allowlistCanGrant}
onChange={(v) => updateForm({allowlistCanGrant: v})}
/>
<span className='form-group__help'>
<FormattedMessage
id='badges.modal.allowlist_grant_help'
defaultMessage='Пользователи, которые могут выдавать значки этого типа.'
/>
</span>
</div>
)}
{error && <div className='error-message'>{error}</div>}
{isEditMode && !editData?.is_default && (
<div className='delete-section'>
{confirmDelete ? (
<div className='confirm-delete'>
<span>
<FormattedMessage
id='badges.types.confirm_delete'
defaultMessage='Удалить тип «{name}» и все его значки?'
values={{name: editData?.name}}
/>
</span>
<button
className='btn btn--danger'
onClick={handleDelete}
disabled={loading}
>
<FormattedMessage
id='badges.modal.btn_confirm_delete'
defaultMessage='Да, удалить'
/>
</button>
<button
className='btn btn--cancel'
onClick={() => setConfirmDelete(false)}
>
<FormattedMessage
id='badges.modal.btn_cancel'
defaultMessage='Отмена'
/>
</button>
</div>
) : (
<button
className='btn btn--danger'
onClick={handleDelete}
disabled={loading}
>
<FormattedMessage
id='badges.modal.btn_delete_type'
defaultMessage='Удалить тип'
/>
</button>
)}
</div>
)}
</div>
<div className='BadgeModal__footer'>
<button
className='btn btn--cancel'
onClick={handleClose}
>
<FormattedMessage
id='badges.modal.btn_cancel'
defaultMessage='Отмена'
/>
</button>
<button
className='btn btn--primary'
onClick={handleSubmit}
disabled={loading || !form.name.trim()}
>
{loading ? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'}) : submitLabel}
</button>
</div>
</div>
</div>
);
};
export default TypeModal;

View File

@ -0,0 +1,244 @@
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {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 './user_multi_select.scss';
type SelectedUser = {
id: string;
username: string;
fullName: string;
avatarUrl: string;
}
type Props = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}
const UserMultiSelect: React.FC<Props> = ({value, onChange, placeholder, disabled}) => {
const intl = useIntl();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState<UserProfile[]>([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [profilesLoading, setProfilesLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const loadedValueRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
if (loadedValueRef.current === value) {
Review

как будто можно инвертировать условие if (loadedValueRef.current !== value && value) {...} или типо того

как будто можно инвертировать условие if (loadedValueRef.current !== value && value) {...} или типо того
// Already synced — nothing to do
} else if (value) {
const usernames = value.split(',').map((u) => u.trim()).filter(Boolean);
if (usernames.length === 0) {
setSelectedUsers([]);
loadedValueRef.current = value;
} else {
setProfilesLoading(true);
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((users) => {
if (!cancelled) {
setSelectedUsers(users);
loadedValueRef.current = value;
setProfilesLoading(false);
}
});
}
} else {
setSelectedUsers([]);
setProfilesLoading(false);
loadedValueRef.current = '';
}
return () => {
cancelled = true;
};
}, [value]);
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 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);
const newValue = next.map((u) => u.username).join(', ');
loadedValueRef.current = newValue;
onChange(newValue);
setSearchTerm('');
setResults([]);
setDropdownOpen(false);
inputRef.current?.focus();
};
const handleRemove = (username: string) => {
const next = selectedUsers.filter((u) => u.username !== username);
setSelectedUsers(next);
const newValue = next.map((u) => u.username).join(', ');
loadedValueRef.current = newValue;
onChange(newValue);
};
const placeholderText = placeholder || intl.formatMessage({
id: 'badges.admin.placeholder',
defaultMessage: 'Начните вводить имя...',
});
return (
<div
className='user-multi-select'
ref={containerRef}
>
<div
className='user-multi-select__container'
onClick={() => inputRef.current?.focus()}
>
{(loading || profilesLoading) ? (
<div className='user-multi-select__spinner'/>
) : (
<SearchIcon/>
)}
{profilesLoading ? null : selectedUsers.map((user) => (
Review

на твое усмотрение {!profilesLoading && selectedUsers.map...}

на твое усмотрение {!profilesLoading && selectedUsers.map...}
<span
key={user.username}
className='user-multi-select__chip'
>
{user.avatarUrl && (
<img
className='user-multi-select__chip-avatar'
src={user.avatarUrl}
alt={user.username}
/>
)}
<span className='user-multi-select__chip-name'>
{user.fullName || user.username}
</span>
{!disabled && (
<button
type='button'
className='user-multi-select__chip-remove'
onClick={(e) => {
e.stopPropagation();
handleRemove(user.username);
}}
>
<CloseIcon size={12}/>
</button>
)}
</span>
))}
<input
ref={inputRef}
className='user-multi-select__input'
type='text'
value={searchTerm}
disabled={disabled}
onChange={handleInputChange}
placeholder={selectedUsers.length === 0 ? placeholderText : ''}
/>
</div>
{dropdownOpen && (
<div className='user-multi-select__dropdown'>
{results.length === 0 && searchTerm && (
<div className={`user-multi-select__no-results${loading ? ' user-multi-select__no-results--loading' : ''}`}>
{intl.formatMessage({
id: 'badges.admin.no_results',
defaultMessage: 'Пользователь не найден',
})}
</div>
)}
{results.map((user) => (
<div
key={user.id}
className='user-multi-select__option'
onClick={() => handleSelect(user)}
>
<img
className='user-multi-select__avatar'
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
alt={user.username}
/>
<span className='user-multi-select__option-name'>
{user.username}
</span>
{(user.first_name || user.last_name) && (
<span className='user-multi-select__option-fullname'>
{'— '}{`${user.first_name} ${user.last_name}`.trim()}
</span>
)}
</div>
))}
</div>
)}
</div>
);
};
export default UserMultiSelect;

View File

@ -1,4 +1,4 @@
.admin-user-select {
.user-multi-select {
position: relative;
&__container {
@ -19,11 +19,6 @@
}
}
&__icon {
flex-shrink: 0;
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
}
&__spinner {
flex-shrink: 0;
width: 18px;
@ -31,7 +26,7 @@
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;
animation: user-multi-select-spin 0.6s linear infinite;
}
&__chip {
@ -154,7 +149,7 @@
}
}
@keyframes admin-user-select-spin {
@keyframes user-multi-select-spin {
to {
transform: rotate(360deg);
}

View File

@ -8,12 +8,18 @@ export const RHS_STATE_MY: RHSState = 'my';
export const RHS_STATE_OTHER: RHSState = 'other';
export const RHS_STATE_ALL: RHSState = 'all';
export const RHS_STATE_DETAIL: RHSState = 'detail';
export const RHS_STATE_TYPES: RHSState = 'types';
export const RHS_STATE_TYPE_BADGES: RHSState = 'type_badges';
export const initialState: PluginState = {
showRHS: null,
rhsView: RHS_STATE_MY,
rhsBadge: null,
rhsUser: null,
rhsTypeId: null,
rhsTypeName: null,
createBadgeModalVisible: false,
editBadgeModalData: null,
createTypeModalVisible: false,
editTypeModalData: null,
};

View File

@ -18,6 +18,7 @@ import {openAddSubscription, openCreateBadge, openCreateType, openRemoveSubscrip
import RHSComponent from 'components/rhs';
import BadgeModal from 'components/badge_modal';
import TypeModal from 'components/type_modal';
import ChannelHeaderButton from 'components/channel_header_button';
@ -62,6 +63,7 @@ export default class Plugin {
registry.registerPopoverUserAttributesComponent(WrappedBadgeList);
registry.registerRootComponent(withIntl(BadgeModal));
registry.registerRootComponent(withIntl(TypeModal));
const locale = getCurrentUser(store.getState())?.locale || 'ru';
const messages = getTranslations(locale);

View File

@ -41,6 +41,24 @@ function rhsBadge(state = null, action: GenericAction) {
}
}
function rhsTypeId(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_RHS_TYPE:
return action.data.typeId;
default:
return state;
}
}
function rhsTypeName(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_RHS_TYPE:
return action.data.typeName;
default:
return state;
}
}
function createBadgeModalVisible(state = false, action: GenericAction) {
switch (action.type) {
case ActionTypes.OPEN_CREATE_BADGE_MODAL:
@ -63,11 +81,37 @@ function editBadgeModalData(state = null, action: GenericAction) {
}
}
function createTypeModalVisible(state = false, action: GenericAction) {
switch (action.type) {
case ActionTypes.OPEN_CREATE_TYPE_MODAL:
return true;
case ActionTypes.CLOSE_CREATE_TYPE_MODAL:
return false;
default:
return state;
}
}
function editTypeModalData(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.OPEN_EDIT_TYPE_MODAL:
return action.data;
case ActionTypes.CLOSE_EDIT_TYPE_MODAL:
return null;
default:
return state;
}
}
export default combineReducers({
showRHS,
rhsView,
rhsUser,
rhsBadge,
rhsTypeId,
rhsTypeName,
createBadgeModalVisible,
editBadgeModalData,
createTypeModalVisible,
editTypeModalData,
});

View File

@ -44,6 +44,20 @@ export const getRHSBadge = createSelector(
},
);
export const getRHSTypeId = createSelector(
getPluginState,
(state) => {
return state.rhsTypeId;
},
);
export const getRHSTypeName = createSelector(
getPluginState,
(state) => {
return state.rhsTypeName;
},
);
export const isCreateBadgeModalVisible = createSelector(
getPluginState,
(state) => {
@ -57,3 +71,17 @@ export const getEditBadgeModalData = createSelector(
return state.editBadgeModalData;
},
);
export const isCreateTypeModalVisible = createSelector(
getPluginState,
(state) => {
return state.createTypeModalVisible;
},
);
export const getEditTypeModalData = createSelector(
getPluginState,
(state) => {
return state.editTypeModalData;
},
);

View File

@ -47,6 +47,8 @@ export type BadgeTypeDefinition = {
can_create: PermissionScheme;
badge_count: number;
is_default: boolean;
allowlist_can_create: string;
allowlist_can_grant: string;
}
export type PermissionScheme = {
@ -59,6 +61,23 @@ export type PermissionScheme = {
export type GetTypesResponse = {
types: BadgeTypeDefinition[];
can_create_type: boolean;
can_edit_type: boolean;
}
export type TypeFormData = {
name: string;
everyoneCanCreate: boolean;
everyoneCanGrant: boolean;
allowlistCanCreate: string;
allowlistCanGrant: string;
}
export type BadgeFormData = {
name: string;
description: string;
image: string;
badgeType: string;
multiple: boolean;
}
export type CreateBadgeRequest = {
@ -83,5 +102,16 @@ export type CreateTypeRequest = {
name: string;
everyone_can_create: boolean;
everyone_can_grant: boolean;
allowlist_can_create: string;
allowlist_can_grant: string;
channel_id?: string;
}
export type UpdateTypeRequest = {
id: string;
name: string;
everyone_can_create: boolean;
everyone_can_grant: boolean;
allowlist_can_create: string;
allowlist_can_grant: string;
}

View File

@ -1,4 +1,4 @@
import {BadgeDetails, BadgeID} from './badges';
import {BadgeDetails, BadgeID, BadgeTypeDefinition} from './badges';
export type RHSState = string;
@ -7,6 +7,10 @@ export type PluginState = {
rhsView: RHSState;
rhsUser: string | null;
rhsBadge: BadgeID | null;
rhsTypeId: number | null;
rhsTypeName: string | null;
createBadgeModalVisible: boolean;
editBadgeModalData: BadgeDetails | null;
createTypeModalVisible: boolean;
editTypeModalData: BadgeTypeDefinition | null;
}

View File

@ -9594,6 +9594,16 @@ __metadata:
languageName: node
linkType: hard
"react-virtuoso@npm:^4.18.1":
version: 4.18.1
resolution: "react-virtuoso@npm:4.18.1"
peerDependencies:
react: ">=16 || >=17 || >= 18 || >= 19"
react-dom: ">=16 || >=17 || >= 18 || >=19"
checksum: 10c0/ed17f580ad8d625ef9e0278ed12190bbadbacf7e39434047b7994e4967ad9d868b66eaee8a66a2890d2964d99d9b266a4657375488c19b1e58de252fb2e8d3e5
languageName: node
linkType: hard
"react@npm:17.0.2":
version: 17.0.2
resolution: "react@npm:17.0.2"
@ -10175,6 +10185,7 @@ __metadata:
react-custom-scrollbars: "npm:^4.2.1"
react-intl: "npm:6.8.9"
react-redux: "npm:7.2.3"
react-virtuoso: "npm:^4.18.1"
redux: "npm:4.0.5"
sass: "npm:1.86.0"
sass-loader: "npm:11.0.1"