diff --git a/badgesmodel/constants.go b/badgesmodel/constants.go index 7a4f249..f4bc1cb 100644 --- a/badgesmodel/constants.go +++ b/badgesmodel/constants.go @@ -3,6 +3,7 @@ package badgesmodel const ( NameMaxLength = 20 DescriptionMaxLength = 120 + DefaultTypeName = "Общий" ImageTypeEmoji ImageType = "emoji" ImageTypeRelativeURL ImageType = "rel_url" diff --git a/badgesmodel/model.go b/badgesmodel/model.go index 3ed98ea..51d0e26 100644 --- a/badgesmodel/model.go +++ b/badgesmodel/model.go @@ -2,6 +2,7 @@ package badgesmodel import ( "time" + "unicode/utf8" ) type BadgeType string @@ -56,6 +57,7 @@ type BadgeTypeDefinition struct { CreatedBy string `json:"created_by"` CanGrant PermissionScheme `json:"can_grant"` CanCreate PermissionScheme `json:"can_create"` + IsDefault bool `json:"is_default"` } type PermissionScheme struct { @@ -87,8 +89,8 @@ type Subscription struct { } func (b Badge) IsValid() bool { - return len(b.Name) <= NameMaxLength && - len(b.Description) <= DescriptionMaxLength && + return utf8.RuneCountInString(b.Name) <= NameMaxLength && + utf8.RuneCountInString(b.Description) <= DescriptionMaxLength && b.Image != "" } diff --git a/server/api.go b/server/api.go index 921b5bc..42e21b2 100644 --- a/server/api.go +++ b/server/api.go @@ -32,6 +32,41 @@ type APIErrorResponse struct { StatusCode int `json:"status_code"` } +type CreateBadgeRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Image string `json:"image"` + Type string `json:"type"` + Multiple bool `json:"multiple"` + ChannelID string `json:"channel_id"` +} + +type UpdateBadgeRequest struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Image string `json:"image"` + Type string `json:"type"` + Multiple bool `json:"multiple"` +} + +type CreateTypeRequest struct { + Name string `json:"name"` + EveryoneCanCreate bool `json:"everyone_can_create"` + EveryoneCanGrant bool `json:"everyone_can_grant"` + ChannelID string `json:"channel_id"` +} + +type TypeWithBadgeCount struct { + *badgesmodel.BadgeTypeDefinition + BadgeCount int `json:"badge_count"` +} + +type GetTypesResponse struct { + Types []TypeWithBadgeCount `json:"types"` + CanCreateType bool `json:"can_create_type"` +} + func (p *Plugin) initializeAPI() { p.router = mux.NewRouter() p.router.Use(p.withRecovery) @@ -44,6 +79,12 @@ func (p *Plugin) initializeAPI() { apiRouter.HandleFunc("/getUserBadges/{userID}", p.extractUserMiddleWare(p.getUserBadges, ResponseTypeJSON)).Methods(http.MethodGet) apiRouter.HandleFunc("/getBadgeDetails/{badgeID}", p.extractUserMiddleWare(p.getBadgeDetails, ResponseTypeJSON)).Methods(http.MethodGet) apiRouter.HandleFunc("/getAllBadges", p.extractUserMiddleWare(p.getAllBadges, ResponseTypeJSON)).Methods(http.MethodGet) + apiRouter.HandleFunc("/getTypes", p.extractUserMiddleWare(p.getTypes, ResponseTypeJSON)).Methods(http.MethodGet) + 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("/deleteBadge/{badgeID}", p.extractUserMiddleWare(p.apiDeleteBadge, ResponseTypeJSON)).Methods(http.MethodDelete) + apiRouter.HandleFunc("/deleteType/{typeID}", p.extractUserMiddleWare(p.apiDeleteType, ResponseTypeJSON)).Methods(http.MethodDelete) pluginAPIRouter.HandleFunc(badgesmodel.PluginAPIPathEnsure, checkPluginRequest(p.ensureBadges)).Methods(http.MethodPost) pluginAPIRouter.HandleFunc(badgesmodel.PluginAPIPathGrant, checkPluginRequest(p.grantBadge)).Methods(http.MethodPost) @@ -91,6 +132,361 @@ func dialogKeepOpen(w http.ResponseWriter) { _, _ = w.Write(resp.ToJson()) } +func (p *Plugin) getTypes(w http.ResponseWriter, r *http.Request, userID string) { + u, 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 + } + + types, err := p.filterCreateBadgeTypes(u) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_types", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + badges, err := p.store.GetRawBadges() + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_badges", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + badgeCountByType := map[badgesmodel.BadgeType]int{} + for _, badge := range badges { + badgeCountByType[badge.Type]++ + } + + result := make([]TypeWithBadgeCount, len(types)) + for i, t := range types { + result[i] = TypeWithBadgeCount{ + BadgeTypeDefinition: t, + BadgeCount: badgeCountByType[t.ID], + } + } + + resp := GetTypesResponse{ + Types: result, + CanCreateType: canCreateType(u, p.badgeAdminUserID, false), + } + + b, _ := json.Marshal(resp) + _, _ = w.Write(b) +} + +func (p *Plugin) apiCreateBadge(w http.ResponseWriter, r *http.Request, userID string) { + var req CreateBadgeRequest + 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 + } + + req.Name = strings.TrimSpace(req.Name) + req.Description = strings.TrimSpace(req.Description) + req.Image = strings.TrimSpace(req.Image) + + if req.Name == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_name", Message: "Name is required", StatusCode: http.StatusBadRequest, + }) + return + } + + if length := len(req.Image); length > 1 && req.Image[0] == ':' && req.Image[length-1] == ':' { + req.Image = req.Image[1 : length-1] + } + if req.Image == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_image", Message: "Emoji is required", StatusCode: http.StatusBadRequest, + }) + return + } + + t, err := p.store.GetType(badgesmodel.BadgeType(req.Type)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "type_not_found", Message: "Badge type not found", StatusCode: http.StatusBadRequest, + }) + return + } + + if !canCreateBadge(user, p.badgeAdminUserID, t) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to create badge of this type", StatusCode: http.StatusForbidden, + }) + return + } + + toCreate := &badgesmodel.Badge{ + Name: req.Name, + Description: req.Description, + Image: req.Image, + ImageType: badgesmodel.ImageTypeEmoji, + Multiple: req.Multiple, + Type: badgesmodel.BadgeType(req.Type), + CreatedBy: userID, + } + + created, err := p.store.AddBadge(toCreate) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_create_badge", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + if req.ChannelID != "" { + T := p.getT(user.Locale) + p.mm.Post.SendEphemeralPost(userID, &model.Post{ + UserId: p.BotUserID, + ChannelId: req.ChannelID, + Message: T("badges.api.badge_created", "Значок `%s` создан.", toCreate.Name), + }) + } + + b, _ := json.Marshal(created) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(b) +} + +func (p *Plugin) apiCreateType(w http.ResponseWriter, r *http.Request, userID string) { + var req CreateTypeRequest + 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 + } + + if !canCreateType(user, p.badgeAdminUserID, false) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to create type", StatusCode: http.StatusForbidden, + }) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_name", Message: "Type name is required", StatusCode: http.StatusBadRequest, + }) + return + } + + toCreate := &badgesmodel.BadgeTypeDefinition{ + Name: req.Name, + CreatedBy: userID, + } + toCreate.CanCreate.Everyone = req.EveryoneCanCreate + toCreate.CanGrant.Everyone = req.EveryoneCanGrant + + created, err := p.store.AddType(toCreate) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_create_type", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + if req.ChannelID != "" { + T := p.getT(user.Locale) + p.mm.Post.SendEphemeralPost(userID, &model.Post{ + UserId: p.BotUserID, + ChannelId: req.ChannelID, + Message: T("badges.api.type_created", "Тип `%s` создан.", toCreate.Name), + }) + } + + b, _ := json.Marshal(created) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(b) +} + +func (p *Plugin) apiUpdateBadge(w http.ResponseWriter, r *http.Request, userID string) { + var req UpdateBadgeRequest + 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 + } + + badge, err := p.store.GetBadge(badgesmodel.BadgeID(req.ID)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "badge_not_found", Message: "Badge not found", StatusCode: http.StatusNotFound, + }) + return + } + + if !canEditBadge(user, p.badgeAdminUserID, badge) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to edit this badge", StatusCode: http.StatusForbidden, + }) + return + } + + req.Name = strings.TrimSpace(req.Name) + req.Description = strings.TrimSpace(req.Description) + req.Image = strings.TrimSpace(req.Image) + + if req.Name == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_name", Message: "Name is required", StatusCode: http.StatusBadRequest, + }) + return + } + + if length := len(req.Image); length > 1 && req.Image[0] == ':' && req.Image[length-1] == ':' { + req.Image = req.Image[1 : length-1] + } + if req.Image == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_image", Message: "Emoji is required", StatusCode: http.StatusBadRequest, + }) + return + } + + badge.Name = req.Name + badge.Description = req.Description + badge.Image = req.Image + badge.Type = badgesmodel.BadgeType(req.Type) + badge.Multiple = req.Multiple + + if err := p.store.UpdateBadge(badge); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_update_badge", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + b, _ := json.Marshal(badge) + _, _ = w.Write(b) +} + +func (p *Plugin) apiDeleteBadge(w http.ResponseWriter, r *http.Request, userID string) { + badgeID, ok := mux.Vars(r)["badgeID"] + if !ok { + p.writeAPIError(w, &APIErrorResponse{ + ID: "missing_badge_id", Message: "Missing badge ID", 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 + } + + badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeID)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "badge_not_found", Message: "Badge not found", StatusCode: http.StatusNotFound, + }) + return + } + + if !canEditBadge(user, p.badgeAdminUserID, badge) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to delete this badge", StatusCode: http.StatusForbidden, + }) + return + } + + if err := p.store.DeleteBadge(badgesmodel.BadgeID(badgeID)); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_delete_badge", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + _, _ = w.Write([]byte(`{"success": true}`)) +} + +func (p *Plugin) apiDeleteType(w http.ResponseWriter, r *http.Request, userID string) { + typeID, ok := mux.Vars(r)["typeID"] + if !ok { + p.writeAPIError(w, &APIErrorResponse{ + ID: "missing_type_id", Message: "Missing type ID", 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 + } + + t, err := p.store.GetType(badgesmodel.BadgeType(typeID)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "type_not_found", Message: "Type not found", StatusCode: http.StatusNotFound, + }) + return + } + + if t.IsDefault { + T := p.getT("ru") + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_delete_default_type", Message: T("badges.api.cannot_delete_default_type", "Нельзя удалить тип по умолчанию"), StatusCode: http.StatusForbidden, + }) + return + } + + if !canEditType(user, p.badgeAdminUserID, t) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to delete this type", StatusCode: http.StatusForbidden, + }) + return + } + + if err := p.store.DeleteType(badgesmodel.BadgeType(typeID)); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_delete_type", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + _, _ = w.Write([]byte(`{"success": true}`)) +} + func (p *Plugin) dialogCreateBadge(w http.ResponseWriter, r *http.Request, userID string) { req := model.SubmitDialogRequestFromJson(r.Body) if req == nil { @@ -333,6 +729,10 @@ func (p *Plugin) dialogEditType(w http.ResponseWriter, r *http.Request, userID s } if getDialogSubmissionBoolField(req, DialogFieldTypeDelete) { + if originalType.IsDefault { + dialogError(w, T("badges.api.cannot_delete_default_type", "Нельзя удалить тип по умолчанию"), nil) + return + } err = p.store.DeleteType(badgesmodel.BadgeType(originalTypeID)) if err != nil { dialogError(w, err.Error(), nil) diff --git a/server/i18n/en.json b/server/i18n/en.json index ca865a3..b556bb7 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -96,6 +96,7 @@ {"id": "badges.api.subscription_added", "translation": "Subscription added"}, {"id": "badges.api.cannot_delete_subscription", "translation": "You cannot delete a subscription"}, {"id": "badges.api.subscription_removed", "translation": "Subscription removed"}, + {"id": "badges.api.cannot_delete_default_type", "translation": "Cannot delete the default type"}, {"id": "badges.api.not_authorized", "translation": "Not authorized"}, {"id": "badges.notify.dm_text", "translation": "@%s granted you the %s`%s` badge."}, diff --git a/server/i18n/ru.json b/server/i18n/ru.json index 0c2e503..f619cfe 100644 --- a/server/i18n/ru.json +++ b/server/i18n/ru.json @@ -96,6 +96,7 @@ {"id": "badges.api.subscription_added", "translation": "Подписка добавлена"}, {"id": "badges.api.cannot_delete_subscription", "translation": "Вы не можете удалить подписку"}, {"id": "badges.api.subscription_removed", "translation": "Подписка удалена"}, + {"id": "badges.api.cannot_delete_default_type", "translation": "Нельзя удалить тип по умолчанию"}, {"id": "badges.api.not_authorized", "translation": "Не авторизован"}, {"id": "badges.notify.dm_text", "translation": "@%s выдал вам значок %s`%s`."}, diff --git a/server/plugin.go b/server/plugin.go index 22d5c44..1768e23 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -57,6 +57,9 @@ func (p *Plugin) OnActivate() error { } p.BotUserID = botID p.store = NewStore(p.API) + if err := p.store.EnsureDefaultType(p.BotUserID); err != nil { + p.mm.Log.Warn("Failed to ensure default type", "error", err.Error()) + } p.i18nBundle = i18n.Init() p.initializeAPI() diff --git a/server/store.go b/server/store.go index 5a9be48..4f8f782 100644 --- a/server/store.go +++ b/server/store.go @@ -39,6 +39,9 @@ type Store interface { GetTypeSubscriptions(tID badgesmodel.BadgeType) ([]string, error) GetChannelSubscriptions(cID string) ([]*badgesmodel.BadgeTypeDefinition, error) + // Default type + EnsureDefaultType(botID string) error + // PAPI EnsureBadges(badges []*badgesmodel.Badge, pluginID, botID string) ([]*badgesmodel.Badge, error) } @@ -144,6 +147,28 @@ func (s *store) addType(t *badgesmodel.BadgeTypeDefinition, isPlugin bool) (*bad return t, nil } +func (s *store) EnsureDefaultType(botID string) error { + types, _, err := s.getAllTypes() + if err != nil { + return err + } + + for _, t := range types { + if t.IsDefault { + return nil + } + } + + _, err = s.addType(&badgesmodel.BadgeTypeDefinition{ + Name: badgesmodel.DefaultTypeName, + IsDefault: true, + CreatedBy: botID, + CanCreate: badgesmodel.PermissionScheme{Everyone: true}, + CanGrant: badgesmodel.PermissionScheme{Everyone: true}, + }, false) + return err +} + func (s *store) GetAllBadges() ([]*badgesmodel.AllBadgesBadge, error) { badges, _, err := s.getAllBadges() if err != nil { @@ -417,6 +442,11 @@ func (s *store) atomicDeleteType(tID badgesmodel.BadgeType) (bool, error) { } func (s *store) DeleteType(tID badgesmodel.BadgeType) error { + t, err := s.GetType(tID) + if err == nil && t.IsDefault { + return errors.New("cannot delete default type") + } + s.doAtomic(func() (bool, error) { return s.atomicDeleteType(tID) }) bb, _, err := s.getAllBadges() diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index e3ebef5..d32ecb3 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -697,7 +697,8 @@ { "extensions": [".jsx", ".tsx"] } - ] + ], + "react/prop-types": 0 } } ] diff --git a/webapp/.yarn/install-state.gz b/webapp/.yarn/install-state.gz index ad7a468..6fc7a48 100644 Binary files a/webapp/.yarn/install-state.gz and b/webapp/.yarn/install-state.gz differ diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 8c898c5..07cd448 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1,6 +1,8 @@ { "badges.loading": "Loading...", "badges.no_badges_yet": "No badges yet.", + "badges.empty.title": "No badges yet", + "badges.empty.description": "Create your first badge to recognize achievements and contributions of your team members.", "badges.badge_not_found": "Badge not found.", "badges.user_not_found": "User not found.", "badges.unknown": "unknown", @@ -10,6 +12,8 @@ "badges.rhs.user_badges": "@{username}'s badges", "badges.rhs.badge_details": "Badge Details", + "badges.label.name": "Name:", + "badges.label.description": "Description:", "badges.label.type": "Type: {typeName}", "badges.label.created_by": "Created by: {username}", "badges.label.granted_by": "Granted by: {username}", @@ -20,6 +24,7 @@ "badges.granted.multiple": "Granted {times, plural, one {# time} other {# times}} to {users, plural, one {# user} other {# users}}.", "badges.granted.single": "Granted to {users, plural, one {# user} other {# users}}.", "badges.granted_to": "Granted to:", + "badges.not_granted_yet": "Not granted to anyone yet", "badges.set_status": "Set status to this badge", "badges.grant_badge": "Grant badge", @@ -36,5 +41,56 @@ "badges.admin.label": "Achievements Admin:", "badges.admin.placeholder": "username", - "badges.admin.help_text": "This user will be considered the achievements plugin administrator. They can create types, as well as modify and grant any badges." + "badges.admin.help_text": "This user will be considered the achievements plugin administrator. They can create types, as well as modify and grant any badges.", + + "badges.rhs.create_badge": "+ Create badge", + "badges.rhs.edit_badge": "Edit", + + "badges.modal.create_badge_title": "Create Badge", + "badges.modal.edit_badge_title": "Edit Badge", + "badges.modal.field_name": "Name", + "badges.modal.field_name_placeholder": "Badge name (max 20 chars)", + "badges.modal.field_description": "Description", + "badges.modal.field_description_placeholder": "Badge description (max 120 chars)", + "badges.modal.field_image": "Emoji", + "badges.modal.field_image_placeholder": "Emoji name (e.g. star)", + "badges.modal.field_type": "Type", + "badges.modal.field_type_placeholder": "Select badge type", + "badges.modal.field_multiple": "Can be granted multiple times", + "badges.modal.create_new_type": "+ Create new type", + "badges.modal.new_type_name": "Type name", + "badges.modal.new_type_name_placeholder": "Type name (max 20 chars)", + "badges.modal.new_type_everyone_create": "Everyone can create badges", + "badges.modal.new_type_everyone_grant": "Everyone can grant badges", + "badges.modal.btn_cancel": "Cancel", + "badges.modal.btn_create": "Create", + "badges.modal.btn_save": "Save", + "badges.modal.btn_creating": "Saving...", + "badges.modal.btn_delete": "Delete badge", + "badges.modal.btn_confirm_delete": "Yes, delete", + "badges.modal.confirm_delete": "Are you sure?", + "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.delete_type": "Delete type", + "badges.modal.confirm_delete_type": "Delete type \"{name}\"?", + "badges.modal.btn_confirm_delete_type": "Yes, delete", + + "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", + "badges.error.cannot_get_badges": "Failed to load badges", + "badges.error.invalid_request": "Invalid request format", + "badges.error.invalid_name": "Name is required", + "badges.error.invalid_image": "Emoji is required", + "badges.error.type_not_found": "Badge type not found", + "badges.error.badge_not_found": "Badge not found", + "badges.error.no_permission": "Insufficient permissions", + "badges.error.missing_badge_id": "Badge ID is missing", + "badges.error.missing_type_id": "Type ID is missing", + "badges.error.cannot_create_badge": "Failed to create badge", + "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_delete_type": "Failed to delete type" } diff --git a/webapp/i18n/ru.json b/webapp/i18n/ru.json index a630386..d593c51 100644 --- a/webapp/i18n/ru.json +++ b/webapp/i18n/ru.json @@ -1,6 +1,8 @@ { "badges.loading": "Загрузка...", "badges.no_badges_yet": "Значков пока нет.", + "badges.empty.title": "Значков пока нет", + "badges.empty.description": "Создайте первый значок, чтобы отмечать достижения и заслуги участников команды.", "badges.badge_not_found": "Значок не найден.", "badges.user_not_found": "Пользователь не найден.", "badges.unknown": "неизвестно", @@ -10,6 +12,8 @@ "badges.rhs.user_badges": "Значки @{username}", "badges.rhs.badge_details": "Детали значка", + "badges.label.name": "Название:", + "badges.label.description": "Описание:", "badges.label.type": "Тип: {typeName}", "badges.label.created_by": "Создал: {username}", "badges.label.granted_by": "Выдал: {username}", @@ -20,6 +24,7 @@ "badges.granted.multiple": "Выдан {times, plural, one {# раз} few {# раза} many {# раз} other {# раз}} {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.", "badges.granted.single": "Выдан {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.", "badges.granted_to": "Выдан:", + "badges.not_granted_yet": "Ещё никому не выдан", "badges.set_status": "Установить как статус", "badges.grant_badge": "Выдать значок", @@ -36,5 +41,56 @@ "badges.admin.label": "Администратор достижений:", "badges.admin.placeholder": "имя пользователя", - "badges.admin.help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки." + "badges.admin.help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки.", + + "badges.rhs.create_badge": "+ Создать значок", + "badges.rhs.edit_badge": "Редактировать", + + "badges.modal.create_badge_title": "Создать значок", + "badges.modal.edit_badge_title": "Редактировать значок", + "badges.modal.field_name": "Название", + "badges.modal.field_name_placeholder": "Название значка (макс. 20 символов)", + "badges.modal.field_description": "Описание", + "badges.modal.field_description_placeholder": "Описание значка (макс. 120 символов)", + "badges.modal.field_image": "Эмодзи", + "badges.modal.field_image_placeholder": "Название эмодзи (напр. star)", + "badges.modal.field_type": "Тип", + "badges.modal.field_type_placeholder": "Выберите тип значка", + "badges.modal.field_multiple": "Можно выдавать несколько раз", + "badges.modal.create_new_type": "+ Создать новый тип", + "badges.modal.new_type_name": "Название типа", + "badges.modal.new_type_name_placeholder": "Название типа (макс. 20 символов)", + "badges.modal.new_type_everyone_create": "Все могут создавать значки", + "badges.modal.new_type_everyone_grant": "Все могут выдавать значки", + "badges.modal.btn_cancel": "Отмена", + "badges.modal.btn_create": "Создать", + "badges.modal.btn_save": "Сохранить", + "badges.modal.btn_creating": "Сохранение...", + "badges.modal.btn_delete": "Удалить значок", + "badges.modal.btn_confirm_delete": "Да, удалить", + "badges.modal.confirm_delete": "Вы уверены?", + "badges.modal.error_generic": "Произошла ошибка", + "badges.modal.error_type_name_required": "Введите название типа", + "badges.modal.error_type_required": "Выберите тип значка", + "badges.modal.delete_type": "Удалить тип", + "badges.modal.confirm_delete_type": "Удалить тип «{name}»?", + "badges.modal.btn_confirm_delete_type": "Да, удалить", + + "badges.error.unknown": "Произошла ошибка", + "badges.error.cannot_get_user": "Не удалось получить данные пользователя", + "badges.error.cannot_get_types": "Не удалось загрузить типы", + "badges.error.cannot_get_badges": "Не удалось загрузить значки", + "badges.error.invalid_request": "Неверный формат запроса", + "badges.error.invalid_name": "Необходимо указать название", + "badges.error.invalid_image": "Необходимо указать эмодзи", + "badges.error.type_not_found": "Тип значка не найден", + "badges.error.badge_not_found": "Значок не найден", + "badges.error.no_permission": "Недостаточно прав для выполнения действия", + "badges.error.missing_badge_id": "Не указан ID значка", + "badges.error.missing_type_id": "Не указан ID типа", + "badges.error.cannot_create_badge": "Не удалось создать значок", + "badges.error.cannot_create_type": "Не удалось создать тип", + "badges.error.cannot_update_badge": "Не удалось обновить значок", + "badges.error.cannot_delete_badge": "Не удалось удалить значок", + "badges.error.cannot_delete_type": "Не удалось удалить тип" } diff --git a/webapp/package.json b/webapp/package.json index 621a88e..b58def7 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -57,7 +57,6 @@ "jest": "26.6.3", "jest-canvas-mock": "2.3.1", "jest-junit": "12.0.0", - "loop-plugin-sdk": "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz", "react-intl": "6.8.9", "sass": "1.86.0", "sass-loader": "11.0.1", diff --git a/webapp/src/action_types/index.ts b/webapp/src/action_types/index.ts index 4d8660b..88a672c 100644 --- a/webapp/src/action_types/index.ts +++ b/webapp/src/action_types/index.ts @@ -8,4 +8,8 @@ export default { RECEIVED_RHS_VIEW: pluginId + '_received_rhs_view', RECEIVED_RHS_USER: pluginId + '_received_rhs_user', RECEIVED_RHS_BADGE: pluginId + '_received_rhs_badge', + 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', }; diff --git a/webapp/src/actions/actions.ts b/webapp/src/actions/actions.ts index 068cdf0..ae44103 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 {BadgeID} from 'types/badges'; +import {BadgeDetails, BadgeID} from 'types/badges'; import {RHSState} from 'types/general'; /** @@ -76,14 +76,28 @@ export function openCreateType() { } export function openCreateBadge() { - return (dispatch: Dispatch, getState: GetStateFunc) => { - const command = '/badges create badge'; - clientExecuteCommand(dispatch, getState, command); - + return (dispatch: Dispatch) => { + dispatch(openCreateBadgeModal()); return {data: true}; }; } +export function openCreateBadgeModal() { + return {type: ActionTypes.OPEN_CREATE_BADGE_MODAL}; +} + +export function closeCreateBadgeModal() { + return {type: ActionTypes.CLOSE_CREATE_BADGE_MODAL}; +} + +export function openEditBadgeModal(badge: BadgeDetails) { + return {type: ActionTypes.OPEN_EDIT_BADGE_MODAL, data: badge}; +} + +export function closeEditBadgeModal() { + return {type: ActionTypes.CLOSE_EDIT_BADGE_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 a5e275e..8769498 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, BadgeDetails, BadgeID, UserBadge} from 'types/badges'; +import {AllBadgesBadge, Badge, BadgeDetails, BadgeID, BadgeTypeDefinition, CreateBadgeRequest, CreateTypeRequest, GetTypesResponse, UpdateBadgeRequest, UserBadge} from 'types/badges'; export default class Client { private url: string; @@ -41,6 +41,35 @@ export default class Client { } } + async getTypes(): Promise { + try { + const res = await this.doGet(`${this.url}/getTypes`); + return res as GetTypesResponse; + } catch { + return {types: [], can_create_type: false}; + } + } + + async createBadge(req: CreateBadgeRequest): Promise { + return await this.doPost(`${this.url}/createBadge`, req) as Badge; + } + + async createType(req: CreateTypeRequest): Promise { + return await this.doPost(`${this.url}/createType`, req) as BadgeTypeDefinition; + } + + async updateBadge(req: UpdateBadgeRequest): Promise { + return await this.doPut(`${this.url}/updateBadge`, req) as Badge; + } + + async deleteBadge(badgeID: BadgeID): Promise { + await this.doDelete(`${this.url}/deleteBadge/${badgeID}`); + } + + async deleteType(typeID: string): Promise { + await this.doDelete(`${this.url}/deleteType/${typeID}`); + } + private doGet = async (url: string, headers: {[x:string]: string} = {}) => { headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset()); @@ -63,4 +92,75 @@ export default class Client { url, }); } + + private doPost = async (url: string, body: any, headers: {[x:string]: string} = {}) => { + headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset()); + + const options = { + method: 'post', + body: JSON.stringify(body), + headers, + }; + + const response = await fetch(url, Client4.getOptions(options)); + + if (response.ok) { + return response.json(); + } + + const text = await response.text(); + + throw new ClientError(Client4.url, { + message: text || '', + status_code: response.status, + url, + }); + } + + private doPut = async (url: string, body: any, headers: {[x:string]: string} = {}) => { + headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset()); + + const options = { + method: 'put', + body: JSON.stringify(body), + headers, + }; + + const response = await fetch(url, Client4.getOptions(options)); + + if (response.ok) { + return response.json(); + } + + const text = await response.text(); + + throw new ClientError(Client4.url, { + message: text || '', + status_code: response.status, + url, + }); + } + + private doDelete = async (url: string, headers: {[x:string]: string} = {}) => { + headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset()); + + const options = { + method: 'delete', + headers, + }; + + const response = await fetch(url, Client4.getOptions(options)); + + if (response.ok) { + return response.json(); + } + + const text = await response.text(); + + throw new ClientError(Client4.url, { + message: text || '', + status_code: response.status, + url, + }); + } } diff --git a/webapp/src/components/badge_modal/badge_modal.scss b/webapp/src/components/badge_modal/badge_modal.scss new file mode 100644 index 0000000..4aed59d --- /dev/null +++ b/webapp/src/components/badge_modal/badge_modal.scss @@ -0,0 +1,297 @@ +.BadgeModal { + &__backdrop { + position: fixed; + top: 0; + left: 0; + 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; + background: var(--center-channel-bg, #fff); + color: var(--center-channel-color, #3d3c40); + border-radius: 8px; + box-shadow: 0 20px 32px rgba(0, 0, 0, 0.12); + width: 480px; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px 0; + + h4 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + + .close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: var(--center-channel-color, #3d3c40); + opacity: 0.56; + padding: 0; + line-height: 1; + + &:hover { + opacity: 1; + } + } + } + + &__body { + padding: 20px 24px; + overflow-y: auto; + flex: 1; + } + + &__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px; + border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + } + + .form-group { + margin-bottom: 16px; + + label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; + text-transform: uppercase; + opacity: 0.64; + } + + input[type='text'], + select, + textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-radius: 4px; + font-size: 14px; + background: var(--center-channel-bg, #fff); + color: var(--center-channel-color, #3d3c40); + + &:focus { + border-color: var(--button-bg, #166de0); + outline: none; + } + } + + textarea { + resize: vertical; + min-height: 60px; + } + } + + .checkbox-group { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + + label { + font-size: 14px; + font-weight: normal; + margin: 0; + text-transform: none; + opacity: 1; + cursor: pointer; + } + } + + .inline-type-section { + padding: 12px; + margin-top: 8px; + border: 1px dashed rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.24); + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.04); + } + + .btn { + padding: 8px 20px; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: none; + + &--primary { + background: var(--button-bg, #166de0); + color: var(--button-color, #fff); + + &:hover { + opacity: 0.88; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + &--cancel { + background: transparent; + color: var(--center-channel-color, #3d3c40); + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + + &:hover { + background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + } + } + + &--danger { + background: var(--error-text, #d24b4e); + color: #fff; + + &:hover { + opacity: 0.88; + } + } + } + + .error-message { + color: var(--error-text, #d24b4e); + font-size: 13px; + margin-top: 8px; + } + + .delete-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + display: flex; + justify-content: space-between; + align-items: center; + } + + .confirm-delete { + display: flex; + align-items: center; + gap: 8px; + + span { + font-size: 13px; + color: var(--error-text, #d24b4e); + } + } + + .type-select { + position: relative; + + &__trigger { + width: 100%; + padding: 8px 12px; + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-radius: 4px; + font-size: 14px; + background: var(--center-channel-bg, #fff); + color: var(--center-channel-color, #3d3c40); + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + text-align: left; + + &:hover { + border-color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.32); + } + } + + &__value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__arrow { + font-size: 12px; + opacity: 0.56; + margin-left: 8px; + flex-shrink: 0; + } + + &__dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: var(--center-channel-bg, #fff); + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: 10; + max-height: 200px; + overflow-y: auto; + } + + &__option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + font-size: 14px; + + &:hover { + background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + } + + &--selected { + background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08); + font-weight: 600; + } + + &--create { + border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + color: var(--button-bg, #166de0); + font-weight: 600; + } + } + + &__option-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__delete-btn { + background: none; + border: none; + color: var(--center-channel-color, #3d3c40); + opacity: 0.4; + cursor: pointer; + padding: 0 4px; + font-size: 12px; + line-height: 1; + flex-shrink: 0; + + &:hover { + opacity: 1; + color: var(--error-text, #d24b4e); + } + } + + } +} diff --git a/webapp/src/components/badge_modal/index.tsx b/webapp/src/components/badge_modal/index.tsx new file mode 100644 index 0000000..bc7115b --- /dev/null +++ b/webapp/src/components/badge_modal/index.tsx @@ -0,0 +1,416 @@ +import React, {useCallback, useEffect, useState} from 'react'; + +import {useDispatch, useSelector} from 'react-redux'; +import {FormattedMessage, useIntl} from 'react-intl'; + +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 Client from 'client/api'; +import {getServerErrorId} from 'utils/helpers'; + +import TypeSelect from './type_select'; + +import './badge_modal.scss'; + +const NEW_TYPE_VALUE = '__new__'; + +const BadgeModal: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + const createVisible = useSelector(isCreateBadgeModalVisible); + const editData = useSelector(getEditBadgeModalData); + 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 [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); + const [confirmDelete, setConfirmDelete] = useState(false); + const [confirmDeleteTypeId, setConfirmDeleteTypeId] = useState(null); + const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); + + useEffect(() => { + if (!isOpen) { + return; + } + const fetchTypes = async () => { + const client = new Client(); + const resp = await client.getTypes(); + setTypes(resp.types); + 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)); + } + }; + fetchTypes(); + + if (isEditMode && editData) { + setName(editData.name); + setDescription(editData.description); + setImage(editData.image); + setBadgeType(String(editData.type)); + setMultiple(editData.multiple); + } else { + setName(''); + setDescription(''); + setImage(''); + setBadgeType(''); + setMultiple(false); + } + setShowCreateType(false); + setNewTypeName(''); + setNewTypeEveryoneCanCreate(false); + setNewTypeEveryoneCanGrant(false); + setError(null); + setConfirmDelete(false); + setConfirmDeleteTypeId(null); + setTypeDropdownOpen(false); + setLoading(false); + }, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleClose = useCallback(() => { + if (createVisible) { + dispatch(closeCreateBadgeModal()); + } + if (editData) { + dispatch(closeEditBadgeModal()); + } + }, [dispatch, createVisible, editData]); + + const handleTypeSelect = useCallback((val: string) => { + if (val === NEW_TYPE_VALUE) { + setShowCreateType(true); + setBadgeType(''); + } else { + setShowCreateType(false); + setBadgeType(val); + } + setTypeDropdownOpen(false); + setConfirmDeleteTypeId(null); + }, []); + + const handleDeleteType = useCallback(async (typeId: string) => { + if (confirmDeleteTypeId !== typeId) { + setConfirmDeleteTypeId(typeId); + return; + } + try { + const client = new Client(); + await client.deleteType(typeId); + const removeById = (t: BadgeTypeDefinition) => String(t.id) !== typeId; + setTypes((prev) => prev.filter(removeById)); + if (badgeType === typeId) { + setBadgeType(''); + } + } catch (err) { + setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'})); + } + setConfirmDeleteTypeId(null); + }, [confirmDeleteTypeId, badgeType, intl]); + + const handleSubmit = useCallback(async () => { + setLoading(true); + setError(null); + try { + const client = new Client(); + let typeID = badgeType; + if (showCreateType) { + if (!newTypeName.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, + channel_id: channelId, + }); + typeID = String(createdType.id); + } + if (!typeID) { + setError(intl.formatMessage({id: 'badges.modal.error_type_required', defaultMessage: 'Выберите тип значка'})); + setLoading(false); + return; + } + if (isEditMode && editData) { + await client.updateBadge({ + id: String(editData.id), + name: name.trim(), + description: description.trim(), + image: image.trim(), + type: typeID, + multiple, + }); + } else { + await client.createBadge({ + name: name.trim(), + description: description.trim(), + image: image.trim(), + type: typeID, + multiple, + channel_id: channelId, + }); + } + handleClose(); + } 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]); + + const handleDelete = useCallback(async () => { + if (!editData) { + return; + } + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + setLoading(true); + setError(null); + try { + const client = new Client(); + await client.deleteBadge(editData.id); + handleClose(); + } catch (err) { + setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'})); + } finally { + setLoading(false); + } + }, [editData, confirmDelete, handleClose, intl]); + + 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: 'Создать'}); + + return ( +
+
+
+
+

{title}

+ +
+
+
+ + setName(e.target.value)} + maxLength={20} + placeholder={intl.formatMessage({id: 'badges.modal.field_name_placeholder', defaultMessage: 'Название значка (макс. 20 символов)'})} + /> +
+
+ +