LP-5613 #2
113
server/api.go
113
server/api.go
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Мероприятия",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
|
||||
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]);
|
||||
|
vladimir.khablak
commented
идентично идентично
|
||||
|
||||
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>
|
||||
|
||||
93
webapp/src/components/badge_modal/inline_type_form.tsx
Normal file
93
webapp/src/components/badge_modal/inline_type_form.tsx
Normal 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;
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
vladimir.khablak
commented
идентично идентично
|
||||
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;
|
||||
|
||||
72
webapp/src/components/rhs/all_types.scss
Normal file
72
webapp/src/components/rhs/all_types.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
99
webapp/src/components/rhs/all_types.tsx
Normal file
99
webapp/src/components/rhs/all_types.tsx
Normal 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]);
|
||||
|
vladimir.khablak
commented
как будто и не надо в зависимости добавлять как будто и не надо в зависимости добавлять
|
||||
|
||||
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;
|
||||
93
webapp/src/components/rhs/all_types_row.scss
Normal file
93
webapp/src/components/rhs/all_types_row.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
132
webapp/src/components/rhs/all_types_row.tsx
Normal file
132
webapp/src/components/rhs/all_types_row.tsx
Normal 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;
|
||||
@ -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)),
|
||||
|
vladimir.khablak
commented
выглядит как отдельный компонент или useMemo выглядит как отдельный компонент или useMemo
|
||||
openCreateBadgeModal: () => dispatch(openCreateBadgeModal()),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_ALL:
|
||||
return (
|
||||
<AllBadges
|
||||
@ -84,4 +184,18 @@ 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;
|
||||
|
||||
296
webapp/src/components/type_modal/index.tsx
Normal file
296
webapp/src/components/type_modal/index.tsx
Normal 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) {
|
||||
|
vladimir.khablak
commented
опять клиент опять клиент
|
||||
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]);
|
||||
|
vladimir.khablak
commented
опять он опять он
|
||||
|
||||
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;
|
||||
244
webapp/src/components/user_multi_select/index.tsx
Normal file
244
webapp/src/components/user_multi_select/index.tsx
Normal 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) {
|
||||
|
vladimir.khablak
commented
как будто можно инвертировать условие 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) => (
|
||||
|
vladimir.khablak
commented
на твое усмотрение {!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;
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user
может тогда лучше сделать client синглтоном прямо в файле api? Чтобы сразу экспортировать и не делать new Client когда нужен запрос?