diff --git a/server/api.go b/server/api.go index ab19455..69ed300 100644 --- a/server/api.go +++ b/server/api.go @@ -51,20 +51,34 @@ type UpdateBadgeRequest struct { } type CreateTypeRequest struct { - Name string `json:"name"` - EveryoneCanCreate bool `json:"everyone_can_create"` - EveryoneCanGrant bool `json:"everyone_can_grant"` - ChannelID string `json:"channel_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"` + 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"` + 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 { diff --git a/server/utils.go b/server/utils.go index 345e7cd..758556f 100644 --- a/server/utils.go +++ b/server/utils.go @@ -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 diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index cdce4b2..dc70000 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -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", diff --git a/webapp/i18n/ru.json b/webapp/i18n/ru.json index 72b6ed3..cbd0404 100644 --- a/webapp/i18n/ru.json +++ b/webapp/i18n/ru.json @@ -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": "Мероприятия", diff --git a/webapp/package.json b/webapp/package.json index b58def7..3c9fe4c 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -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" }, diff --git a/webapp/src/action_types/index.ts b/webapp/src/action_types/index.ts index 88a672c..eaa067e 100644 --- a/webapp/src/action_types/index.ts +++ b/webapp/src/action_types/index.ts @@ -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', }; diff --git a/webapp/src/actions/actions.ts b/webapp/src/actions/actions.ts index ae44103..c873be8 100644 --- a/webapp/src/actions/actions.ts +++ b/webapp/src/actions/actions.ts @@ -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, getState: GetStateFunc) => { const command = '/badges subscription create'; diff --git a/webapp/src/client/api.ts b/webapp/src/client/api.ts index 8769498..87d0259 100644 --- a/webapp/src/client/api.ts +++ b/webapp/src/client/api.ts @@ -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 { + return await this.doPut(`${this.url}/updateType`, req) as BadgeTypeDefinition; + } + async deleteType(typeID: string): Promise { await this.doDelete(`${this.url}/deleteType/${typeID}`); } diff --git a/webapp/src/components/admin/badges_admin_setting.tsx b/webapp/src/components/admin/badges_admin_setting.tsx index 9d95b06..02aae09 100644 --- a/webapp/src/components/admin/badges_admin_setting.tsx +++ b/webapp/src/components/admin/badges_admin_setting.tsx @@ -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 = ({id, value, disabled, onChange, setSaveNeeded}) => { - const intl = useIntl(); - const containerRef = useRef(null); - const inputRef = useRef(null); - - const [searchTerm, setSearchTerm] = useState(''); - const [results, setResults] = useState([]); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [loading, setLoading] = useState(false); - const [selectedUsers, setSelectedUsers] = useState([]); - - useEffect(() => { - if (!value) { - return; - } - const usernames = value.split(',').map((u) => u.trim()).filter(Boolean); - Promise.all(usernames.map(async (username) => { - try { - const user = await Client4.getUserByUsername(username); - return { - id: user.id, - username: user.username, - fullName: getUserDisplayName(user), - avatarUrl: Client4.getProfilePictureUrl(user.id, user.last_picture_update), - }; - } catch { - return {id: '', username, fullName: '', avatarUrl: ''}; - } - })).then(setSelectedUsers); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setDropdownOpen(false); - setSearchTerm(''); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const performSearch = async (term: string, excluded: Set) => { - if (!term) { - setResults([]); - setDropdownOpen(false); - setLoading(false); - return; - } - setLoading(true); - try { - const data = await Client4.autocompleteUsers(term, '', '', {limit: 20}); - setResults(data.users.filter((u: UserProfile) => !excluded.has(u.username))); - } catch { - setResults([]); - } finally { - setLoading(false); - } - }; - - const doSearch = useMemo(() => debounce(performSearch, 400), []); // eslint-disable-line react-hooks/exhaustive-deps - - const saveValue = (users: SelectedUser[]) => { - onChange(id, users.map((u) => u.username).join(',')); + const handleChange = useCallback((newValue: string) => { + onChange(id, newValue); setSaveNeeded(); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const term = e.target.value; - setSearchTerm(term); - if (term) { - setDropdownOpen(true); - } - doSearch(term, new Set(selectedUsers.map((u) => u.username))); - }; - - const handleSelect = (user: UserProfile) => { - const next = [...selectedUsers, { - id: user.id, - username: user.username, - fullName: getUserDisplayName(user), - avatarUrl: Client4.getProfilePictureUrl(user.id, user.last_picture_update), - }]; - setSelectedUsers(next); - saveValue(next); - setSearchTerm(''); - setResults([]); - setDropdownOpen(false); - inputRef.current?.focus(); - }; - - const handleRemove = (username: string) => { - const next = selectedUsers.filter((u) => u.username !== username); - setSelectedUsers(next); - saveValue(next); - }; + }, [id, onChange, setSaveNeeded]); return (
@@ -136,95 +31,11 @@ const BadgesAdminSetting: React.FC = ({id, value, disabled, onChange, set />
-
-
inputRef.current?.focus()} - > - {loading ? ( -
- ) : ( - - )} - {selectedUsers.map((user) => ( - - {user.avatarUrl && ( - {user.username} - )} - - {user.fullName || user.username} - - {!disabled && ( - - )} - - ))} - -
- {dropdownOpen && ( -
- {results.length === 0 && searchTerm && ( -
- -
- )} - {results.map((user) => ( -
handleSelect(user)} - > - {user.username} - - {user.username} - - {(user.first_name || user.last_name) && ( - - {'— '}{`${user.first_name} ${user.last_name}`.trim()} - - )} -
- ))} -
- )} -
+
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 { diff --git a/webapp/src/components/badge_modal/index.tsx b/webapp/src/components/badge_modal/index.tsx index 61d8c02..270e045 100644 --- a/webapp/src/components/badge_modal/index.tsx +++ b/webapp/src/components/badge_modal/index.tsx @@ -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(emptyBadgeForm); + const [newTypeForm, setNewTypeForm] = useState(emptyTypeForm); const [types, setTypes] = useState([]); 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(null); @@ -47,8 +60,17 @@ const BadgeModal: React.FC = () => { const [confirmDeleteTypeId, setConfirmDeleteTypeId] = useState(null); const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const modalRef = useRef(null); const dialogRef = useRef(null); + const updateForm = useCallback((updates: Partial) => { + setForm((prev) => ({...prev, ...updates})); + }, []); + + const updateTypeForm = useCallback((updates: Partial) => { + 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]); 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 ( -
+
{ 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 = () => { />