LP-5613 #2
@ -3,6 +3,7 @@ package badgesmodel
|
||||
const (
|
||||
NameMaxLength = 20
|
||||
DescriptionMaxLength = 120
|
||||
DefaultTypeName = "Общий"
|
||||
|
||||
ImageTypeEmoji ImageType = "emoji"
|
||||
ImageTypeRelativeURL ImageType = "rel_url"
|
||||
|
||||
@ -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 != ""
|
||||
}
|
||||
|
||||
|
||||
885
server/api.go
@ -271,7 +271,12 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canEditBadge(u, p.badgeAdminUserID, badge) {
|
||||
badgeType, err := p.store.GetType(badge.Type)
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canEditBadge(u, p.badgeAdminUserIDs, badge, badgeType) {
|
||||
return commandError(T("badges.error.cannot_edit_badge", "У вас нет прав на редактирование этого значка"))
|
||||
}
|
||||
|
||||
@ -359,7 +364,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
|
||||
}
|
||||
T := p.getT(u.Locale)
|
||||
|
||||
if !canCreateType(u, p.badgeAdminUserID, false) {
|
||||
if !canCreateType(u, p.badgeAdminUserIDs, false) {
|
||||
return commandError(T("badges.error.no_permissions_edit_type", "У вас нет прав на редактирование типа значков."))
|
||||
}
|
||||
|
||||
@ -379,7 +384,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canEditType(u, p.badgeAdminUserID, typeDefinition) {
|
||||
if !canEditType(u, p.badgeAdminUserIDs, typeDefinition) {
|
||||
return commandError(T("badges.error.cannot_edit_type", "У вас нет прав на редактирование этого типа"))
|
||||
}
|
||||
|
||||
@ -493,7 +498,7 @@ func (p *Plugin) runCreateType(args []string, extra *model.CommandArgs) (bool, *
|
||||
}
|
||||
T := p.getT(u.Locale)
|
||||
|
||||
if !canCreateType(u, p.badgeAdminUserID, false) {
|
||||
if !canCreateType(u, p.badgeAdminUserIDs, false) {
|
||||
return commandError(T("badges.error.no_permissions_create_type", "У вас нет прав на создание типа значков."))
|
||||
}
|
||||
|
||||
@ -582,7 +587,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) {
|
||||
if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) {
|
||||
return commandError(T("badges.error.no_permissions_grant", "У вас нет прав на выдачу этого значка"))
|
||||
}
|
||||
|
||||
@ -592,6 +597,9 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
}
|
||||
|
||||
shouldNotify, err := p.store.GrantBadge(badgesmodel.BadgeID(badgeStr), user.Id, extra.UserId, "")
|
||||
if err == errAlreadyOwned {
|
||||
return commandError(T("badges.error.already_owned", "Это достижение уже выдано этому пользователю"))
|
||||
}
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
@ -755,7 +763,7 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs)
|
||||
}
|
||||
T := p.getT(actingUser.Locale)
|
||||
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
|
||||
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
|
||||
}
|
||||
|
||||
@ -818,7 +826,7 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs)
|
||||
}
|
||||
T := p.getT(actingUser.Locale)
|
||||
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
|
||||
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -78,13 +79,20 @@ func (p *Plugin) OnConfigurationChange() error {
|
||||
return errors.Wrap(err, "failed to load plugin configuration")
|
||||
}
|
||||
|
||||
p.badgeAdminUserID = ""
|
||||
p.badgeAdminUserIDs = make(map[string]bool)
|
||||
if configuration.BadgesAdmin != "" {
|
||||
u, err := p.API.GetUserByUsername(configuration.BadgesAdmin)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot get badge admin user")
|
||||
for username := range strings.SplitSeq(configuration.BadgesAdmin, ",") {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
u, err := p.API.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
p.API.LogWarn("Cannot find badge admin user", "username", username, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
p.badgeAdminUserIDs[u.Id] = true
|
||||
}
|
||||
p.badgeAdminUserID = u.Id
|
||||
}
|
||||
|
||||
p.setConfiguration(configuration)
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
[
|
||||
{"id": "badges.dialog.create_badge.title", "translation": "Create badge"},
|
||||
{"id": "badges.dialog.create_badge.title", "translation": "Create achievement"},
|
||||
{"id": "badges.dialog.create_badge.submit", "translation": "Create"},
|
||||
{"id": "badges.dialog.edit_badge.title", "translation": "Edit badge"},
|
||||
{"id": "badges.dialog.edit_badge.title", "translation": "Edit achievement"},
|
||||
{"id": "badges.dialog.edit_badge.submit", "translation": "Save"},
|
||||
{"id": "badges.dialog.create_type.title", "translation": "Create type"},
|
||||
{"id": "badges.dialog.create_type.submit", "translation": "Create"},
|
||||
{"id": "badges.dialog.edit_type.title", "translation": "Edit type"},
|
||||
{"id": "badges.dialog.edit_type.submit", "translation": "Save"},
|
||||
{"id": "badges.dialog.grant.title", "translation": "Grant badge"},
|
||||
{"id": "badges.dialog.grant.title", "translation": "Grant achievement"},
|
||||
{"id": "badges.dialog.grant.submit", "translation": "Grant"},
|
||||
{"id": "badges.dialog.grant.intro", "translation": "Grant badge to @%s"},
|
||||
{"id": "badges.dialog.grant.intro", "translation": "Grant achievement to @%s"},
|
||||
{"id": "badges.dialog.create_subscription.title", "translation": "Create subscription"},
|
||||
{"id": "badges.dialog.create_subscription.submit", "translation": "Add"},
|
||||
{"id": "badges.dialog.create_subscription.intro", "translation": "Select the badge type you want to subscribe to this channel."},
|
||||
{"id": "badges.dialog.create_subscription.intro", "translation": "Select the achievement type you want to subscribe to this channel."},
|
||||
{"id": "badges.dialog.delete_subscription.title", "translation": "Delete subscription"},
|
||||
{"id": "badges.dialog.delete_subscription.submit", "translation": "Remove"},
|
||||
{"id": "badges.dialog.delete_subscription.intro", "translation": "Select the badge type you want to unsubscribe from this channel."},
|
||||
{"id": "badges.dialog.delete_subscription.intro", "translation": "Select the achievement type you want to unsubscribe from this channel."},
|
||||
|
||||
{"id": "badges.field.name", "translation": "Name"},
|
||||
{"id": "badges.field.description", "translation": "Description"},
|
||||
@ -23,45 +23,46 @@
|
||||
{"id": "badges.field.image.help", "translation": "Enter an emoticon name"},
|
||||
{"id": "badges.field.type", "translation": "Type"},
|
||||
{"id": "badges.field.multiple", "translation": "Multiple"},
|
||||
{"id": "badges.field.multiple.help", "translation": "Whether the badge can be granted multiple times"},
|
||||
{"id": "badges.field.delete_badge", "translation": "Delete badge"},
|
||||
{"id": "badges.field.delete_badge.help", "translation": "WARNING: checking this will remove this badge permanently."},
|
||||
{"id": "badges.field.everyone_can_create", "translation": "Everyone can create badge"},
|
||||
{"id": "badges.field.everyone_can_create.help", "translation": "Whether any user can create a badge of this type"},
|
||||
{"id": "badges.field.multiple.help", "translation": "Whether the achievement can be granted multiple times"},
|
||||
{"id": "badges.field.delete_badge", "translation": "Delete achievement"},
|
||||
{"id": "badges.field.delete_badge.help", "translation": "WARNING: checking this will remove this achievement permanently."},
|
||||
{"id": "badges.field.everyone_can_create", "translation": "Everyone can create achievement"},
|
||||
{"id": "badges.field.everyone_can_create.help", "translation": "Whether any user can create an achievement of this type"},
|
||||
{"id": "badges.field.allowlist_create", "translation": "Can create allowlist"},
|
||||
{"id": "badges.field.allowlist_create.help", "translation": "Fill the usernames separated by comma (,) of the people that can create badges of this type."},
|
||||
{"id": "badges.field.everyone_can_grant", "translation": "Everyone can grant badge"},
|
||||
{"id": "badges.field.everyone_can_grant.help", "translation": "Whether any user can grant a badge of this type"},
|
||||
{"id": "badges.field.allowlist_create.help", "translation": "Fill the usernames separated by comma (,) of the people that can create achievements of this type."},
|
||||
{"id": "badges.field.everyone_can_grant", "translation": "Everyone can grant achievement"},
|
||||
{"id": "badges.field.everyone_can_grant.help", "translation": "Whether any user can grant an achievement of this type"},
|
||||
{"id": "badges.field.allowlist_grant", "translation": "Can grant allowlist"},
|
||||
{"id": "badges.field.allowlist_grant.help", "translation": "Fill the usernames separated by comma (,) of the people that can grant badges of this type."},
|
||||
{"id": "badges.field.allowlist_grant.help", "translation": "Fill the usernames separated by comma (,) of the people that can grant achievements of this type."},
|
||||
{"id": "badges.field.delete_type", "translation": "Remove type"},
|
||||
{"id": "badges.field.delete_type.help", "translation": "WARNING: checking this will remove this type and all associated badges permanently."},
|
||||
{"id": "badges.field.delete_type.help", "translation": "WARNING: checking this will remove this type and all associated achievements permanently."},
|
||||
{"id": "badges.field.user", "translation": "User"},
|
||||
{"id": "badges.field.badge", "translation": "Badge"},
|
||||
{"id": "badges.field.badge", "translation": "Achievement"},
|
||||
{"id": "badges.field.reason", "translation": "Reason"},
|
||||
{"id": "badges.field.reason.help", "translation": "Reason why you are granting this badge. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions)."},
|
||||
{"id": "badges.field.reason.help", "translation": "Reason why you are granting this achievement. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions)."},
|
||||
{"id": "badges.field.notify_here", "translation": "Notify on this channel"},
|
||||
{"id": "badges.field.notify_here.help", "translation": "If you mark this, the bot will send a message to this channel notifying that you granted this badge to this person."},
|
||||
{"id": "badges.field.notify_here.help", "translation": "If you mark this, the bot will send a message to this channel notifying that you granted this achievement to this person."},
|
||||
|
||||
{"id": "badges.error.unknown", "translation": "An unknown error occurred. Please talk to your system administrator for help."},
|
||||
{"id": "badges.error.cannot_get_user", "translation": "Cannot get user."},
|
||||
{"id": "badges.error.only_sysadmin_clean", "translation": "Only a system admin can clean the badges database."},
|
||||
{"id": "badges.error.only_sysadmin_clean", "translation": "Only a system admin can clean the achievements database."},
|
||||
{"id": "badges.error.specify_create", "translation": "Specify what you want to create."},
|
||||
{"id": "badges.error.create_badge_or_type", "translation": "You can create either badge or type"},
|
||||
{"id": "badges.error.no_types_available", "translation": "You cannot create badges from any type."},
|
||||
{"id": "badges.error.must_set_badge_id", "translation": "You must set the badge ID"},
|
||||
{"id": "badges.error.cannot_edit_badge", "translation": "You cannot edit this badge"},
|
||||
{"id": "badges.error.create_badge_or_type", "translation": "You can create either achievement or type"},
|
||||
{"id": "badges.error.no_types_available", "translation": "You cannot create achievements from any type."},
|
||||
{"id": "badges.error.must_set_badge_id", "translation": "You must set the achievement ID"},
|
||||
{"id": "badges.error.cannot_edit_badge", "translation": "You cannot edit this achievement"},
|
||||
{"id": "badges.error.specify_edit", "translation": "Specify what you want to edit."},
|
||||
{"id": "badges.error.edit_badge_or_type", "translation": "You can edit either badge or type"},
|
||||
{"id": "badges.error.no_permissions_edit_type", "translation": "You have no permissions to edit a badge type."},
|
||||
{"id": "badges.error.edit_badge_or_type", "translation": "You can edit either achievement or type"},
|
||||
{"id": "badges.error.no_permissions_edit_type", "translation": "You have no permissions to edit an achievement type."},
|
||||
{"id": "badges.error.must_provide_type_id", "translation": "You must provide a type id"},
|
||||
{"id": "badges.error.cannot_edit_type", "translation": "You cannot edit this type"},
|
||||
{"id": "badges.error.no_permissions_grant", "translation": "You have no permissions to grant this badge"},
|
||||
{"id": "badges.error.cannot_grant_badge", "translation": "You cannot grant that badge"},
|
||||
{"id": "badges.error.no_permissions_grant", "translation": "You have no permissions to grant this achievement"},
|
||||
{"id": "badges.error.cannot_grant_badge", "translation": "You cannot grant that achievement"},
|
||||
{"id": "badges.error.specify_subscription", "translation": "Specify what you want to do."},
|
||||
{"id": "badges.error.create_or_delete_subscription", "translation": "You can either create or delete subscriptions"},
|
||||
{"id": "badges.error.cannot_create_subscription", "translation": "You cannot create subscriptions"},
|
||||
{"id": "badges.error.no_permissions_create_type", "translation": "You have no permissions to create a badge type."},
|
||||
{"id": "badges.error.no_permissions_create_type", "translation": "You have no permissions to create an achievement type."},
|
||||
{"id": "badges.error.already_owned", "translation": "This achievement is already owned by this user"},
|
||||
|
||||
{"id": "badges.success.clean", "translation": "Clean"},
|
||||
{"id": "badges.success.granted", "translation": "Granted"},
|
||||
@ -72,8 +73,8 @@
|
||||
{"id": "badges.api.empty_emoji", "translation": "Empty emoji"},
|
||||
{"id": "badges.api.invalid_field", "translation": "Invalid field"},
|
||||
{"id": "badges.api.type_not_exist", "translation": "This type does not exist"},
|
||||
{"id": "badges.api.no_permissions_create_badge", "translation": "You have no permissions to create this badge"},
|
||||
{"id": "badges.api.badge_created", "translation": "Badge `%s` created."},
|
||||
{"id": "badges.api.no_permissions_create_badge", "translation": "You have no permissions to create this achievement"},
|
||||
{"id": "badges.api.badge_created", "translation": "Achievement `%s` created."},
|
||||
{"id": "badges.api.no_permissions_create_type", "translation": "You have no permissions to create a type"},
|
||||
{"id": "badges.api.cannot_find_user", "translation": "Cannot find user"},
|
||||
{"id": "badges.api.error_getting_user", "translation": "Error getting user %s: %v"},
|
||||
@ -83,24 +84,25 @@
|
||||
{"id": "badges.api.could_not_get_type", "translation": "Could not get the type"},
|
||||
{"id": "badges.api.no_permissions_edit_type", "translation": "You have no permissions to edit this type"},
|
||||
{"id": "badges.api.type_updated", "translation": "Type `%s` updated."},
|
||||
{"id": "badges.api.cannot_get_badge", "translation": "Cannot get badge"},
|
||||
{"id": "badges.api.cannot_edit_badge", "translation": "You cannot edit this badge"},
|
||||
{"id": "badges.api.could_not_get_badge", "translation": "Could not get the badge"},
|
||||
{"id": "badges.api.no_permissions_edit_badge", "translation": "You have no permissions to edit this badge"},
|
||||
{"id": "badges.api.badge_updated", "translation": "Badge `%s` updated."},
|
||||
{"id": "badges.api.badge_not_found", "translation": "Badge not found"},
|
||||
{"id": "badges.api.no_permissions_grant", "translation": "You have no permissions to grant this badge"},
|
||||
{"id": "badges.api.cannot_get_badge", "translation": "Cannot get achievement"},
|
||||
{"id": "badges.api.cannot_edit_badge", "translation": "You cannot edit this achievement"},
|
||||
{"id": "badges.api.could_not_get_badge", "translation": "Could not get the achievement"},
|
||||
{"id": "badges.api.no_permissions_edit_badge", "translation": "You have no permissions to edit this achievement"},
|
||||
{"id": "badges.api.badge_updated", "translation": "Achievement `%s` updated."},
|
||||
{"id": "badges.api.badge_not_found", "translation": "Achievement not found"},
|
||||
{"id": "badges.api.no_permissions_grant", "translation": "You have no permissions to grant this achievement"},
|
||||
{"id": "badges.api.user_not_found", "translation": "User not found"},
|
||||
{"id": "badges.api.badge_granted", "translation": "Badge `%s` granted to @%s."},
|
||||
{"id": "badges.api.badge_granted", "translation": "Achievement `%s` granted to @%s."},
|
||||
{"id": "badges.api.cannot_create_subscription", "translation": "You cannot create a subscription"},
|
||||
{"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."},
|
||||
{"id": "badges.notify.dm_text", "translation": "@%s granted you the %s`%s` achievement."},
|
||||
{"id": "badges.notify.dm_reason", "translation": "\nWhy? "},
|
||||
{"id": "badges.notify.title", "translation": "%sbadge granted!"},
|
||||
{"id": "badges.notify.channel_text", "translation": "@%s granted @%s the %s`%s` badge."},
|
||||
{"id": "badges.notify.title", "translation": "%sachievement granted!"},
|
||||
{"id": "badges.notify.channel_text", "translation": "@%s granted @%s the %s`%s` achievement."},
|
||||
{"id": "badges.notify.no_permission_channel", "translation": "You don't have permissions to notify the grant on this channel."}
|
||||
]
|
||||
@ -18,6 +18,7 @@ type Bundle i18n.Bundle
|
||||
func Init() *Bundle {
|
||||
bundle := i18n.NewBundle(language.Russian)
|
||||
_, _ = bundle.LoadMessageFileFS(i18nFiles, "en.json")
|
||||
_, _ = bundle.LoadMessageFileFS(i18nFiles, "ru.json")
|
||||
|
||||
return (*Bundle)(bundle)
|
||||
}
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
[
|
||||
{"id": "badges.dialog.create_badge.title", "translation": "Создать значок"},
|
||||
{"id": "badges.dialog.create_badge.title", "translation": "Создать достижение"},
|
||||
{"id": "badges.dialog.create_badge.submit", "translation": "Создать"},
|
||||
{"id": "badges.dialog.edit_badge.title", "translation": "Редактировать значок"},
|
||||
{"id": "badges.dialog.edit_badge.title", "translation": "Редактировать достижение"},
|
||||
{"id": "badges.dialog.edit_badge.submit", "translation": "Сохранить"},
|
||||
{"id": "badges.dialog.create_type.title", "translation": "Создать тип"},
|
||||
{"id": "badges.dialog.create_type.submit", "translation": "Создать"},
|
||||
{"id": "badges.dialog.edit_type.title", "translation": "Редактировать тип"},
|
||||
{"id": "badges.dialog.edit_type.submit", "translation": "Сохранить"},
|
||||
{"id": "badges.dialog.grant.title", "translation": "Выдать значок"},
|
||||
{"id": "badges.dialog.grant.title", "translation": "Выдать достижение"},
|
||||
{"id": "badges.dialog.grant.submit", "translation": "Выдать"},
|
||||
{"id": "badges.dialog.grant.intro", "translation": "Выдать значок пользователю @%s"},
|
||||
{"id": "badges.dialog.grant.intro", "translation": "Выдать достижение пользователю @%s"},
|
||||
{"id": "badges.dialog.create_subscription.title", "translation": "Создать подписку"},
|
||||
{"id": "badges.dialog.create_subscription.submit", "translation": "Добавить"},
|
||||
{"id": "badges.dialog.create_subscription.intro", "translation": "Выберите тип значка, на который хотите подписать этот канал."},
|
||||
{"id": "badges.dialog.create_subscription.intro", "translation": "Выберите тип достижения, на который хотите подписать этот канал."},
|
||||
{"id": "badges.dialog.delete_subscription.title", "translation": "Удалить подписку"},
|
||||
{"id": "badges.dialog.delete_subscription.submit", "translation": "Удалить"},
|
||||
{"id": "badges.dialog.delete_subscription.intro", "translation": "Выберите тип значка, подписку на который хотите удалить из этого канала."},
|
||||
{"id": "badges.dialog.delete_subscription.intro", "translation": "Выберите тип достижения, подписку на который хотите удалить из этого канала."},
|
||||
|
||||
{"id": "badges.field.name", "translation": "Название"},
|
||||
{"id": "badges.field.description", "translation": "Описание"},
|
||||
@ -23,45 +23,46 @@
|
||||
{"id": "badges.field.image.help", "translation": "Введите название эмодзи"},
|
||||
{"id": "badges.field.type", "translation": "Тип"},
|
||||
{"id": "badges.field.multiple", "translation": "Многократный"},
|
||||
{"id": "badges.field.multiple.help", "translation": "Можно ли выдавать этот значок несколько раз"},
|
||||
{"id": "badges.field.delete_badge", "translation": "Удалить значок"},
|
||||
{"id": "badges.field.delete_badge.help", "translation": "ВНИМАНИЕ: если отметить, значок будет удалён безвозвратно."},
|
||||
{"id": "badges.field.everyone_can_create", "translation": "Все могут создавать значки"},
|
||||
{"id": "badges.field.everyone_can_create.help", "translation": "Любой пользователь может создать значок этого типа"},
|
||||
{"id": "badges.field.multiple.help", "translation": "Можно ли выдавать это достижение несколько раз"},
|
||||
{"id": "badges.field.delete_badge", "translation": "Удалить достижение"},
|
||||
{"id": "badges.field.delete_badge.help", "translation": "ВНИМАНИЕ: если отметить, достижение будет удалён безвозвратно."},
|
||||
{"id": "badges.field.everyone_can_create", "translation": "Все могут создавать достижения"},
|
||||
{"id": "badges.field.everyone_can_create.help", "translation": "Любой пользователь может создать достижение этого типа"},
|
||||
{"id": "badges.field.allowlist_create", "translation": "Список допущенных к созданию"},
|
||||
{"id": "badges.field.allowlist_create.help", "translation": "Укажите имена пользователей через запятую (,), которые могут создавать значки этого типа."},
|
||||
{"id": "badges.field.everyone_can_grant", "translation": "Все могут выдавать значки"},
|
||||
{"id": "badges.field.everyone_can_grant.help", "translation": "Любой пользователь может выдать значок этого типа"},
|
||||
{"id": "badges.field.allowlist_create.help", "translation": "Укажите имена пользователей через запятую (,), которые могут создавать достижения этого типа."},
|
||||
{"id": "badges.field.everyone_can_grant", "translation": "Все могут выдавать достижения"},
|
||||
{"id": "badges.field.everyone_can_grant.help", "translation": "Любой пользователь может выдать достижение этого типа"},
|
||||
{"id": "badges.field.allowlist_grant", "translation": "Список допущенных к выдаче"},
|
||||
{"id": "badges.field.allowlist_grant.help", "translation": "Укажите имена пользователей через запятую (,), которые могут выдавать значки этого типа."},
|
||||
{"id": "badges.field.allowlist_grant.help", "translation": "Укажите имена пользователей через запятую (,), которые могут выдавать достижения этого типа."},
|
||||
{"id": "badges.field.delete_type", "translation": "Удалить тип"},
|
||||
{"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные значки будут удалены безвозвратно."},
|
||||
{"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные достижения будут удалены безвозвратно."},
|
||||
{"id": "badges.field.user", "translation": "Пользователь"},
|
||||
{"id": "badges.field.badge", "translation": "Значок"},
|
||||
{"id": "badges.field.badge", "translation": "Достижение"},
|
||||
{"id": "badges.field.reason", "translation": "Причина"},
|
||||
{"id": "badges.field.reason.help", "translation": "Причина выдачи значка. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."},
|
||||
{"id": "badges.field.reason.help", "translation": "Причина выдачи достижения. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."},
|
||||
{"id": "badges.field.notify_here", "translation": "Уведомить в этом канале"},
|
||||
{"id": "badges.field.notify_here.help", "translation": "Если отметить, бот отправит сообщение в этот канал о том, что вы выдали значок этому пользователю."},
|
||||
{"id": "badges.field.notify_here.help", "translation": "Если отметить, бот отправит сообщение в этот канал о том, что вы выдали достижение этому пользователю."},
|
||||
|
||||
{"id": "badges.error.unknown", "translation": "Произошла неизвестная ошибка. Обратитесь к системному администратору."},
|
||||
{"id": "badges.error.cannot_get_user", "translation": "Не удалось получить пользователя."},
|
||||
{"id": "badges.error.only_sysadmin_clean", "translation": "Только системный администратор может очистить базу значков."},
|
||||
{"id": "badges.error.only_sysadmin_clean", "translation": "Только системный администратор может очистить базу достижений."},
|
||||
{"id": "badges.error.specify_create", "translation": "Укажите, что вы хотите создать."},
|
||||
{"id": "badges.error.create_badge_or_type", "translation": "Можно создать badge или type"},
|
||||
{"id": "badges.error.no_types_available", "translation": "Вы не можете создать значки ни одного типа."},
|
||||
{"id": "badges.error.must_set_badge_id", "translation": "Необходимо указать ID значка"},
|
||||
{"id": "badges.error.cannot_edit_badge", "translation": "У вас нет прав на редактирование этого значка"},
|
||||
{"id": "badges.error.no_types_available", "translation": "Вы не можете создать достижения ни одного типа."},
|
||||
{"id": "badges.error.must_set_badge_id", "translation": "Необходимо указать ID достижения"},
|
||||
{"id": "badges.error.cannot_edit_badge", "translation": "У вас нет прав на редактирование этого достижения"},
|
||||
{"id": "badges.error.specify_edit", "translation": "Укажите, что вы хотите отредактировать."},
|
||||
{"id": "badges.error.edit_badge_or_type", "translation": "Можно редактировать badge или type"},
|
||||
{"id": "badges.error.no_permissions_edit_type", "translation": "У вас нет прав на редактирование типа значков."},
|
||||
{"id": "badges.error.no_permissions_edit_type", "translation": "У вас нет прав на редактирование типа достижений."},
|
||||
{"id": "badges.error.must_provide_type_id", "translation": "Необходимо указать ID типа"},
|
||||
{"id": "badges.error.cannot_edit_type", "translation": "У вас нет прав на редактирование этого типа"},
|
||||
{"id": "badges.error.no_permissions_grant", "translation": "У вас нет прав на выдачу этого значка"},
|
||||
{"id": "badges.error.cannot_grant_badge", "translation": "Вы не можете выдать этот значок"},
|
||||
{"id": "badges.error.no_permissions_grant", "translation": "У вас нет прав на выдачу этого достижения"},
|
||||
{"id": "badges.error.cannot_grant_badge", "translation": "Вы не можете выдать это достижение"},
|
||||
{"id": "badges.error.specify_subscription", "translation": "Укажите, что вы хотите сделать."},
|
||||
{"id": "badges.error.create_or_delete_subscription", "translation": "Можно создать или удалить подписку"},
|
||||
{"id": "badges.error.cannot_create_subscription", "translation": "Вы не можете создавать подписки"},
|
||||
{"id": "badges.error.no_permissions_create_type", "translation": "У вас нет прав на создание типа значков."},
|
||||
{"id": "badges.error.no_permissions_create_type", "translation": "У вас нет прав на создание типа достижений."},
|
||||
{"id": "badges.error.already_owned", "translation": "Это достижение уже выдано этому пользователю"},
|
||||
|
||||
{"id": "badges.success.clean", "translation": "Очищено"},
|
||||
{"id": "badges.success.granted", "translation": "Выдано"},
|
||||
@ -72,8 +73,8 @@
|
||||
{"id": "badges.api.empty_emoji", "translation": "Пустой эмодзи"},
|
||||
{"id": "badges.api.invalid_field", "translation": "Некорректное поле"},
|
||||
{"id": "badges.api.type_not_exist", "translation": "Этот тип не существует"},
|
||||
{"id": "badges.api.no_permissions_create_badge", "translation": "У вас нет прав на создание этого значка"},
|
||||
{"id": "badges.api.badge_created", "translation": "Значок `%s` создан."},
|
||||
{"id": "badges.api.no_permissions_create_badge", "translation": "У вас нет прав на создание этого достижения"},
|
||||
{"id": "badges.api.badge_created", "translation": "Достижение `%s` создано."},
|
||||
{"id": "badges.api.no_permissions_create_type", "translation": "У вас нет прав на создание типа"},
|
||||
{"id": "badges.api.cannot_find_user", "translation": "Не удалось найти пользователя"},
|
||||
{"id": "badges.api.error_getting_user", "translation": "Ошибка получения пользователя %s: %v"},
|
||||
@ -83,24 +84,25 @@
|
||||
{"id": "badges.api.could_not_get_type", "translation": "Не удалось получить тип"},
|
||||
{"id": "badges.api.no_permissions_edit_type", "translation": "У вас нет прав на редактирование этого типа"},
|
||||
{"id": "badges.api.type_updated", "translation": "Тип `%s` обновлён."},
|
||||
{"id": "badges.api.cannot_get_badge", "translation": "Не удалось получить значок"},
|
||||
{"id": "badges.api.cannot_edit_badge", "translation": "Вы не можете редактировать этот значок"},
|
||||
{"id": "badges.api.could_not_get_badge", "translation": "Не удалось получить значок"},
|
||||
{"id": "badges.api.no_permissions_edit_badge", "translation": "У вас нет прав на редактирование этого значка"},
|
||||
{"id": "badges.api.badge_updated", "translation": "Значок `%s` обновлён."},
|
||||
{"id": "badges.api.badge_not_found", "translation": "Значок не найден"},
|
||||
{"id": "badges.api.no_permissions_grant", "translation": "У вас нет прав на выдачу этого значка"},
|
||||
{"id": "badges.api.cannot_get_badge", "translation": "Не удалось получить достижение"},
|
||||
{"id": "badges.api.cannot_edit_badge", "translation": "Вы не можете редактировать это достижение"},
|
||||
{"id": "badges.api.could_not_get_badge", "translation": "Не удалось получить достижение"},
|
||||
{"id": "badges.api.no_permissions_edit_badge", "translation": "У вас нет прав на редактирование этого достижения"},
|
||||
{"id": "badges.api.badge_updated", "translation": "Достижение `%s` обновлёно."},
|
||||
{"id": "badges.api.badge_not_found", "translation": "Достижение не найдено"},
|
||||
{"id": "badges.api.no_permissions_grant", "translation": "У вас нет прав на выдачу этого достижения"},
|
||||
{"id": "badges.api.user_not_found", "translation": "Пользователь не найден"},
|
||||
{"id": "badges.api.badge_granted", "translation": "Значок `%s` выдан @%s."},
|
||||
{"id": "badges.api.badge_granted", "translation": "Достижение `%s` выдан @%s."},
|
||||
{"id": "badges.api.cannot_create_subscription", "translation": "Вы не можете создать подписку"},
|
||||
{"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`."},
|
||||
{"id": "badges.notify.dm_text", "translation": "@%s выдал вам достижение %s`%s`."},
|
||||
{"id": "badges.notify.dm_reason", "translation": "\nПочему? "},
|
||||
{"id": "badges.notify.title", "translation": "%sзначок выдан!"},
|
||||
{"id": "badges.notify.channel_text", "translation": "@%s выдал @%s значок %s`%s`."},
|
||||
{"id": "badges.notify.title", "translation": "%sдостижение выдано!"},
|
||||
{"id": "badges.notify.channel_text", "translation": "@%s выдал @%s достижение %s`%s`."},
|
||||
{"id": "badges.notify.no_permission_channel", "translation": "У вас нет прав на отправку уведомления о выдаче в этот канал."}
|
||||
]
|
||||
|
||||
@ -28,7 +28,7 @@ type Plugin struct {
|
||||
BotUserID string
|
||||
store Store
|
||||
router *mux.Router
|
||||
badgeAdminUserID string
|
||||
badgeAdminUserIDs map[string]bool
|
||||
i18nBundle *i18n.Bundle
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
var errInvalidBadge = errors.New("invalid badge")
|
||||
var errBadgeNotFound = errors.New("badge not found")
|
||||
var errAlreadyOwned = errors.New("already owned")
|
||||
|
||||
type Store interface {
|
||||
// Interface
|
||||
@ -33,12 +34,17 @@ type Store interface {
|
||||
UpdateBadge(b *badgesmodel.Badge) error
|
||||
DeleteType(tID badgesmodel.BadgeType) error
|
||||
DeleteBadge(bID badgesmodel.BadgeID) error
|
||||
RevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) error
|
||||
FindOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (*badgesmodel.Ownership, error)
|
||||
|
||||
AddSubscription(tID badgesmodel.BadgeType, cID string) error
|
||||
RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error
|
||||
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 +150,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 +445,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()
|
||||
@ -473,6 +506,25 @@ func (s *store) AddSubscription(tID badgesmodel.BadgeType, cID string) error {
|
||||
return s.doAtomic(func() (bool, error) { return s.atomicAddSubscription(toAdd) })
|
||||
}
|
||||
|
||||
func (s *store) FindOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (*badgesmodel.Ownership, error) {
|
||||
ownership, _, err := s.getOwnershipList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, o := range ownership {
|
||||
if o.Badge == badgeID && o.User == userID && o.Time.Format(time.RFC3339Nano) == grantTime {
|
||||
return &o, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("ownership not found")
|
||||
}
|
||||
|
||||
func (s *store) RevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) error {
|
||||
return s.doAtomic(func() (bool, error) { return s.atomicRevokeOwnership(badgeID, userID, grantTime) })
|
||||
}
|
||||
|
||||
func (s *store) RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error {
|
||||
toRemove := badgesmodel.Subscription{ChannelID: cID, TypeID: tID}
|
||||
return s.doAtomic(func() (bool, error) { return s.atomicRemoveSubscription(toRemove) })
|
||||
|
||||
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
|
||||
)
|
||||
@ -107,7 +108,7 @@ func (s *store) atomicAddBadgeToOwnership(o badgesmodel.Ownership, isMultiple bo
|
||||
}
|
||||
|
||||
if !isMultiple && ownership.IsOwned(o.User, o.Badge) {
|
||||
return false, true, nil
|
||||
return false, true, errAlreadyOwned
|
||||
}
|
||||
|
||||
ownership = append(ownership, o)
|
||||
@ -159,6 +160,28 @@ func (s *store) atomicUpdateBadge(b *badgesmodel.Badge) (bool, error) {
|
||||
return s.compareAndSet(KVKeyBadges, data, bb)
|
||||
}
|
||||
|
||||
func (s *store) atomicRevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (bool, error) {
|
||||
ownership, data, err := s.getOwnershipList()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, o := range ownership {
|
||||
if o.Badge == badgeID && o.User == userID && o.Time.Format(time.RFC3339Nano) == grantTime {
|
||||
ownership = append(ownership[:i], ownership[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return s.compareAndSet(KVKeyOwnership, data, ownership)
|
||||
}
|
||||
|
||||
func (s *store) atomicAddSubscription(toAdd badgesmodel.Subscription) (bool, error) {
|
||||
subs, data, err := s.getAllSubscriptions()
|
||||
if err != nil {
|
||||
|
||||
@ -23,7 +23,7 @@ func (p *Plugin) filterGrantBadges(user *model.User) ([]*badgesmodel.Badge, erro
|
||||
p.mm.Log.Debug("Badge with missing type", "badge", b)
|
||||
continue
|
||||
}
|
||||
if canGrantBadge(user, p.badgeAdminUserID, b, badgeType) {
|
||||
if canGrantBadge(user, p.badgeAdminUserIDs, b, badgeType) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,7 @@ func (p *Plugin) filterCreateBadgeTypes(user *model.User) (badgesmodel.BadgeType
|
||||
|
||||
out := badgesmodel.BadgeTypeList{}
|
||||
for _, t := range types {
|
||||
if canCreateBadge(user, p.badgeAdminUserID, t) {
|
||||
if canCreateBadge(user, p.badgeAdminUserIDs, t) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
@ -55,7 +55,7 @@ func (p *Plugin) filterEditTypes(user *model.User) (badgesmodel.BadgeTypeList, e
|
||||
|
||||
out := badgesmodel.BadgeTypeList{}
|
||||
for _, t := range types {
|
||||
if canEditType(user, p.badgeAdminUserID, t) {
|
||||
if canEditType(user, p.badgeAdminUserIDs, t) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
@ -69,9 +69,18 @@ func (p *Plugin) filterEditBadges(user *model.User) ([]*badgesmodel.Badge, error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
typeCache := map[badgesmodel.BadgeType]*badgesmodel.BadgeTypeDefinition{}
|
||||
out := []*badgesmodel.Badge{}
|
||||
for _, b := range bb {
|
||||
if canEditBadge(user, p.badgeAdminUserID, b) {
|
||||
bt, ok := typeCache[b.Type]
|
||||
if !ok {
|
||||
bt, err = p.store.GetType(b.Type)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
typeCache[b.Type] = bt
|
||||
}
|
||||
if canEditBadge(user, p.badgeAdminUserIDs, b, bt) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
@ -23,8 +24,8 @@ func areRolesAllowed(userRoles []string, allowedRoles map[string]bool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canGrantBadge(user *model.User, badgeAdminIDs map[string]bool, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -57,8 +58,8 @@ func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Bad
|
||||
return badgeType.CanGrant.Everyone
|
||||
}
|
||||
|
||||
func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canCreateBadge(user *model.User, badgeAdminIDs map[string]bool, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -87,36 +88,44 @@ func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmode
|
||||
return badgeType.CanCreate.Everyone
|
||||
}
|
||||
|
||||
func canEditType(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canEditType(user *model.User, badgeAdminIDs map[string]bool, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
return user.IsSystemAdmin()
|
||||
}
|
||||
|
||||
func canEditBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canEditBadge(user *model.User, badgeAdminIDs map[string]bool, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
return user.IsSystemAdmin() || user.Id == badge.CreatedBy
|
||||
if user.IsSystemAdmin() {
|
||||
return true
|
||||
}
|
||||
|
||||
func canCreateType(user *model.User, badgeAdminID string, isPlugin bool) bool {
|
||||
if badgeType != nil && canCreateBadge(user, badgeAdminIDs, badgeType) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func canCreateType(user *model.User, badgeAdminIDs map[string]bool, isPlugin bool) bool {
|
||||
if isPlugin {
|
||||
return true
|
||||
}
|
||||
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
return user.IsSystemAdmin()
|
||||
}
|
||||
|
||||
func canCreateSubscription(user *model.User, badgeAdminID string, channelID string) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canCreateSubscription(user *model.User, badgeAdminIDs map[string]bool, channelID string) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -160,6 +169,7 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
|
||||
dmText += Tdm("badges.notify.dm_reason", "\nПочему? ") + reason
|
||||
}
|
||||
dmAttachment := model.SlackAttachment{
|
||||
Fallback: dmText,
|
||||
Title: Tdm("badges.notify.title", "%sзначок выдан!", image),
|
||||
Text: dmText,
|
||||
}
|
||||
@ -192,7 +202,14 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
|
||||
p.mm.Log.Debug("notify subscription error", "err", err)
|
||||
}
|
||||
}
|
||||
if inChannel {
|
||||
alreadyNotified := false
|
||||
for _, sub := range subs {
|
||||
if sub == channelID {
|
||||
alreadyNotified = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if inChannel && !alreadyNotified {
|
||||
if !p.API.HasPermissionToChannel(granter, channelID, model.PERMISSION_CREATE_POST) {
|
||||
Tg := p.getT(granterUser.Locale)
|
||||
p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: Tg("badges.notify.no_permission_channel", "У вас нет прав на отправку уведомления о выдаче в этот канал."), ChannelId: channelID})
|
||||
@ -208,6 +225,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
|
||||
|
||||
@ -194,22 +194,14 @@
|
||||
"skipComments": false
|
||||
}
|
||||
],
|
||||
"max-nested-callbacks": [
|
||||
2,
|
||||
{
|
||||
"max": 2
|
||||
}
|
||||
],
|
||||
"max-nested-callbacks": 0,
|
||||
"max-statements-per-line": [
|
||||
2,
|
||||
{
|
||||
"max": 1
|
||||
}
|
||||
],
|
||||
"multiline-ternary": [
|
||||
1,
|
||||
"never"
|
||||
],
|
||||
"multiline-ternary": 0,
|
||||
"new-cap": 2,
|
||||
"new-parens": 2,
|
||||
"newline-before-return": 0,
|
||||
@ -415,10 +407,7 @@
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"operator-linebreak": [
|
||||
2,
|
||||
"after"
|
||||
],
|
||||
"operator-linebreak": 0,
|
||||
"padded-blocks": [
|
||||
2,
|
||||
"never"
|
||||
@ -697,7 +686,8 @@
|
||||
{
|
||||
"extensions": [".jsx", ".tsx"]
|
||||
}
|
||||
]
|
||||
],
|
||||
"react/prop-types": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,40 +1,181 @@
|
||||
{
|
||||
"badges.loading": "Loading...",
|
||||
"badges.no_badges_yet": "No badges yet.",
|
||||
"badges.badge_not_found": "Badge not found.",
|
||||
"badges.no_badges_yet": "No achievements yet.",
|
||||
"badges.empty.title": "No achievements yet",
|
||||
"badges.empty.description": "Create your first achievement to recognize contributions of your team members.",
|
||||
"badges.badge_not_found": "Achievement not found.",
|
||||
"badges.user_not_found": "User not found.",
|
||||
"badges.unknown": "unknown",
|
||||
|
||||
"badges.rhs.all_badges": "All badges",
|
||||
"badges.rhs.my_badges": "My badges",
|
||||
"badges.rhs.user_badges": "@{username}'s badges",
|
||||
"badges.rhs.badge_details": "Badge Details",
|
||||
"badges.rhs.all_badges": "All achievements",
|
||||
"badges.rhs.my_badges": "My achievements",
|
||||
"badges.rhs.user_badges": "@{username}'s achievements",
|
||||
"badges.rhs.badge_details": "Achievement 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}",
|
||||
"badges.label.granted_at": "Granted at: {date}",
|
||||
"badges.label.reason": "Why? {reason}",
|
||||
"badges.label.count": "Count: {count}",
|
||||
|
||||
"badges.granted.not_yet": "Not yet granted.",
|
||||
"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",
|
||||
"badges.set_status": "Set status to this achievement",
|
||||
"badges.grant_badge": "Grant achievement",
|
||||
"badges.and_more": "and {count} more. Click to see all.",
|
||||
|
||||
"badges.menu.open_list": "Open the list of all badges.",
|
||||
"badges.menu.create_badge": "Create badge",
|
||||
"badges.menu.create_type": "Create badge type",
|
||||
"badges.menu.add_subscription": "Add badge subscription",
|
||||
"badges.menu.remove_subscription": "Remove badge subscription",
|
||||
"badges.menu.open_list": "Achievements.",
|
||||
"badges.menu.create_badge": "Create achievement",
|
||||
"badges.menu.create_type": "Create achievement type",
|
||||
"badges.menu.add_subscription": "Add achievement subscription",
|
||||
"badges.menu.remove_subscription": "Remove achievement subscription",
|
||||
|
||||
"badges.sidebar.title": "Badges",
|
||||
"badges.popover.title": "Badges",
|
||||
"badges.sidebar.title": "Achievements",
|
||||
"badges.popover.title": "Achievements",
|
||||
|
||||
"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.label": "Achievements Administrators:",
|
||||
"badges.admin.placeholder": "Start typing a name...",
|
||||
"badges.admin.help_text": "These users will be considered achievements plugin administrators. They can create types, as well as modify and grant any achievements.",
|
||||
"badges.admin.no_results": "No users found",
|
||||
|
||||
"badges.rhs.create_badge": "+ Create achievement",
|
||||
"badges.rhs.edit_badge": "Edit",
|
||||
"badges.rhs.types": "Types",
|
||||
"badges.rhs.create_type": "+ Create type",
|
||||
|
||||
"badges.modal.create_badge_title": "Create Achievement",
|
||||
"badges.modal.edit_badge_title": "Edit Achievement",
|
||||
"badges.modal.field_name": "Name",
|
||||
"badges.modal.field_name_placeholder": "Achievement name (max 20 chars)",
|
||||
"badges.modal.field_description": "Description",
|
||||
"badges.modal.field_description_placeholder": "Achievement 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 achievement 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 achievements",
|
||||
"badges.modal.new_type_everyone_grant": "Everyone can grant achievements",
|
||||
"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 achievement",
|
||||
"badges.modal.btn_confirm_delete": "Yes, delete",
|
||||
"badges.modal.confirm_delete": "Are you sure?",
|
||||
"badges.modal.confirm_delete_badge": "Delete achievement \"{name}\"?",
|
||||
"badges.modal.error_generic": "An error occurred",
|
||||
"badges.modal.error_type_name_required": "Enter type name",
|
||||
"badges.modal.error_type_required": "Select achievement type",
|
||||
"badges.modal.error_duplicate_name": "An achievement with this name already exists in this type",
|
||||
"badges.modal.error_not_found_emoji": "This emoji was not found",
|
||||
"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 {# achievement} other {# achievements}}",
|
||||
"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 achievements?",
|
||||
"badges.types.empty": "No types yet",
|
||||
"badges.types.no_badges": "No achievements in this type",
|
||||
"badges.rhs.back_to_types": "Back to types",
|
||||
"badges.rhs.back_to_achievements": "Back to achievements",
|
||||
|
||||
"badges.modal.allowlist_create": "Allowlist for creation",
|
||||
"badges.modal.allowlist_create_help": "Users who can create achievements of this type.",
|
||||
"badges.modal.allowlist_grant": "Allowlist for granting",
|
||||
"badges.modal.allowlist_grant_help": "Users who can grant achievements of this type.",
|
||||
"badges.modal.allowlist_placeholder": "user-1, user-2, user-3",
|
||||
|
||||
"badges.grant.title": "Grant Achievement",
|
||||
"badges.grant.intro": "Grant achievement to @{username}",
|
||||
"badges.grant.field_badge": "Achievement",
|
||||
"badges.grant.field_badge_placeholder": "Select an achievement",
|
||||
"badges.grant.no_badges": "No achievements available",
|
||||
"badges.grant.field_reason": "Reason",
|
||||
"badges.grant.field_reason_placeholder": "Why is this achievement being granted? (optional)",
|
||||
"badges.grant.notify_here": "Notify in channel",
|
||||
"badges.grant.btn_grant": "Grant",
|
||||
|
||||
"badges.revoke.btn": "Revoke",
|
||||
"badges.revoke.confirm": "Revoke achievement?",
|
||||
"badges.revoke.confirm_yes": "Yes",
|
||||
|
||||
"badges.subscription.title_create": "Add Subscription",
|
||||
"badges.subscription.title_delete": "Remove Subscription",
|
||||
"badges.subscription.field_type": "Achievement Type",
|
||||
"badges.subscription.field_type_placeholder": "Select achievement type",
|
||||
"badges.subscription.no_types": "No types available",
|
||||
"badges.subscription.btn_create": "Add",
|
||||
"badges.subscription.btn_delete": "Remove",
|
||||
|
||||
"badges.error.invalid_badge_id": "Achievement not specified",
|
||||
"badges.error.invalid_user_id": "User not specified",
|
||||
"badges.error.no_permission_grant": "Insufficient permissions to grant this achievement",
|
||||
"badges.error.cannot_grant_badge": "Failed to grant achievement",
|
||||
"badges.error.user_not_found": "User not found",
|
||||
"badges.error.invalid_type_id": "Achievement type not specified",
|
||||
"badges.error.no_permission_subscription": "Insufficient permissions to manage subscriptions",
|
||||
"badges.error.cannot_create_subscription": "Failed to create subscription",
|
||||
"badges.error.cannot_delete_subscription": "Failed to delete subscription",
|
||||
"badges.error.ownership_not_found": "Ownership not found",
|
||||
"badges.error.no_permission_revoke": "Insufficient permissions to revoke",
|
||||
"badges.error.cannot_revoke": "Failed to revoke",
|
||||
"badges.error.already_owned": "This achievement is already owned by this user",
|
||||
|
||||
"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 achievements",
|
||||
"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": "Achievement type not found",
|
||||
"badges.error.badge_not_found": "Achievement not found",
|
||||
"badges.error.no_permission": "Insufficient permissions",
|
||||
"badges.error.missing_badge_id": "Achievement ID is missing",
|
||||
"badges.error.missing_type_id": "Type ID is missing",
|
||||
"badges.error.cannot_create_badge": "Failed to create achievement",
|
||||
"badges.error.cannot_create_type": "Failed to create type",
|
||||
"badges.error.cannot_update_badge": "Failed to update achievement",
|
||||
"badges.error.cannot_delete_badge": "Failed to delete achievement",
|
||||
"badges.error.cannot_update_type": "Failed to update type",
|
||||
"badges.error.cannot_delete_type": "Failed to delete type",
|
||||
|
||||
"emoji_picker.activities": "Activities",
|
||||
"emoji_picker.animals-nature": "Animals & Nature",
|
||||
"emoji_picker.close": "Close",
|
||||
"emoji_picker.custom": "Custom",
|
||||
"emoji_picker.custom_emoji": "Custom Emoji",
|
||||
"emoji_picker.emojiPicker.button.ariaLabel": "select an emoji",
|
||||
"emoji_picker.emojiPicker.previewPlaceholder": "Select an Emoji",
|
||||
"emoji_picker.flags": "Flags",
|
||||
"emoji_picker.food-drink": "Food & Drink",
|
||||
"emoji_picker.header": "Emoji Picker",
|
||||
"emoji_picker.objects": "Objects",
|
||||
"emoji_picker.people-body": "People & Body",
|
||||
"emoji_picker.recent": "Recently Used",
|
||||
"emoji_picker.search": "Search emojis",
|
||||
"emoji_picker.searchResults": "Search Results",
|
||||
"emoji_picker.search_emoji": "Search for an emoji",
|
||||
"emoji_picker.skin_tone": "Skin tone",
|
||||
"emoji_picker.smileys-emotion": "Smileys & Emotion",
|
||||
"emoji_picker.symbols": "Symbols",
|
||||
"emoji_picker.travel-places": "Travel Places",
|
||||
"emoji_picker_item.emoji_aria_label": "{emojiName} emoji"
|
||||
}
|
||||
|
||||
@ -1,40 +1,181 @@
|
||||
{
|
||||
"badges.loading": "Загрузка...",
|
||||
"badges.no_badges_yet": "Значков пока нет.",
|
||||
"badges.badge_not_found": "Значок не найден.",
|
||||
"badges.no_badges_yet": "Достижений пока нет.",
|
||||
"badges.empty.title": "Достижений пока нет",
|
||||
"badges.empty.description": "Создайте первое достижение, чтобы отмечать заслуги участников команды.",
|
||||
"badges.badge_not_found": "Достижение не найдено.",
|
||||
"badges.user_not_found": "Пользователь не найден.",
|
||||
"badges.unknown": "неизвестно",
|
||||
|
||||
"badges.rhs.all_badges": "Все значки",
|
||||
"badges.rhs.my_badges": "Мои значки",
|
||||
"badges.rhs.user_badges": "Значки @{username}",
|
||||
"badges.rhs.badge_details": "Детали значка",
|
||||
"badges.rhs.all_badges": "Все достижения",
|
||||
"badges.rhs.my_badges": "Мои достижения",
|
||||
"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}",
|
||||
"badges.label.granted_at": "Выдан: {date}",
|
||||
"badges.label.reason": "Причина: {reason}",
|
||||
"badges.label.count": "Количество: {count}",
|
||||
|
||||
"badges.granted.not_yet": "Ещё не выдан.",
|
||||
"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": "Выдать значок",
|
||||
"badges.grant_badge": "Выдать достижение",
|
||||
"badges.and_more": "и ещё {count}. Нажмите, чтобы увидеть все.",
|
||||
|
||||
"badges.menu.open_list": "Открыть список всех значков.",
|
||||
"badges.menu.create_badge": "Создать значок",
|
||||
"badges.menu.create_type": "Создать тип значков",
|
||||
"badges.menu.add_subscription": "Добавить подписку на значки",
|
||||
"badges.menu.remove_subscription": "Удалить подписку на значки",
|
||||
"badges.menu.open_list": "Достижения.",
|
||||
"badges.menu.create_badge": "Создать достижение",
|
||||
"badges.menu.create_type": "Создать тип достижений",
|
||||
"badges.menu.add_subscription": "Добавить подписку на достижения",
|
||||
"badges.menu.remove_subscription": "Удалить подписку на достижения",
|
||||
|
||||
"badges.sidebar.title": "Значки",
|
||||
"badges.popover.title": "Значки",
|
||||
"badges.sidebar.title": "Достижения",
|
||||
"badges.popover.title": "Достижения",
|
||||
|
||||
"badges.admin.label": "Администратор достижений:",
|
||||
"badges.admin.placeholder": "имя пользователя",
|
||||
"badges.admin.help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки."
|
||||
"badges.admin.label": "Администраторы достижений:",
|
||||
"badges.admin.placeholder": "Начните вводить имя...",
|
||||
"badges.admin.help_text": "Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые достижения.",
|
||||
"badges.admin.no_results": "Пользователь не найден",
|
||||
|
||||
"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": "Редактировать достижение",
|
||||
"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.confirm_delete_badge": "Удалить достижение «{name}»?",
|
||||
"badges.modal.error_generic": "Произошла ошибка",
|
||||
"badges.modal.error_type_name_required": "Введите название типа",
|
||||
"badges.modal.error_type_required": "Выберите тип достижения",
|
||||
"badges.modal.error_duplicate_name": "Достижение в данном типе с таким названием уже существует",
|
||||
"badges.modal.error_not_found_emoji": "Этот эмодзи не найден",
|
||||
"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.rhs.back_to_achievements": "Назад к достижениям",
|
||||
|
||||
"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.grant.title": "Выдать достижение",
|
||||
"badges.grant.intro": "Выдать достижение пользователю @{username}",
|
||||
"badges.grant.field_badge": "Достижение",
|
||||
"badges.grant.field_badge_placeholder": "Выберите достижение",
|
||||
"badges.grant.no_badges": "Нет доступных достижений",
|
||||
"badges.grant.field_reason": "Причина",
|
||||
"badges.grant.field_reason_placeholder": "За что выдаётся достижение? (необязательно)",
|
||||
"badges.grant.notify_here": "Уведомить в канале",
|
||||
"badges.grant.btn_grant": "Выдать",
|
||||
|
||||
"badges.revoke.btn": "Снять достижение",
|
||||
"badges.revoke.confirm": "Снять достижение?",
|
||||
"badges.revoke.confirm_yes": "Да",
|
||||
|
||||
"badges.subscription.title_create": "Добавить подписку",
|
||||
"badges.subscription.title_delete": "Удалить подписку",
|
||||
"badges.subscription.field_type": "Тип достижений",
|
||||
"badges.subscription.field_type_placeholder": "Выберите тип достижений",
|
||||
"badges.subscription.no_types": "Нет доступных типов",
|
||||
"badges.subscription.btn_create": "Добавить",
|
||||
"badges.subscription.btn_delete": "Удалить",
|
||||
|
||||
"badges.error.invalid_badge_id": "Не указано достижение",
|
||||
"badges.error.invalid_user_id": "Не указан пользователь",
|
||||
"badges.error.no_permission_grant": "Недостаточно прав для выдачи этого достижения",
|
||||
"badges.error.cannot_grant_badge": "Не удалось выдать достижение",
|
||||
"badges.error.user_not_found": "Пользователь не найден",
|
||||
"badges.error.invalid_type_id": "Не указан тип достижений",
|
||||
"badges.error.no_permission_subscription": "Недостаточно прав для управления подписками",
|
||||
"badges.error.cannot_create_subscription": "Не удалось создать подписку",
|
||||
"badges.error.cannot_delete_subscription": "Не удалось удалить подписку",
|
||||
"badges.error.ownership_not_found": "Выдача не найдена",
|
||||
"badges.error.no_permission_revoke": "Недостаточно прав для снятия этого достижения",
|
||||
"badges.error.cannot_revoke": "Не удалось снять достижение",
|
||||
"badges.error.already_owned": "Это достижение уже выдано этому пользователю",
|
||||
|
||||
"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_update_type": "Не удалось обновить тип",
|
||||
"badges.error.cannot_delete_type": "Не удалось удалить тип",
|
||||
|
||||
"emoji_picker.activities": "Мероприятия",
|
||||
"emoji_picker.animals-nature": "Животные и природа",
|
||||
"emoji_picker.close": "Закрыть",
|
||||
"emoji_picker.custom": "Настраиваемое",
|
||||
"emoji_picker.custom_emoji": "Пользовательские смайлики",
|
||||
"emoji_picker.emojiPicker.button.ariaLabel": "выберите смайлик",
|
||||
"emoji_picker.emojiPicker.previewPlaceholder": "Выберите смайлик",
|
||||
"emoji_picker.flags": "Флаги",
|
||||
"emoji_picker.food-drink": "Еда и напитки",
|
||||
"emoji_picker.header": "Выбор смайликов",
|
||||
"emoji_picker.objects": "Объекты",
|
||||
"emoji_picker.people-body": "Люди и тело",
|
||||
"emoji_picker.recent": "Недавно использованные",
|
||||
"emoji_picker.search": "Поиск смайликов",
|
||||
"emoji_picker.searchResults": "Результаты поиска",
|
||||
"emoji_picker.search_emoji": "Поиск смайлика",
|
||||
"emoji_picker.skin_tone": "Цвет кожи",
|
||||
"emoji_picker.smileys-emotion": "Смайлы и эмоции",
|
||||
"emoji_picker.symbols": "Символы",
|
||||
"emoji_picker.travel-places": "Места путешествий",
|
||||
"emoji_picker_item.emoji_aria_label": "смайлик {emojiName}"
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
"@typescript-eslint/parser": "4.22.0",
|
||||
"babel-eslint": "10.1.0",
|
||||
"babel-jest": "26.6.3",
|
||||
"babel-loader": "8.2.2",
|
||||
"babel-loader": "^8.3.0",
|
||||
"babel-plugin-typescript-to-proptypes": "1.4.2",
|
||||
"css-loader": "5.2.4",
|
||||
"enzyme": "3.11.0",
|
||||
@ -57,12 +57,11 @@
|
||||
"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",
|
||||
"style-loader": "2.0.0",
|
||||
"webpack": "5.34.0",
|
||||
"webpack": "^5.54.0",
|
||||
"webpack-cli": "4.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -73,9 +72,13 @@
|
||||
"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"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "17.0.3"
|
||||
},
|
||||
"jest": {
|
||||
"snapshotSerializers": [
|
||||
"<rootDir>/node_modules/enzyme-to-json/serializer"
|
||||
|
||||
@ -8,4 +8,17 @@ 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',
|
||||
OPEN_GRANT_MODAL: pluginId + '_open_grant_modal',
|
||||
CLOSE_GRANT_MODAL: pluginId + '_close_grant_modal',
|
||||
OPEN_SUBSCRIPTION_MODAL: pluginId + '_open_subscription_modal',
|
||||
CLOSE_SUBSCRIPTION_MODAL: pluginId + '_close_subscription_modal',
|
||||
};
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
import {AnyAction, Dispatch} from 'redux';
|
||||
|
||||
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {GetStateFunc} from 'mattermost-redux/types/actions';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {IntegrationTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import ActionTypes from 'action_types/';
|
||||
import {BadgeID} from 'types/badges';
|
||||
import {RHSState} from 'types/general';
|
||||
import {BadgeDetails, BadgeID, BadgeTypeDefinition} from 'types/badges';
|
||||
import {GrantModalData, RHSState, SubscriptionModalData} from 'types/general';
|
||||
import {id as pluginId} from '../manifest';
|
||||
|
||||
/**
|
||||
* Stores`showRHSPlugin` action returned by
|
||||
@ -36,91 +31,106 @@ export function setRHSBadge(badgeID: BadgeID | null) {
|
||||
}
|
||||
|
||||
export function setRHSView(view: RHSState) {
|
||||
return {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: () => any) => {
|
||||
const state = getState();
|
||||
const pluginState = state['plugins-' + pluginId];
|
||||
const currentView = pluginState?.rhsView;
|
||||
dispatch({
|
||||
type: ActionTypes.RECEIVED_RHS_VIEW,
|
||||
data: view,
|
||||
prevView: currentView,
|
||||
});
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function setTriggerId(triggerId: string) {
|
||||
export function setRHSType(typeId: number | null, typeName: string | null) {
|
||||
return {
|
||||
type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
|
||||
data: triggerId,
|
||||
type: ActionTypes.RECEIVED_RHS_TYPE,
|
||||
data: {typeId, typeName},
|
||||
};
|
||||
}
|
||||
|
||||
export function openGrant(user?: string, badge?: string) {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
let command = '/badges grant';
|
||||
if (user) {
|
||||
command += ` --user ${user}`;
|
||||
}
|
||||
|
||||
if (badge) {
|
||||
command += ` --badge ${badge}`;
|
||||
}
|
||||
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openGrantModal({prefillUser: user, prefillBadgeId: badge}));
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function openCreateType() {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
const command = '/badges create type';
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openCreateTypeModal());
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function openCreateBadge() {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
const command = '/badges create badge';
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openCreateBadgeModal());
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function openAddSubscription() {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
const command = '/badges subscription create';
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
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 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 openGrantModal(data?: GrantModalData) {
|
||||
return {type: ActionTypes.OPEN_GRANT_MODAL, data: data || {}};
|
||||
}
|
||||
|
||||
export function closeGrantModal() {
|
||||
return {type: ActionTypes.CLOSE_GRANT_MODAL};
|
||||
}
|
||||
|
||||
export function openSubscriptionModal(data: SubscriptionModalData) {
|
||||
return {type: ActionTypes.OPEN_SUBSCRIPTION_MODAL, data};
|
||||
}
|
||||
|
||||
export function closeSubscriptionModal() {
|
||||
return {type: ActionTypes.CLOSE_SUBSCRIPTION_MODAL};
|
||||
}
|
||||
|
||||
export function openAddSubscription() {
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openSubscriptionModal({mode: 'create'}));
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function openRemoveSubscription() {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
const command = '/badges subscription remove';
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openSubscriptionModal({mode: 'delete'}));
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export async function clientExecuteCommand(dispatch: Dispatch<AnyAction>, getState: GetStateFunc, command: string) {
|
||||
let currentChannel = getCurrentChannel(getState());
|
||||
const currentTeamId = getCurrentTeamId(getState());
|
||||
|
||||
// Default to town square if there is no current channel (i.e., if Mattermost has not yet loaded)
|
||||
if (!currentChannel) {
|
||||
currentChannel = await Client4.getChannelByName(currentTeamId, 'town-square');
|
||||
}
|
||||
|
||||
const args = {
|
||||
channel_id: currentChannel?.id,
|
||||
team_id: currentTeamId,
|
||||
};
|
||||
|
||||
try {
|
||||
//@ts-ignore Typing in mattermost-redux is wrong
|
||||
const data = await Client4.executeCommand(command, args);
|
||||
dispatch(setTriggerId(data?.trigger_id));
|
||||
} catch (error) {
|
||||
console.error(error); //eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, GrantBadgeRequest, RevokeOwnershipRequest, SubscriptionRequest, UpdateBadgeRequest, UpdateTypeRequest, UserBadge} from 'types/badges';
|
||||
|
||||
export default class Client {
|
||||
private url: string;
|
||||
@ -41,6 +41,64 @@ export default class Client {
|
||||
}
|
||||
}
|
||||
|
||||
async getTypes(): Promise<GetTypesResponse> {
|
||||
try {
|
||||
const res = await this.doGet(`${this.url}/getTypes`);
|
||||
return res as GetTypesResponse;
|
||||
} catch {
|
||||
return {types: [], can_create_type: false, can_edit_type: false};
|
||||
}
|
||||
}
|
||||
|
||||
async createBadge(req: CreateBadgeRequest): Promise<Badge> {
|
||||
return await this.doPost(`${this.url}/createBadge`, req) as Badge;
|
||||
}
|
||||
|
||||
async createType(req: CreateTypeRequest): Promise<BadgeTypeDefinition> {
|
||||
return await this.doPost(`${this.url}/createType`, req) as BadgeTypeDefinition;
|
||||
}
|
||||
|
||||
async updateBadge(req: UpdateBadgeRequest): Promise<Badge> {
|
||||
return await this.doPut(`${this.url}/updateBadge`, req) as Badge;
|
||||
}
|
||||
|
||||
async deleteBadge(badgeID: BadgeID): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
async grantBadge(req: GrantBadgeRequest): Promise<void> {
|
||||
await this.doPost(`${this.url}/grantBadge`, req);
|
||||
}
|
||||
|
||||
async createSubscription(req: SubscriptionRequest): Promise<void> {
|
||||
await this.doPost(`${this.url}/createSubscription`, req);
|
||||
}
|
||||
|
||||
async deleteSubscription(req: SubscriptionRequest): Promise<void> {
|
||||
await this.doPost(`${this.url}/deleteSubscription`, req);
|
||||
}
|
||||
|
||||
async revokeOwnership(req: RevokeOwnershipRequest): Promise<void> {
|
||||
await this.doPost(`${this.url}/revokeOwnership`, req);
|
||||
}
|
||||
|
||||
async getChannelSubscriptions(channelID: string): Promise<BadgeTypeDefinition[]> {
|
||||
try {
|
||||
const res = await this.doGet(`${this.url}/getChannelSubscriptions/${channelID}`);
|
||||
return res as BadgeTypeDefinition[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private doGet = async (url: string, headers: {[x:string]: string} = {}) => {
|
||||
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
|
||||
|
||||
@ -63,4 +121,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, {useCallback} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import UserMultiSelect from 'components/user_multi_select';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
@ -16,10 +17,8 @@ type Props = {
|
||||
}
|
||||
|
||||
const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, setSaveNeeded}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(id, e.target.value);
|
||||
const handleChange = useCallback((newValue: string) => {
|
||||
onChange(id, newValue);
|
||||
setSaveNeeded();
|
||||
}, [id, onChange, setSaveNeeded]);
|
||||
|
||||
@ -28,25 +27,19 @@ const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, set
|
||||
<label className='control-label col-sm-4'>
|
||||
<FormattedMessage
|
||||
id='badges.admin.label'
|
||||
defaultMessage='Администратор достижений:'
|
||||
defaultMessage='Администраторы достижений:'
|
||||
/>
|
||||
</label>
|
||||
<div className='col-sm-8'>
|
||||
<input
|
||||
className='form-control'
|
||||
type='text'
|
||||
value={value || ''}
|
||||
disabled={disabled}
|
||||
<UserMultiSelect
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'badges.admin.placeholder',
|
||||
defaultMessage: 'username',
|
||||
})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className='help-text'>
|
||||
<FormattedMessage
|
||||
id='badges.admin.help_text'
|
||||
defaultMessage='Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки.'
|
||||
defaultMessage='Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые достижения.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
13
webapp/src/components/back_button/back_button.scss
Normal file
@ -0,0 +1,13 @@
|
||||
.BackButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: var(--button-bg, #166de0);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
29
webapp/src/components/back_button/back_button.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import {RHSState} from '../../types/general';
|
||||
|
||||
import './back_button.scss';
|
||||
|
||||
type Props = {
|
||||
targetView: RHSState;
|
||||
onNavigate: (view: RHSState) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BackButton: React.FC<Props> = ({
|
||||
targetView,
|
||||
onNavigate,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className='BackButton'
|
||||
onClick={() => onNavigate(targetView)}
|
||||
>
|
||||
{'← '}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackButton;
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import {Badge} from '../../types/badges';
|
||||
import RenderEmoji from '../utils/emoji';
|
||||
import RenderEmoji from '../emoji/emoji';
|
||||
import {IMAGE_TYPE_ABSOLUTE_URL, IMAGE_TYPE_EMOJI} from '../../constants';
|
||||
|
||||
type Props = {
|
||||
422
webapp/src/components/badge_modal/badge_modal.scss
Normal file
@ -0,0 +1,422 @@
|
||||
@keyframes badgeModalBackdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes badgeModalBackdropOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes badgeModalDialogIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes badgeModalDialogOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
animation: badgeModalBackdropIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
&__dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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;
|
||||
animation: badgeModalDialogIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
&--closing {
|
||||
.BadgeModal__backdrop {
|
||||
animation: badgeModalBackdropOut 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
.BadgeModal__dialog {
|
||||
animation: badgeModalDialogOut 0.15s ease-in forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&--compact {
|
||||
.BadgeModal__body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.BadgeModal__dialog {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
|
||||
.grant-intro {
|
||||
font-size: 14px;
|
||||
margin: 0 0 16px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
&__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;
|
||||
|
||||
.required {
|
||||
color: var(--error-text, #d24b4e);
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
> 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;
|
||||
}
|
||||
|
||||
.emoji-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--button-bg, #166de0);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
.emoticon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.emojisprite,
|
||||
.emoticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 12px 8px 0;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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: 160px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
25
webapp/src/components/badge_modal/emoji_picker.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
interface EmojiPickerOverlayProps {
|
||||
target: () => HTMLElement | null;
|
||||
container?: () => HTMLElement | null;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onEmojiClick: (emoji: any) => void;
|
||||
rightOffset?: number;
|
||||
defaultHorizontalPosition?: 'left' | 'right';
|
||||
onExited?: () => void;
|
||||
hideCustomEmojiButton?: boolean;
|
||||
}
|
||||
|
||||
const EmojiPickerOverlay: React.FC<EmojiPickerOverlayProps> = (props) => {
|
||||
const Overlay = (window as any).Components?.EmojiPickerOverlay;
|
||||
|
||||
if (!Overlay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Overlay {...props}/>;
|
||||
};
|
||||
|
||||
export default EmojiPickerOverlay;
|
||||
470
webapp/src/components/badge_modal/index.tsx
Normal file
@ -0,0 +1,470 @@
|
||||
import React, {useCallback, useEffect, useRef, 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 RenderEmoji from 'components/emoji/emoji';
|
||||
|
||||
import {isCreateBadgeModalVisible, getEditBadgeModalData, getEmojiMap} from 'selectors';
|
||||
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';
|
||||
import EmojiIcon from 'components/icons/emoji_icon';
|
||||
|
||||
import ConfirmDialog from 'components/confirm_dialog/confirm_dialog';
|
||||
|
||||
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();
|
||||
const createVisible = useSelector(isCreateBadgeModalVisible);
|
||||
const editData = useSelector(getEditBadgeModalData);
|
||||
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
|
||||
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
|
||||
const isOpen = createVisible || editData !== null;
|
||||
const isEditMode = editData !== null;
|
||||
|
||||
const [form, setForm] = useState<BadgeFormData>(emptyBadgeForm);
|
||||
const [newTypeForm, setNewTypeForm] = useState<TypeFormData>(emptyTypeForm);
|
||||
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
|
||||
const [showCreateType, setShowCreateType] = useState(false);
|
||||
const [canCreateType, setCanCreateType] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [confirmDeleteTypeId, setConfirmDeleteTypeId] = useState<string | null>(null);
|
||||
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [closing, setClosing] = 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,
|
||||
calculateRightOffSet,
|
||||
} = emojiData || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const fetchTypes = async () => {
|
||||
const client = new Client();
|
||||
|
vladimir.khablak
commented
может тогда лучше сделать client синглтоном прямо в файле api? Чтобы сразу экспортировать и не делать new Client когда нужен запрос? может тогда лучше сделать client синглтоном прямо в файле api? Чтобы сразу экспортировать и не делать 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);
|
||||
setForm((prev) => ({...prev, badgeType: String(defaultType ? defaultType.id : resp.types[0].id)}));
|
||||
}
|
||||
};
|
||||
fetchTypes();
|
||||
|
||||
if (isEditMode && editData) {
|
||||
setForm({
|
||||
name: editData.name,
|
||||
description: editData.description,
|
||||
image: editData.image,
|
||||
badgeType: String(editData.type),
|
||||
multiple: editData.multiple,
|
||||
});
|
||||
} else {
|
||||
setForm(emptyBadgeForm);
|
||||
}
|
||||
setShowCreateType(false);
|
||||
setNewTypeForm(emptyTypeForm);
|
||||
setError(null);
|
||||
setConfirmDelete(false);
|
||||
setConfirmDeleteTypeId(null);
|
||||
setTypeDropdownOpen(false);
|
||||
setShowEmojiPicker(false);
|
||||
setLoading(false);
|
||||
}, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const doClose = useCallback(() => {
|
||||
if (createVisible) {
|
||||
dispatch(closeCreateBadgeModal());
|
||||
}
|
||||
if (editData) {
|
||||
dispatch(closeEditBadgeModal());
|
||||
}
|
||||
setClosing(false);
|
||||
}, [dispatch, createVisible, editData]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(doClose, 150);
|
||||
}, [doClose]);
|
||||
|
||||
const handleTypeSelect = useCallback((val: string) => {
|
||||
if (val === NEW_TYPE_VALUE) {
|
||||
setShowCreateType(true);
|
||||
updateForm({badgeType: ''});
|
||||
} else {
|
||||
setShowCreateType(false);
|
||||
updateForm({badgeType: val});
|
||||
}
|
||||
setTypeDropdownOpen(false);
|
||||
setConfirmDeleteTypeId(null);
|
||||
}, [updateForm]);
|
||||
|
||||
const handleEmojiSelect = (emoji: any) => {
|
||||
if (emoji.short_name) {
|
||||
updateForm({image: emoji.short_name});
|
||||
} else if (emoji.name) {
|
||||
updateForm({image: emoji.name});
|
||||
}
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleDeleteType = useCallback(async (typeId: string) => {
|
||||
if (confirmDeleteTypeId !== typeId) {
|
||||
setConfirmDeleteTypeId(typeId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = new Client();
|
||||
|
vladimir.khablak
commented
идентично идентично
|
||||
await client.deleteType(typeId);
|
||||
const removeById = (t: BadgeTypeDefinition) => String(t.id) !== typeId;
|
||||
setTypes((prev) => prev.filter(removeById));
|
||||
if (form.badgeType === typeId) {
|
||||
updateForm({badgeType: ''});
|
||||
}
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
}
|
||||
setConfirmDeleteTypeId(null);
|
||||
}, [confirmDeleteTypeId, form.badgeType, updateForm, intl]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
let typeID = form.badgeType;
|
||||
if (showCreateType) {
|
||||
if (!newTypeForm.name.trim()) {
|
||||
setError(intl.formatMessage({id: 'badges.modal.error_type_name_required', defaultMessage: 'Введите название типа'}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const createdType = await client.createType({
|
||||
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);
|
||||
}
|
||||
if (!typeID) {
|
||||
setError(intl.formatMessage({id: 'badges.modal.error_type_required', defaultMessage: 'Выберите тип достижения'}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const allBadges = await client.getAllBadges();
|
||||
const trimmedName = form.name.trim().toLowerCase();
|
||||
const duplicate = allBadges.find(
|
||||
(b) => b.name.toLowerCase() === trimmedName &&
|
||||
String(b.type) === typeID &&
|
||||
(!isEditMode || !editData || b.id !== editData.id),
|
||||
);
|
||||
if (!emojiMap.has(form.image)) {
|
||||
setError(intl.formatMessage({id: 'badges.modal.error_not_found_emoji', defaultMessage: 'Этот эмодзи не найден'}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (duplicate) {
|
||||
setError(intl.formatMessage({id: 'badges.modal.error_duplicate_name', defaultMessage: 'Достижение в данном типе с таким названием уже существует'}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (isEditMode && editData) {
|
||||
await client.updateBadge({
|
||||
id: String(editData.id),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
image: form.image.trim(),
|
||||
type: typeID,
|
||||
multiple: form.multiple,
|
||||
});
|
||||
} else {
|
||||
await client.createBadge({
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
image: form.image.trim(),
|
||||
type: typeID,
|
||||
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);
|
||||
}
|
||||
}, [form, showCreateType, newTypeForm, isEditMode, editData, handleClose, intl, channelId, dispatch, emojiMap]);
|
||||
|
||||
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();
|
||||
dispatch(setRHSView(RHS_STATE_ALL));
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [editData, confirmDelete, handleClose, intl, dispatch]);
|
||||
|
||||
if (!isOpen && !closing) {
|
||||
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 (
|
||||
<div
|
||||
className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}
|
||||
ref={modalRef}
|
||||
>
|
||||
<div
|
||||
className='BadgeModal__backdrop'
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div
|
||||
className='BadgeModal__dialog'
|
||||
ref={dialogRef}
|
||||
>
|
||||
<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='Название'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm({name: e.target.value})}
|
||||
maxLength={20}
|
||||
placeholder={intl.formatMessage({id: 'badges.modal.field_name_placeholder', defaultMessage: 'Название достижения (макс. 20 символов)'})}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_description'
|
||||
defaultMessage='Описание'
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => updateForm({description: e.target.value})}
|
||||
maxLength={120}
|
||||
placeholder={intl.formatMessage({id: 'badges.modal.field_description_placeholder', defaultMessage: 'Описание достижения (макс. 120 символов)'})}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_image'
|
||||
defaultMessage='Эмодзи'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<div className='emoji-input'>
|
||||
<button
|
||||
type='button'
|
||||
className='emoji-input__icon'
|
||||
onClick={() => setShowEmojiPicker((prev) => !prev)}
|
||||
ref={emojiButtonRef}
|
||||
>
|
||||
<EmojiIcon/>
|
||||
</button>
|
||||
{form.image && (
|
||||
<RenderEmoji
|
||||
emojiName={form.image}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type='text'
|
||||
value={form.image}
|
||||
onChange={(e) => updateForm({image: e.target.value.trim()})}
|
||||
placeholder={intl.formatMessage({id: 'badges.modal.field_image_placeholder', defaultMessage: 'Название эмодзи (напр. star)'})}
|
||||
/>
|
||||
</div>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPickerOverlay
|
||||
target={() => emojiButtonRef?.current}
|
||||
container={() => modalRef.current}
|
||||
show={showEmojiPicker}
|
||||
onHide={() => setShowEmojiPicker(false)}
|
||||
onEmojiClick={handleEmojiSelect}
|
||||
rightOffset={calculateRightOffSet?.(emojiButtonRef?.current)}
|
||||
defaultHorizontalPosition='right'
|
||||
hideCustomEmojiButton={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_type'
|
||||
defaultMessage='Тип'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<TypeSelect
|
||||
types={types}
|
||||
badgeType={form.badgeType}
|
||||
showCreateType={showCreateType}
|
||||
canCreateType={canCreateType}
|
||||
typeDropdownOpen={typeDropdownOpen}
|
||||
confirmDeleteTypeId={confirmDeleteTypeId}
|
||||
onToggleDropdown={() => setTypeDropdownOpen(!typeDropdownOpen)}
|
||||
onSelect={handleTypeSelect}
|
||||
onDeleteType={handleDeleteType}
|
||||
onCancelDeleteType={() => setConfirmDeleteTypeId(null)}
|
||||
/>
|
||||
{showCreateType && (
|
||||
<InlineTypeForm
|
||||
form={newTypeForm}
|
||||
onChange={updateTypeForm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='checkbox-group'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='badgeMultiple'
|
||||
checked={form.multiple}
|
||||
onChange={(e) => updateForm({multiple: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor='badgeMultiple'>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_multiple'
|
||||
defaultMessage='Можно выдавать несколько раз'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <div className='error-message'>{error}</div>}
|
||||
{isEditMode && (
|
||||
<div className='delete-section'>
|
||||
<button
|
||||
className='btn btn--danger'
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_delete'
|
||||
defaultMessage='Удалить достижение'
|
||||
/>
|
||||
</button>
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.confirm_delete_badge'
|
||||
defaultMessage='Удалить достижение «{name}»?'
|
||||
values={{name: form.name || editData?.name}}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</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() || !form.image.trim()}
|
||||
>
|
||||
{loading ? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'}) : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeModal;
|
||||
94
webapp/src/components/badge_modal/inline_type_form.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
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='Название типа'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</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;
|
||||
110
webapp/src/components/badge_modal/type_select.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {BadgeTypeDefinition} from 'types/badges';
|
||||
import TrashIcon from 'components/icons/trash_icon';
|
||||
import ConfirmDialog from 'components/confirm_dialog/confirm_dialog';
|
||||
|
||||
const NEW_TYPE_VALUE = '__new__';
|
||||
|
||||
type Props = {
|
||||
types: BadgeTypeDefinition[];
|
||||
badgeType: string;
|
||||
showCreateType: boolean;
|
||||
canCreateType: boolean;
|
||||
typeDropdownOpen: boolean;
|
||||
confirmDeleteTypeId: string | null;
|
||||
onToggleDropdown: () => void;
|
||||
onSelect: (val: string) => void;
|
||||
onDeleteType: (typeId: string) => void;
|
||||
onCancelDeleteType: () => void;
|
||||
}
|
||||
|
||||
const TypeSelect: React.FC<Props> = ({
|
||||
types,
|
||||
badgeType,
|
||||
showCreateType,
|
||||
canCreateType,
|
||||
typeDropdownOpen,
|
||||
confirmDeleteTypeId,
|
||||
onToggleDropdown,
|
||||
onSelect,
|
||||
onDeleteType,
|
||||
onCancelDeleteType,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const selectedTypeName = types.find((t) => String(t.id) === badgeType)?.name ||
|
||||
|
vladimir.khablak
commented
мб в мемо? мб в мемо?
|
||||
intl.formatMessage({id: 'badges.modal.field_type_placeholder', defaultMessage: 'Выберите тип достижения'});
|
||||
const triggerLabel = showCreateType ? intl.formatMessage({id: 'badges.modal.create_new_type', defaultMessage: '+ Создать новый тип'}) : selectedTypeName;
|
||||
const confirmType = confirmDeleteTypeId ? types.find((t) => String(t.id) === confirmDeleteTypeId) : null;
|
||||
|
vladimir.khablak
commented
мб в мемо? мб в мемо?
|
||||
|
||||
return (
|
||||
<div className='type-select'>
|
||||
<button
|
||||
type='button'
|
||||
className='type-select__trigger'
|
||||
onClick={onToggleDropdown}
|
||||
>
|
||||
<span className='type-select__value'>{triggerLabel}</span>
|
||||
<span className='type-select__arrow'>{'\u25BE'}</span>
|
||||
</button>
|
||||
{typeDropdownOpen && (
|
||||
<div className='type-select__dropdown'>
|
||||
{types.map((t) => {
|
||||
const tid = String(t.id);
|
||||
const isEmpty = t.badge_count === 0;
|
||||
return (
|
||||
<div
|
||||
key={tid}
|
||||
className={'type-select__option' + (tid === badgeType ? ' type-select__option--selected' : '')}
|
||||
>
|
||||
<span
|
||||
className='type-select__option-name'
|
||||
onClick={() => onSelect(tid)}
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
{isEmpty && !t.is_default && (
|
||||
<button
|
||||
type='button'
|
||||
className='type-select__delete-btn'
|
||||
onClick={() => onDeleteType(tid)}
|
||||
title={intl.formatMessage({id: 'badges.modal.delete_type', defaultMessage: 'Удалить тип'})}
|
||||
>
|
||||
<TrashIcon/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canCreateType && (
|
||||
<div
|
||||
className='type-select__option type-select__option--create'
|
||||
onClick={() => onSelect(NEW_TYPE_VALUE)}
|
||||
>
|
||||
<span className='type-select__option-name'>
|
||||
{intl.formatMessage({id: 'badges.modal.create_new_type', defaultMessage: '+ Создать новый тип'})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{confirmType && (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => onDeleteType(String(confirmDeleteTypeId))}
|
||||
onCancel={onCancelDeleteType}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.confirm_delete_type'
|
||||
defaultMessage='Удалить тип «{name}»?'
|
||||
values={{name: confirmType.name}}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypeSelect;
|
||||
60
webapp/src/components/confirm_dialog/confirm_dialog.scss
Normal file
@ -0,0 +1,60 @@
|
||||
.ConfirmDialog {
|
||||
background: var(--center-channel-bg, #fff);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
|
||||
min-width: 240px;
|
||||
text-align: center;
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 11;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.btn--cancel {
|
||||
background: var(--center-channel-bg, #fff);
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: var(--error-text, #d24b4e);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--error-text, #d24b4e) 85%, #000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
webapp/src/components/confirm_dialog/confirm_dialog.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import './confirm_dialog.scss';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<Props> = ({children, onConfirm, onCancel}) => (
|
||||
<div
|
||||
className='ConfirmDialog__overlay'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='ConfirmDialog'>
|
||||
<p className='ConfirmDialog__text'>
|
||||
{children}
|
||||
</p>
|
||||
<div className='ConfirmDialog__actions'>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn--cancel'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_cancel'
|
||||
defaultMessage='Отмена'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn--danger'
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_confirm_delete'
|
||||
defaultMessage='Да, удалить'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ConfirmDialog;
|
||||
@ -1,6 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import React, {memo} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
@ -14,17 +14,22 @@ interface ComponentProps {
|
||||
emojiStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const RenderEmoji = ({emojiName, emojiStyle, size}: ComponentProps) => {
|
||||
const FALLBACK_EMOJI = 'question';
|
||||
|
||||
const RenderEmoji = ({emojiName, emojiStyle, size = 16}: ComponentProps) => {
|
||||
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
|
||||
|
||||
if (!emojiName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emojiFromMap = emojiMap.get(emojiName);
|
||||
let emojiFromMap = emojiMap.get(emojiName);
|
||||
if (!emojiFromMap) {
|
||||
emojiFromMap = emojiMap.get(FALLBACK_EMOJI);
|
||||
if (!emojiFromMap) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const emojiImageUrl = getEmojiImageUrl(emojiFromMap);
|
||||
|
||||
return (
|
||||
@ -46,10 +51,4 @@ const RenderEmoji = ({emojiName, emojiStyle, size}: ComponentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
RenderEmoji.defaultProps = {
|
||||
emoji: '',
|
||||
emojiStyle: {},
|
||||
size: 16,
|
||||
};
|
||||
|
||||
export default React.memo(RenderEmoji);
|
||||
export default memo(RenderEmoji);
|
||||
292
webapp/src/components/grant_modal/index.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
import React, {useCallback, useEffect, useRef, 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 {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {closeGrantModal} from 'actions/actions';
|
||||
import {getGrantModalData} from 'selectors';
|
||||
import {AllBadgesBadge} from 'types/badges';
|
||||
import Client from 'client/api';
|
||||
import {getServerErrorId, getUserDisplayName} from 'utils/helpers';
|
||||
import CloseIcon from 'components/icons/close_icon';
|
||||
import RenderEmoji from 'components/emoji/emoji';
|
||||
|
||||
type GrantFormData = {
|
||||
badgeId: string;
|
||||
userId: string;
|
||||
userDisplayName: string;
|
||||
reason: string;
|
||||
notifyHere: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: GrantFormData = {
|
||||
badgeId: '',
|
||||
userId: '',
|
||||
userDisplayName: '',
|
||||
reason: '',
|
||||
notifyHere: false,
|
||||
};
|
||||
|
||||
const GrantModal: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const modalData = useSelector(getGrantModalData);
|
||||
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
|
||||
const isOpen = modalData !== null;
|
||||
const hasFixedUser = Boolean(modalData?.prefillUser);
|
||||
|
||||
const [form, setForm] = useState<GrantFormData>(emptyForm);
|
||||
const [badges, setBadges] = useState<AllBadgesBadge[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
// Выбор достижения
|
||||
const [badgeDropdownOpen, setBadgeDropdownOpen] = useState(false);
|
||||
const badgeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateForm = useCallback((updates: Partial<GrantFormData>) => {
|
||||
setForm((prev) => ({...prev, ...updates}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Всегда очищаем форму при открытии
|
||||
setForm(emptyForm);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setBadgeDropdownOpen(false);
|
||||
|
||||
const fetchBadges = async () => {
|
||||
const client = new Client();
|
||||
const allBadges = await client.getAllBadges();
|
||||
setBadges(allBadges);
|
||||
};
|
||||
fetchBadges();
|
||||
|
||||
// Prefill достижения, если передан
|
||||
if (modalData?.prefillBadgeId) {
|
||||
setForm((prev) => ({...prev, badgeId: modalData.prefillBadgeId || ''}));
|
||||
}
|
||||
|
||||
// Prefill пользователя, если передан
|
||||
if (modalData?.prefillUser) {
|
||||
Client4.getUserByUsername(modalData.prefillUser).then((user) => {
|
||||
|
vladimir.khablak
commented
получается что есть api а есть еще какое-то апи, на твое усмотрение можно запихнуть в api плагина получается что есть api а есть еще какое-то апи, на твое усмотрение можно запихнуть в api плагина
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
userId: user.id,
|
||||
userDisplayName: getUserDisplayName(user) || user.username,
|
||||
}));
|
||||
}).catch(() => {
|
||||
// Если пользователь не найден — игнорируем
|
||||
});
|
||||
}
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Закрытие выпадающих списков при клике снаружи
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (badgeDropdownRef.current && !badgeDropdownRef.current.contains(e.target as Node)) {
|
||||
setBadgeDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const doClose = useCallback(() => {
|
||||
dispatch(closeGrantModal());
|
||||
setClosing(false);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(doClose, 150);
|
||||
|
vladimir.khablak
commented
на твое усмотрение - вынести число в константу на твое усмотрение - вынести число в константу
|
||||
}, [doClose]);
|
||||
|
||||
const handleBadgeSelect = (badgeId: string) => {
|
||||
updateForm({badgeId});
|
||||
setBadgeDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
await client.grantBadge({
|
||||
badge_id: form.badgeId,
|
||||
user_id: form.userId,
|
||||
reason: form.reason.trim(),
|
||||
notify_here: form.notifyHere,
|
||||
channel_id: channelId,
|
||||
});
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form, channelId, handleClose, intl]);
|
||||
|
||||
if (!isOpen && !closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedBadge = badges.find((b) => String(b.id) === form.badgeId);
|
||||
|
||||
return (
|
||||
<div className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}>
|
||||
<div
|
||||
className='BadgeModal__backdrop'
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className='BadgeModal__dialog'>
|
||||
<div className='BadgeModal__header'>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='badges.grant.title'
|
||||
defaultMessage='Выдать достижение'
|
||||
/>
|
||||
</h4>
|
||||
<button
|
||||
className='close-btn'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<CloseIcon/>
|
||||
</button>
|
||||
</div>
|
||||
<div className='BadgeModal__body'>
|
||||
{hasFixedUser && form.userDisplayName && (
|
||||
<p className='grant-intro'>
|
||||
<FormattedMessage
|
||||
id='badges.grant.intro'
|
||||
defaultMessage='Выдать достижение пользователю @{username}'
|
||||
values={{username: modalData?.prefillUser || ''}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.grant.field_badge'
|
||||
defaultMessage='Достижение'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<div
|
||||
className='type-select'
|
||||
ref={badgeDropdownRef}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='type-select__trigger'
|
||||
onClick={() => setBadgeDropdownOpen(!badgeDropdownOpen)}
|
||||
>
|
||||
<span className='type-select__value'>
|
||||
{selectedBadge ? (
|
||||
<>
|
||||
<RenderEmoji
|
||||
emojiName={selectedBadge.image}
|
||||
size={16}
|
||||
/>
|
||||
{' '}{selectedBadge.name}
|
||||
</>
|
||||
) : intl.formatMessage({id: 'badges.grant.field_badge_placeholder', defaultMessage: 'Выберите достижение'})}
|
||||
</span>
|
||||
<span className='type-select__arrow'>{'▾'}</span>
|
||||
</button>
|
||||
{badgeDropdownOpen && (
|
||||
<div className='type-select__dropdown'>
|
||||
{badges.length === 0 && (
|
||||
<div className='type-select__option'>
|
||||
<FormattedMessage
|
||||
id='badges.grant.no_badges'
|
||||
defaultMessage='Нет доступных достижений'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{badges.map((badge) => (
|
||||
<div
|
||||
key={badge.id}
|
||||
className={'type-select__option' + (String(badge.id) === form.badgeId ? ' type-select__option--selected' : '')}
|
||||
onClick={() => handleBadgeSelect(String(badge.id))}
|
||||
>
|
||||
<span className='type-select__option-name'>
|
||||
<RenderEmoji
|
||||
emojiName={badge.image}
|
||||
size={16}
|
||||
/>
|
||||
{' '}{badge.name}
|
||||
</span>
|
||||
<span style={{opacity: 0.56, fontSize: '12px'}}>{badge.type_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.grant.field_reason'
|
||||
defaultMessage='Причина'
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
value={form.reason}
|
||||
onChange={(e) => updateForm({reason: e.target.value})}
|
||||
maxLength={200}
|
||||
placeholder={intl.formatMessage({id: 'badges.grant.field_reason_placeholder', defaultMessage: 'За что выдаётся достижение? (необязательно)'})}
|
||||
/>
|
||||
</div>
|
||||
<div className='checkbox-group'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='grantNotifyHere'
|
||||
checked={form.notifyHere}
|
||||
onChange={(e) => updateForm({notifyHere: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor='grantNotifyHere'>
|
||||
<FormattedMessage
|
||||
id='badges.grant.notify_here'
|
||||
defaultMessage='Уведомить в канале'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <div className='error-message'>{error}</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.badgeId || !form.userId}
|
||||
>
|
||||
{loading
|
||||
? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'})
|
||||
: intl.formatMessage({id: 'badges.grant.btn_grant', defaultMessage: 'Выдать'})
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GrantModal;
|
||||
29
webapp/src/components/icons/close_icon.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const CloseIcon: React.FC<Props> = ({size = 16}) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path
|
||||
stroke='none'
|
||||
d='M0 0h24v24H0z'
|
||||
fill='none'
|
||||
/>
|
||||
<path d='M18 6l-12 12'/>
|
||||
<path d='M6 6l12 12'/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default CloseIcon;
|
||||
31
webapp/src/components/icons/emoji_icon.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const EmojiIcon: React.FC<Props> = ({size = 20}) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path
|
||||
stroke='none'
|
||||
d='M0 0h24v24H0z'
|
||||
fill='none'
|
||||
/>
|
||||
<path d='M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0'/>
|
||||
<path d='M9 10l.01 0'/>
|
||||
<path d='M15 10l.01 0'/>
|
||||
<path d='M9.5 15a3.5 3.5 0 0 0 5 0'/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default EmojiIcon;
|
||||
29
webapp/src/components/icons/search_icon.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const SearchIcon: React.FC<Props> = ({size = 18}) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path
|
||||
stroke='none'
|
||||
d='M0 0h24v24H0z'
|
||||
fill='none'
|
||||
/>
|
||||
<path d='M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0'/>
|
||||
<path d='M21 21l-6 -6'/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SearchIcon;
|
||||
27
webapp/src/components/icons/trash_icon.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const TrashIcon: React.FC<Props> = ({size = 16}) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path d='M4 7l16 0'/>
|
||||
<path d='M10 11l0 6'/>
|
||||
<path d='M14 11l0 6'/>
|
||||
<path d='M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12'/>
|
||||
<path d='M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3'/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default TrashIcon;
|
||||
@ -3,4 +3,140 @@
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
|
||||
&--loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&__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 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__emptyDescription {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.72);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 2px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
&__tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
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;
|
||||
}
|
||||
|
||||
&__filterTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
&__createButton {
|
||||
background: var(--button-bg, #166de0);
|
||||
color: var(--button-color, #fff);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 7px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,107 +1,154 @@
|
||||
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';
|
||||
import {EmojiIndicesByAlias} from 'utils/emoji';
|
||||
|
||||
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 BackButton from 'components/back_button/back_button';
|
||||
|
||||
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;
|
||||
getCustomEmojisByName: (names: string[]) => void;
|
||||
openCreateBadgeModal: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
|
vladimir.khablak
commented
идентично идентично
|
||||
client.getAllBadges().then((data) => {
|
||||
setBadges(data);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
onBadgeClick = (badge: AllBadgesBadge) => {
|
||||
this.props.actions.setRHSBadge(badge.id);
|
||||
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.loading) {
|
||||
return (<div className='AllBadges'>
|
||||
<FormattedMessage
|
||||
id='badges.loading'
|
||||
defaultMessage='Загрузка...'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
if (!this.state.badges || this.state.badges.length === 0) {
|
||||
return (<div className='AllBadges'>
|
||||
<FormattedMessage
|
||||
id='badges.no_badges_yet'
|
||||
defaultMessage='Значков пока нет.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
const content = this.state.badges.map((badge) => {
|
||||
return (
|
||||
<AllBadgesRow
|
||||
key={badge.id}
|
||||
badge={badge}
|
||||
onClick={this.onBadgeClick}
|
||||
/>
|
||||
);
|
||||
const toLoad = names.filter((v) => !EmojiIndicesByAlias.has(v));
|
||||
actions.getCustomEmojisByName(toLoad);
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
fetchBadges();
|
||||
}, [fetchBadges]);
|
||||
|
||||
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'>
|
||||
<div><b>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.all_badges'
|
||||
defaultMessage='Все значки'
|
||||
/>
|
||||
</b></div>
|
||||
<RHSScrollbars>{content}</RHSScrollbars>
|
||||
<div className='AllBadges__loadingWrap'>
|
||||
<div className='spinner'/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = !isFiltered && (!badges || badges.length === 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFiltered && (
|
||||
<div className='AllBadges__header'>
|
||||
<div className='AllBadges__backHeader'>
|
||||
<BackButton
|
||||
targetView={RHS_STATE_TYPES}
|
||||
onNavigate={actions.setRHSView}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.back_to_types'
|
||||
defaultMessage='Назад к типам'
|
||||
/>
|
||||
</BackButton>
|
||||
<span className='AllBadges__filterTitle'>{filterTypeName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<div className='AllBadges__emptyContent'>
|
||||
<div className='AllBadges__emptyTitle'>
|
||||
<FormattedMessage
|
||||
id='badges.empty.title'
|
||||
defaultMessage='Достижений пока нет'
|
||||
/>
|
||||
</div>
|
||||
<div className='AllBadges__emptyDescription'>
|
||||
<FormattedMessage
|
||||
id='badges.empty.description'
|
||||
defaultMessage='Создайте первое достижение, чтобы отмечать заслуги участников команды.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!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={onBadgeClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllBadges;
|
||||
|
||||
@ -1,25 +1,59 @@
|
||||
.AllBadgesRow {
|
||||
display: flex;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
margin-bottom: 3px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badge-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.granted-by {
|
||||
font-size: 10px;
|
||||
}
|
||||
.badge-type {
|
||||
font-size: 10px;
|
||||
}
|
||||
.badge-descrition {
|
||||
|
||||
.badge-description {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
margin-top: 2px;
|
||||
word-break: break-word;
|
||||
|
||||
p {
|
||||
margin: 0px
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-label {
|
||||
font-weight: 400;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {AllBadgesBadge} from '../../types/badges';
|
||||
import BadgeImage from '../utils/badge_image';
|
||||
import BadgeImage from '../badge_image/badge_image';
|
||||
import {markdown} from 'utils/markdown';
|
||||
|
||||
import './all_badges_row.scss';
|
||||
@ -43,29 +43,46 @@ function getGrantedText(badge: AllBadgesBadge): React.ReactNode {
|
||||
|
||||
const AllBadgesRow: React.FC<Props> = ({badge, onClick}: Props) => {
|
||||
return (
|
||||
<div className='AllBadgesRow'>
|
||||
<a
|
||||
className='badge-icon'
|
||||
<div
|
||||
className='AllBadgesRow'
|
||||
onClick={() => onClick(badge)}
|
||||
>
|
||||
<span>
|
||||
<span className='badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={32}
|
||||
size={36}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<div>
|
||||
<div className='badge-name'>{badge.name}</div>
|
||||
<div className='badge-description'>{markdown(badge.description)}</div>
|
||||
<div className='badge-type'>
|
||||
<div className='badge-text'>
|
||||
<div className='badge-name'>
|
||||
<span className='badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.name'
|
||||
defaultMessage='Название:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.name}
|
||||
</div>
|
||||
<div className='badge-description'>
|
||||
<span className='badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.description'
|
||||
defaultMessage='Описание:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.description ? markdown(badge.description) : '-'}
|
||||
</div>
|
||||
<div className='badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.type'
|
||||
defaultMessage='Тип: {typeName}'
|
||||
values={{typeName: badge.type_name}}
|
||||
/>
|
||||
{' · '}
|
||||
{getGrantedText(badge)}
|
||||
</div>
|
||||
<div className='granted-by'>{getGrantedText(badge)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
73
webapp/src/components/rhs/all_types.scss
Normal file
@ -0,0 +1,73 @@
|
||||
.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;
|
||||
margin-bottom: 7px;
|
||||
|
||||
&: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
@ -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
@ -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;
|
||||
}
|
||||
}
|
||||
118
webapp/src/components/rhs/all_types_row.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import React, {useState} from 'react';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {BadgeTypeDefinition} from '../../types/badges';
|
||||
import ConfirmDialog from '../confirm_dialog/confirm_dialog';
|
||||
|
||||
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.label.created_by'
|
||||
defaultMessage='Создал: {username}'
|
||||
values={{username: badgeType.created_by_username || badgeType.created_by}}
|
||||
/>
|
||||
{' · '}
|
||||
<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()}
|
||||
>
|
||||
<button
|
||||
className='AllTypesRow__btn AllTypesRow__btn--edit'
|
||||
onClick={() => onEdit(badgeType)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.edit_badge'
|
||||
defaultMessage='Редактировать'
|
||||
/>
|
||||
</button>
|
||||
{!badgeType.is_default && (
|
||||
<button
|
||||
className='AllTypesRow__btn AllTypesRow__btn--danger'
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.delete_type'
|
||||
defaultMessage='Удалить'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => onDelete(badgeType)}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.confirm_delete_type'
|
||||
defaultMessage='Удалить тип «{name}»?'
|
||||
values={{name: badgeType.name}}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllTypesRow;
|
||||
@ -3,26 +3,101 @@
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
|
||||
&--loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
align-items: flex-start;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
|
||||
.badge-icon {
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 100px;
|
||||
}
|
||||
|
||||
.badge-label {
|
||||
font-weight: 400;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding-right: 7px;
|
||||
color: var(--center-channel-text);
|
||||
}
|
||||
.created-by {
|
||||
font-size: 10px;
|
||||
}
|
||||
.badge-descrition {
|
||||
|
||||
.badge-description {
|
||||
font-size: 14px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
margin-top: 4px;
|
||||
word-break: break-word;
|
||||
|
||||
p {
|
||||
margin: 0px
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.badge-type {
|
||||
font-size: 10px;
|
||||
|
||||
.badge-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__backHeader {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__editButton {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--button-bg, #166de0);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-owners {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,17 +2,19 @@ import React from 'react';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||
import {EmojiIndicesByAlias} from 'utils/emoji';
|
||||
|
||||
import {BadgeDetails, BadgeID} from '../../types/badges';
|
||||
import Client from '../../client/api';
|
||||
|
||||
import {RHSState} from '../../types/general';
|
||||
import {RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
|
||||
import BadgeImage from '../utils/badge_image';
|
||||
import {IMAGE_TYPE_EMOJI, RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
|
||||
import BadgeImage from '../badge_image/badge_image';
|
||||
|
||||
import {markdown} from 'utils/markdown';
|
||||
|
||||
import BackButton from '../../components/back_button/back_button';
|
||||
|
||||
import RHSScrollbars from './rhs_scrollbars';
|
||||
import UserRow from './user_row';
|
||||
|
||||
@ -21,10 +23,12 @@ import './badge_details.scss';
|
||||
type Props = {
|
||||
badgeID: BadgeID | null;
|
||||
currentUserID: string;
|
||||
prevView: RHSState;
|
||||
actions: {
|
||||
setRHSView: (view: RHSState) => void;
|
||||
setRHSUser: (user: string | null) => void;
|
||||
getCustomEmojiByName: (names: string) => void;
|
||||
openEditBadgeModal: (badge: BadgeDetails) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@ -54,8 +58,8 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (this.state.badge !== prevState.badge && this.state.badge && !systemEmojis.has(this.state.badge.name)) {
|
||||
this.props.actions.getCustomEmojiByName(this.state.badge.name);
|
||||
if (this.state.badge !== prevState.badge && this.state.badge && this.state.badge.image_type === IMAGE_TYPE_EMOJI && !EmojiIndicesByAlias.has(this.state.badge.image)) {
|
||||
this.props.actions.getCustomEmojiByName(this.state.badge.image);
|
||||
}
|
||||
|
||||
if (this.props.badgeID === prevProps.badgeID) {
|
||||
@ -92,17 +96,14 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.badge_not_found'
|
||||
defaultMessage='Значок не найден.'
|
||||
defaultMessage='Достижение не найдено.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.loading'
|
||||
defaultMessage='Загрузка...'
|
||||
/>
|
||||
return (<div className='BadgeDetails BadgeDetails--loading'>
|
||||
<div className='spinner'/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@ -110,7 +111,7 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.badge_not_found'
|
||||
defaultMessage='Значок не найден.'
|
||||
defaultMessage='Достижение не найдено.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
@ -126,30 +127,52 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
});
|
||||
return (
|
||||
<div className='BadgeDetails'>
|
||||
<div><b>
|
||||
<div className='BadgeDetails__backHeader'>
|
||||
<BackButton
|
||||
targetView={this.props.prevView}
|
||||
onNavigate={this.props.actions.setRHSView}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.badge_details'
|
||||
defaultMessage='Детали значка'
|
||||
id='badges.rhs.back_to_achievements'
|
||||
defaultMessage='Назад к достижениям'
|
||||
/>
|
||||
</b></div>
|
||||
</BackButton>
|
||||
</div>
|
||||
<div className='badge-info'>
|
||||
<span className='badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={32}
|
||||
size={48}
|
||||
/>
|
||||
</span>
|
||||
<div className='badge-text'>
|
||||
<div className='badge-name'>{badge.name}</div>
|
||||
<div className='badge-description'>{markdown(badge.description)}</div>
|
||||
<div className='badge-type'>
|
||||
<div className='badge-name'>
|
||||
<span className='badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.name'
|
||||
defaultMessage='Название:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.name}
|
||||
</div>
|
||||
<div className='badge-description'>
|
||||
<span className='badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.description'
|
||||
defaultMessage='Описание:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.description ? markdown(badge.description) : '—'}
|
||||
</div>
|
||||
<div className='badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.type'
|
||||
defaultMessage='Тип: {typeName}'
|
||||
values={{typeName: badge.type_name}}
|
||||
/>
|
||||
</div>
|
||||
<div className='created-by'>
|
||||
{' · '}
|
||||
<FormattedMessage
|
||||
id='badges.label.created_by'
|
||||
defaultMessage='Создал: {username}'
|
||||
@ -157,14 +180,36 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{badge.can_edit && (
|
||||
<button
|
||||
className='BadgeDetails__editButton'
|
||||
onClick={() => this.props.actions.openEditBadgeModal(badge)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.edit_badge'
|
||||
defaultMessage='Редактировать'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div><b>
|
||||
{badge.owners.length > 0 ? (
|
||||
<>
|
||||
<div className='section-title'>
|
||||
<FormattedMessage
|
||||
id='badges.granted_to'
|
||||
defaultMessage='Выдан:'
|
||||
/>
|
||||
</b></div>
|
||||
</div>
|
||||
<RHSScrollbars>{content}</RHSScrollbars>
|
||||
</>
|
||||
) : (
|
||||
<div className='empty-owners'>
|
||||
<FormattedMessage
|
||||
id='badges.not_granted_yet'
|
||||
defaultMessage='Ещё никому не выдан'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,137 @@ 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, getPrevRHSView, 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 {setRHSBadge, setRHSUser, setRHSView} from 'actions/actions';
|
||||
import {BadgeID} from 'types/badges';
|
||||
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 prevView = useSelector(getPrevRHSView);
|
||||
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);
|
||||
const [canCreateBadge, setCanCreateBadge] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const client = new Client();
|
||||
client.getTypes().then((resp) => {
|
||||
setCanEditType(resp.can_edit_type);
|
||||
setCanCreateType(resp.can_create_type);
|
||||
setCanCreateBadge(resp.types.length > 0 || resp.can_create_type);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showTabs = currentView === RHS_STATE_MY || 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_MY ? ' AllBadges__tab--active' : '')}
|
||||
onClick={() => dispatch(setRHSView(RHS_STATE_MY))}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.my_badges'
|
||||
defaultMessage='Мои'
|
||||
/>
|
||||
</button>
|
||||
<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 && canCreateBadge && (
|
||||
<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 = () => {
|
||||
|
vladimir.khablak
commented
выглядит как отдельный компонент или useMemo выглядит как отдельный компонент или useMemo
|
||||
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)),
|
||||
openCreateBadgeModal: () => dispatch(openCreateBadgeModal()),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_ALL:
|
||||
return (
|
||||
<AllBadges
|
||||
@ -39,6 +151,7 @@ const RHS: React.FC = () => {
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
openCreateBadgeModal: () => dispatch(openCreateBadgeModal()),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -47,10 +160,12 @@ const RHS: React.FC = () => {
|
||||
<BadgeDetailsComponent
|
||||
badgeID={currentBadge}
|
||||
currentUserID={myUser.id}
|
||||
prevView={prevView}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSUser: (user: string | null) => dispatch(setRHSUser(user)),
|
||||
getCustomEmojiByName: (names: string) => dispatch(getCustomEmojiByName(names)),
|
||||
openEditBadgeModal: (badge: BadgeDetails) => dispatch(openEditBadgeModal(badge)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -59,6 +174,7 @@ const RHS: React.FC = () => {
|
||||
<UserBadges
|
||||
user={currentUser}
|
||||
isCurrentUser={false}
|
||||
currentUserID={myUser.id}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
@ -72,6 +188,7 @@ const RHS: React.FC = () => {
|
||||
<UserBadges
|
||||
user={myUser}
|
||||
isCurrentUser={true}
|
||||
currentUserID={myUser.id}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
@ -82,4 +199,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;
|
||||
|
||||
@ -26,7 +26,7 @@ function renderThumbVertical(props: any) {
|
||||
/>);
|
||||
}
|
||||
|
||||
const RHSScrollbars = ({children}: {children: React.ReactNode[]}) => {
|
||||
const RHSScrollbars = ({children}: {children: React.ReactNode}) => {
|
||||
return (
|
||||
<Scrollbars
|
||||
autoHide={true}
|
||||
|
||||
@ -1,28 +1,110 @@
|
||||
.UserBadgesRow {
|
||||
display: flex;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
margin-bottom: 3px;
|
||||
.user-badge-icon {
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
.user-badge-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-badge-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-badge-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-badge-description {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
margin-top: 2px;
|
||||
word-break: break-word;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.user-badge-label {
|
||||
font-weight: 400;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-badge-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.user-badge-reason {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.user-badge-set-status {
|
||||
margin-top: 4px;
|
||||
|
||||
a {
|
||||
font-size: 12px;
|
||||
color: var(--button-bg, #166de0);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-badge-revoke {
|
||||
margin-top: 4px;
|
||||
|
||||
a {
|
||||
font-size: 12px;
|
||||
color: var(--error-text, #d24b4e);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 12px;
|
||||
color: var(--error-text, #d24b4e);
|
||||
font-weight: 600;
|
||||
}
|
||||
.user-badge-granted-by {
|
||||
font-size: 10px;
|
||||
|
||||
&__yes {
|
||||
font-weight: 600;
|
||||
}
|
||||
.user-badge-granted-at {
|
||||
font-size: 10px;
|
||||
}
|
||||
.user-badge-descrition {
|
||||
p {
|
||||
margin: 0px
|
||||
|
||||
&__no {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56) !important;
|
||||
}
|
||||
}
|
||||
.user-badge-type {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +1,52 @@
|
||||
import React from 'react';
|
||||
import React, {useState} from 'react';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import Client4 from 'mattermost-redux/client/client4';
|
||||
|
||||
import {UserBadge} from '../../types/badges';
|
||||
import BadgeImage from '../utils/badge_image';
|
||||
import BadgeImage from '../badge_image/badge_image';
|
||||
import {markdown} from 'utils/markdown';
|
||||
import Client from '../../client/api';
|
||||
import ConfirmDialog from '../confirm_dialog/confirm_dialog';
|
||||
|
||||
import './user_badge_row.scss';
|
||||
|
||||
type Props = {
|
||||
badge: UserBadge;
|
||||
isCurrentUser: boolean;
|
||||
currentUserID: string;
|
||||
onClick: (badge: UserBadge) => void;
|
||||
onRevoke?: (badge: UserBadge) => void;
|
||||
}
|
||||
|
||||
const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) => {
|
||||
const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser, currentUserID, onRevoke}: Props) => {
|
||||
const intl = useIntl();
|
||||
const time = new Date(badge.time);
|
||||
const [confirmingRevoke, setConfirmingRevoke] = useState(false);
|
||||
|
||||
const canRevoke = badge.granted_by === currentUserID;
|
||||
|
||||
const handleRevoke = async () => {
|
||||
try {
|
||||
const client = new Client();
|
||||
await client.revokeOwnership({
|
||||
badge_id: String(badge.id),
|
||||
user_id: badge.user,
|
||||
time: String(badge.time),
|
||||
});
|
||||
onRevoke?.(badge);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setConfirmingRevoke(false);
|
||||
}
|
||||
};
|
||||
|
||||
let reason = null;
|
||||
if (badge.reason) {
|
||||
reason = (
|
||||
<div className='badge-user-reason'>
|
||||
<div className='user-badge-reason'>
|
||||
<FormattedMessage
|
||||
id='badges.label.reason'
|
||||
defaultMessage='Причина: {reason}'
|
||||
@ -35,7 +60,8 @@ const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) =
|
||||
setStatus = (
|
||||
<div className='user-badge-set-status'>
|
||||
<a
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const c = new Client4();
|
||||
c.updateCustomStatus({emoji: badge.image, text: badge.name});
|
||||
}}
|
||||
@ -48,42 +74,98 @@ const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) =
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let revokeAction = null;
|
||||
if (canRevoke && onRevoke) {
|
||||
revokeAction = (
|
||||
<div className='user-badge-revoke'>
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingRevoke(true);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.revoke.btn'
|
||||
defaultMessage='Снять достижение'
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
if (confirmingRevoke) {
|
||||
revokeAction = (
|
||||
<>
|
||||
{revokeAction}
|
||||
<ConfirmDialog
|
||||
onConfirm={handleRevoke}
|
||||
onCancel={() => setConfirmingRevoke(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.revoke.confirm'
|
||||
defaultMessage='Снять достижение?'
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='UserBadgesRow'>
|
||||
<a onClick={() => onClick(badge)}>
|
||||
<div
|
||||
className='UserBadgesRow'
|
||||
onClick={() => onClick(badge)}
|
||||
>
|
||||
<span className='user-badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={32}
|
||||
size={36}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<div className='user-badge-text'>
|
||||
<div className='user-badge-name'>{badge.name}</div>
|
||||
<div className='user-badge-description'>{markdown(badge.description)}</div>
|
||||
{reason}
|
||||
<div className='user-badge-type'>
|
||||
<div className='user-badge-name'>
|
||||
<span className='user-badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.name'
|
||||
defaultMessage='Название:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.name}
|
||||
</div>
|
||||
<div className='user-badge-description'>
|
||||
<span className='user-badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.description'
|
||||
defaultMessage='Описание:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.description ? markdown(badge.description) : '—'}
|
||||
</div>
|
||||
<div className='user-badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.type'
|
||||
defaultMessage='Тип: {typeName}'
|
||||
values={{typeName: badge.type_name}}
|
||||
/>
|
||||
</div>
|
||||
<div className='user-badge-granted-by'>
|
||||
<div className='user-badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_by'
|
||||
defaultMessage='Выдал: {username}'
|
||||
values={{username: badge.granted_by_name}}
|
||||
/>
|
||||
</div>
|
||||
<div className='user-badge-granted-at'>
|
||||
<div className='user-badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_at'
|
||||
defaultMessage='Выдан: {date}'
|
||||
values={{date: time.toDateString()}}
|
||||
values={{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}}
|
||||
/>
|
||||
</div>
|
||||
{reason}
|
||||
{setStatus}
|
||||
{revokeAction}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,4 +3,20 @@
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
|
||||
&--loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,8 @@ import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {UserProfile} from 'mattermost-redux/types/users';
|
||||
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||
|
||||
import {EmojiIndicesByAlias} from 'utils/emoji';
|
||||
|
||||
import {BadgeID, UserBadge} from '../../types/badges';
|
||||
import Client from '../../client/api';
|
||||
@ -18,6 +19,7 @@ import './user_badges.scss';
|
||||
|
||||
type Props = {
|
||||
isCurrentUser: boolean;
|
||||
currentUserID: string;
|
||||
user: UserProfile | null;
|
||||
actions: {
|
||||
setRHSView: (view: RHSState) => void;
|
||||
@ -58,7 +60,7 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
names.push(badge.image);
|
||||
}
|
||||
});
|
||||
const toLoad = names.filter((v) => !systemEmojis.has(v));
|
||||
const toLoad = names.filter((v) => !EmojiIndicesByAlias.has(v));
|
||||
this.props.actions.getCustomEmojisByName(toLoad);
|
||||
}
|
||||
if (this.props.user?.id === prevProps.user?.id) {
|
||||
@ -84,6 +86,17 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||
}
|
||||
|
||||
onRevoke = () => {
|
||||
if (!this.props.user) {
|
||||
return;
|
||||
}
|
||||
const c = new Client();
|
||||
|
vladimir.khablak
commented
пупупуууу пупупуууу
|
||||
this.setState({loading: true});
|
||||
c.getUserBadges(this.props.user.id).then((badges) => {
|
||||
this.setState({badges, loading: false});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.user) {
|
||||
return (<div>
|
||||
@ -95,11 +108,8 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
if (this.state.loading) {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.loading'
|
||||
defaultMessage='Загрузка...'
|
||||
/>
|
||||
return (<div className='UserBadges UserBadges--loading'>
|
||||
<div className='spinner'/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@ -107,7 +117,7 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.no_badges_yet'
|
||||
defaultMessage='Значков пока нет.'
|
||||
defaultMessage='Достижений пока нет.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
@ -116,9 +126,11 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
return (
|
||||
<UserBadgeRow
|
||||
isCurrentUser={this.props.isCurrentUser}
|
||||
currentUserID={this.props.currentUserID}
|
||||
key={badge.time}
|
||||
badge={badge}
|
||||
onClick={this.onBadgeClick}
|
||||
onRevoke={this.onRevoke}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -126,18 +138,18 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
const title = this.props.isCurrentUser ? (
|
||||
<FormattedMessage
|
||||
id='badges.rhs.my_badges'
|
||||
defaultMessage='Мои значки'
|
||||
defaultMessage='Мои достижения'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='badges.rhs.user_badges'
|
||||
defaultMessage='Значки @{username}'
|
||||
defaultMessage='Достижения @{username}'
|
||||
values={{username: this.props.user.username}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className='UserBadges'>
|
||||
<div><b>{title}</b></div>
|
||||
<div className='UserBadges__title'>{title}</div>
|
||||
<RHSScrollbars>{content}</RHSScrollbars>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,13 +1,32 @@
|
||||
.UserRow {
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
margin-bottom: 3px;
|
||||
border-radius: 6px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
.badge-user-username {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-user-granted-at {
|
||||
font-size: 10px;
|
||||
|
||||
a {
|
||||
color: var(--button-bg, #166de0);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.badge-user-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {UserProfile} from 'mattermost-redux/types/users';
|
||||
import {Ownership} from '../../types/badges';
|
||||
|
||||
import './user_row.scss';
|
||||
|
||||
type Props = {
|
||||
ownership: Ownership;
|
||||
onClick: (user: string) => void;
|
||||
@ -31,20 +32,24 @@ const UserBadgeRow: React.FC<Props> = ({ownership, onClick}: Props) => {
|
||||
|
||||
const time = new Date(ownership.time);
|
||||
return (
|
||||
<div className='UserRow'>
|
||||
<div className='badge-user-username'><a onClick={() => onClick(ownership.user)}>{`@${user.username}`}</a></div>
|
||||
<div className='badge-user-granted-by'>
|
||||
<div
|
||||
className='UserRow'
|
||||
onClick={() => onClick(ownership.user)}
|
||||
>
|
||||
<div className='badge-user-username'>
|
||||
<a>{`@${user.username}`}</a>
|
||||
</div>
|
||||
<div className='badge-user-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_by'
|
||||
defaultMessage='Выдал: {username}'
|
||||
values={{username: grantedByName}}
|
||||
/>
|
||||
</div>
|
||||
<div className='badge-user-granted-at'>
|
||||
{' · '}
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_at'
|
||||
defaultMessage='Выдан: {date}'
|
||||
values={{date: time.toDateString()}}
|
||||
values={{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
206
webapp/src/components/subscription_modal/index.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import React, {useCallback, useEffect, useRef, 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 {closeSubscriptionModal} from 'actions/actions';
|
||||
import {getSubscriptionModalData} from 'selectors';
|
||||
import {BadgeTypeDefinition} from 'types/badges';
|
||||
import Client from 'client/api';
|
||||
import {getServerErrorId} from 'utils/helpers';
|
||||
import CloseIcon from 'components/icons/close_icon';
|
||||
|
||||
const SubscriptionModal: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const modalData = useSelector(getSubscriptionModalData);
|
||||
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
|
||||
const isOpen = modalData !== null;
|
||||
const isDeleteMode = modalData?.mode === 'delete';
|
||||
|
||||
const [selectedTypeId, setSelectedTypeId] = useState('');
|
||||
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
|
||||
const typeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Закрытие дропдауна при клике снаружи
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (typeDropdownRef.current && !typeDropdownRef.current.contains(e.target as Node)) {
|
||||
setTypeDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const fetchTypes = async () => {
|
||||
const client = new Client();
|
||||
const subs = await client.getChannelSubscriptions(channelId);
|
||||
if (isDeleteMode) {
|
||||
setTypes(subs);
|
||||
} else {
|
||||
const resp = await client.getTypes();
|
||||
const subscribedIds = new Set(subs.map((s) => String(s.id)));
|
||||
setTypes(resp.types.filter((t) => !subscribedIds.has(String(t.id))));
|
||||
}
|
||||
};
|
||||
fetchTypes();
|
||||
setSelectedTypeId('');
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setTypeDropdownOpen(false);
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const doClose = useCallback(() => {
|
||||
dispatch(closeSubscriptionModal());
|
||||
setClosing(false);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(doClose, 150);
|
||||
}, [doClose]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!selectedTypeId) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
const req = {type_id: selectedTypeId, channel_id: channelId};
|
||||
if (isDeleteMode) {
|
||||
await client.deleteSubscription(req);
|
||||
} else {
|
||||
await client.createSubscription(req);
|
||||
}
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTypeId, channelId, isDeleteMode, handleClose, intl]);
|
||||
|
||||
if (!isOpen && !closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = isDeleteMode
|
||||
? intl.formatMessage({id: 'badges.subscription.title_delete', defaultMessage: 'Удалить подписку'})
|
||||
: intl.formatMessage({id: 'badges.subscription.title_create', defaultMessage: 'Добавить подписку'});
|
||||
const submitLabel = isDeleteMode
|
||||
? intl.formatMessage({id: 'badges.subscription.btn_delete', defaultMessage: 'Удалить'})
|
||||
: intl.formatMessage({id: 'badges.subscription.btn_create', defaultMessage: 'Добавить'});
|
||||
|
||||
const selectedType = types.find((t) => String(t.id) === selectedTypeId);
|
||||
|
||||
return (
|
||||
<div className={'BadgeModal BadgeModal--compact' + (closing ? ' BadgeModal--closing' : '')}>
|
||||
<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.subscription.field_type'
|
||||
defaultMessage='Тип достижений'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<div
|
||||
className='type-select'
|
||||
ref={typeDropdownRef}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='type-select__trigger'
|
||||
onClick={() => setTypeDropdownOpen(!typeDropdownOpen)}
|
||||
>
|
||||
<span className='type-select__value'>
|
||||
{selectedType
|
||||
? selectedType.name
|
||||
: intl.formatMessage({id: 'badges.subscription.field_type_placeholder', defaultMessage: 'Выберите тип достижений'})
|
||||
}
|
||||
</span>
|
||||
<span className='type-select__arrow'>{'▾'}</span>
|
||||
</button>
|
||||
{typeDropdownOpen && (
|
||||
<div className='type-select__dropdown'>
|
||||
{types.length === 0 && (
|
||||
<div className='type-select__option'>
|
||||
<FormattedMessage
|
||||
id='badges.subscription.no_types'
|
||||
defaultMessage='Нет доступных типов'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{types.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={'type-select__option' + (String(t.id) === selectedTypeId ? ' type-select__option--selected' : '')}
|
||||
onClick={() => {
|
||||
setSelectedTypeId(String(t.id));
|
||||
setTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className='type-select__option-name'>{t.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className='error-message'>{error}</div>}
|
||||
</div>
|
||||
<div className='BadgeModal__footer'>
|
||||
<button
|
||||
className='btn btn--cancel'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_cancel'
|
||||
defaultMessage='Отмена'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={isDeleteMode ? 'btn btn--danger' : 'btn btn--primary'}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !selectedTypeId}
|
||||
>
|
||||
{loading
|
||||
? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'})
|
||||
: submitLabel
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionModal;
|
||||
286
webapp/src/components/type_modal/index.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
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';
|
||||
import ConfirmDialog from 'components/confirm_dialog/confirm_dialog';
|
||||
|
||||
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 [closing, setClosing] = 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 doClose = useCallback(() => {
|
||||
if (createVisible) {
|
||||
dispatch(closeCreateTypeModal());
|
||||
}
|
||||
if (editData) {
|
||||
dispatch(closeEditTypeModal());
|
||||
}
|
||||
setClosing(false);
|
||||
}, [dispatch, createVisible, editData]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(doClose, 150);
|
||||
}, [doClose]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
|
vladimir.khablak
commented
опять клиент опять клиент
|
||||
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) {
|
||||
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();
|
||||
|
vladimir.khablak
commented
опять он опять он
|
||||
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]);
|
||||
|
||||
if (!isOpen && !closing) {
|
||||
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' + (closing ? ' BadgeModal--closing' : '')}>
|
||||
<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='Название'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</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='Пользователи, которые могут создавать достижения этого типа.'
|
||||
/>
|
||||
</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'>
|
||||
<button
|
||||
className='btn btn--danger'
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_delete_type'
|
||||
defaultMessage='Удалить тип'
|
||||
/>
|
||||
</button>
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.types.confirm_delete'
|
||||
defaultMessage='Удалить тип «{name}» и все его достижения?'
|
||||
values={{name: editData?.name}}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</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
@ -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) => !excluded.has(u.username) && !(u as UserProfile & {remote_id?: string}).remote_id));
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doSearch = useMemo(() => debounce(performSearch, 400), []);
|
||||
|
||||
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;
|
||||
156
webapp/src/components/user_multi_select/user_multi_select.scss
Normal file
@ -0,0 +1,156 @@
|
||||
.user-multi-select {
|
||||
position: relative;
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
min-height: 34px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
cursor: text;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--button-bg, #166de0);
|
||||
box-shadow: 0 0 0 1px var(--button-bg, #166de0);
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-top-color: var(--button-bg, #166de0);
|
||||
border-radius: 50%;
|
||||
animation: user-multi-select-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.1);
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__chip-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1 1 60px;
|
||||
min-width: 60px;
|
||||
padding: 2px 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
}
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
&__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__option-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__option-fullname {
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
font-style: italic;
|
||||
|
||||
&--loading {
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes user-multi-select-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
display: flex;
|
||||
align-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#showMoreButton {
|
||||
@ -23,6 +24,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.badge-stacked {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-stack-count {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -4px;
|
||||
background: var(--button-bg, #166de0);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 1px 3px;
|
||||
border-radius: 6px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#grantBadgeButton {
|
||||
margin-top: 4px;
|
||||
padding-left: 0;
|
||||
|
||||
@ -1,209 +1,175 @@
|
||||
import {UserProfile} from 'mattermost-redux/types/users';
|
||||
import React from 'react';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||
|
||||
import {BadgeID, UserBadge} from 'types/badges';
|
||||
import {getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
|
||||
|
||||
import {EmojiIndicesByAlias} from 'utils/emoji';
|
||||
|
||||
import {UserBadge} from 'types/badges';
|
||||
import Client from 'client/api';
|
||||
import BadgeImage from '../utils/badge_image';
|
||||
import TooltipWrapper from '../utils/tooltip_wrapper';
|
||||
import {RHSState} from 'types/general';
|
||||
import BadgeImage from '../badge_image/badge_image';
|
||||
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL, RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
|
||||
import {setRHSView, setRHSBadge, setRHSUser, openGrant} from '../../actions/actions';
|
||||
import {getShowRHS} from 'selectors';
|
||||
import {groupBadges} from 'components/utils/badge_list_utils';
|
||||
|
||||
import BadgeTooltip from './badge_tooltip';
|
||||
import TooltipWrapper from './tooltip_wrapper';
|
||||
import './badge_list.scss';
|
||||
|
||||
type Props = {
|
||||
intl: IntlShape;
|
||||
debug: GlobalState;
|
||||
user: UserProfile;
|
||||
currentUserID: string;
|
||||
openRHS: (() => void) | null;
|
||||
hide: () => void;
|
||||
status?: string;
|
||||
actions: {
|
||||
setRHSView: (view: RHSState) => Promise<void>;
|
||||
setRHSBadge: (id: BadgeID | null) => Promise<void>;
|
||||
setRHSUser: (id: string | null) => Promise<void>;
|
||||
openGrant: (user?: string, badge?: string) => Promise<void>;
|
||||
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
type State = {
|
||||
badges?: UserBadge[];
|
||||
loaded?: boolean;
|
||||
}
|
||||
|
||||
const MAX_BADGES = 7;
|
||||
const MAX_BADGES = 6;
|
||||
const BADGE_SIZE = 24;
|
||||
|
||||
class BadgeList extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
const BadgeList: React.FC<Props> = ({user, hide}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const currentUserID = useSelector(getCurrentUserId);
|
||||
const openRHS = useSelector(getShowRHS);
|
||||
const [badges, setBadges] = useState<UserBadge[]>();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
useEffect(() => {
|
||||
const c = new Client();
|
||||
|
vladimir.khablak
commented
client client
|
||||
c.getUserBadges(this.props.user.id).then((badges) => {
|
||||
this.setState({badges, loaded: true});
|
||||
c.getUserBadges(user.id).then((result) => {
|
||||
setBadges(result);
|
||||
setLoaded(true);
|
||||
});
|
||||
}
|
||||
}, [user.id]);
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (this.state.badges !== prevState.badges) {
|
||||
const nBadges = this.state.badges?.length || 0;
|
||||
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < toShow; i++) {
|
||||
const badge = this.state.badges![i];
|
||||
if (badge.image_type === IMAGE_TYPE_EMOJI) {
|
||||
names.push(badge.image);
|
||||
}
|
||||
}
|
||||
const toLoad = names.filter((v) => !systemEmojis.has(v));
|
||||
this.props.actions.getCustomEmojisByName(toLoad);
|
||||
}
|
||||
}
|
||||
const groups = useMemo(
|
||||
() => (badges ? groupBadges(badges) : []),
|
||||
[badges],
|
||||
);
|
||||
|
||||
onMoreClick = () => {
|
||||
if (!this.props.openRHS) {
|
||||
useEffect(() => {
|
||||
if (!badges) {
|
||||
return;
|
||||
}
|
||||
const toShow = groups.slice(0, MAX_BADGES);
|
||||
const names = toShow.
|
||||
|
vladimir.khablak
commented
как будто filter.map.filter можно заменить на reduce как будто filter.map.filter можно заменить на reduce
|
||||
filter(({badge}) => badge.image_type === IMAGE_TYPE_EMOJI).
|
||||
map(({badge}) => badge.image).
|
||||
filter((v) => !EmojiIndicesByAlias.has(v));
|
||||
if (names.length > 0) {
|
||||
dispatch(getCustomEmojisByName(names));
|
||||
}
|
||||
}, [badges, groups, dispatch]);
|
||||
|
||||
if (this.props.currentUserID === this.props.user.id) {
|
||||
this.props.actions.setRHSView(RHS_STATE_MY);
|
||||
this.props.openRHS();
|
||||
const handleMoreClick = useCallback(() => {
|
||||
if (!openRHS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.actions.setRHSUser(this.props.user.id);
|
||||
this.props.actions.setRHSView(RHS_STATE_OTHER);
|
||||
this.props.openRHS();
|
||||
this.props.hide();
|
||||
if (currentUserID === user.id) {
|
||||
dispatch(setRHSView(RHS_STATE_MY));
|
||||
} else {
|
||||
dispatch(setRHSUser(user.id));
|
||||
dispatch(setRHSView(RHS_STATE_OTHER));
|
||||
}
|
||||
openRHS();
|
||||
hide();
|
||||
}, [openRHS, currentUserID, user.id, dispatch, hide]);
|
||||
|
||||
onBadgeClick = (badge: UserBadge) => {
|
||||
if (!this.props.openRHS) {
|
||||
const handleBadgeClick = useCallback((badge: UserBadge) => {
|
||||
if (!openRHS) {
|
||||
return;
|
||||
}
|
||||
dispatch(setRHSBadge(badge.id));
|
||||
dispatch(setRHSView(RHS_STATE_DETAIL));
|
||||
openRHS();
|
||||
hide();
|
||||
}, [openRHS, dispatch, hide]);
|
||||
|
||||
this.props.actions.setRHSBadge(badge.id);
|
||||
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||
this.props.openRHS();
|
||||
this.props.hide();
|
||||
const handleGrantClick = useCallback(() => {
|
||||
dispatch(openGrant(user.username));
|
||||
hide();
|
||||
}, [dispatch, user.username, hide]);
|
||||
|
||||
if ((user as UserProfile & {remote_id?: string}).remote_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
onGrantClick = () => {
|
||||
this.props.actions.openGrant(this.props.user.username);
|
||||
this.props.hide();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {intl} = this.props;
|
||||
const nBadges = this.state.badges?.length || 0;
|
||||
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
|
||||
|
||||
const content: React.ReactNode[] = [];
|
||||
for (let i = 0; i < toShow; i++) {
|
||||
const badge = this.state.badges![i];
|
||||
const time = new Date(badge.time);
|
||||
let reason: string | null = null;
|
||||
if (badge.reason) {
|
||||
reason = intl.formatMessage(
|
||||
{id: 'badges.label.reason', defaultMessage: 'Причина: {reason}'},
|
||||
{reason: badge.reason},
|
||||
);
|
||||
}
|
||||
const grantedBy = intl.formatMessage(
|
||||
{id: 'badges.label.granted_by', defaultMessage: 'Выдал: {username}'},
|
||||
{username: badge.granted_by_name},
|
||||
);
|
||||
const grantedAt = intl.formatMessage(
|
||||
{id: 'badges.label.granted_at', defaultMessage: 'Выдан: {date}'},
|
||||
{date: time.toDateString()},
|
||||
);
|
||||
const tooltipLines = [
|
||||
badge.name,
|
||||
badge.description,
|
||||
reason,
|
||||
grantedBy,
|
||||
grantedAt,
|
||||
].filter(Boolean).join('\n');
|
||||
const badgeComponent = (
|
||||
<TooltipWrapper tooltipContent={tooltipLines}>
|
||||
<a onClick={() => this.onBadgeClick(badge)}>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={BADGE_SIZE}
|
||||
/>
|
||||
</a>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
content.push(badgeComponent);
|
||||
}
|
||||
let andMore: React.ReactNode = null;
|
||||
if (nBadges > MAX_BADGES) {
|
||||
const andMoreText = intl.formatMessage(
|
||||
{id: 'badges.and_more', defaultMessage: 'и ещё {count}. Нажмите, чтобы увидеть все.'},
|
||||
{count: nBadges - MAX_BADGES},
|
||||
);
|
||||
andMore = (
|
||||
<TooltipWrapper tooltipContent={andMoreText}>
|
||||
<button
|
||||
id='showMoreButton'
|
||||
onClick={this.onMoreClick}
|
||||
>
|
||||
<span className={'fa fa-angle-right'}/>
|
||||
</button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
const visibleGroups = groups.slice(0, MAX_BADGES);
|
||||
const maxWidth = (MAX_BADGES * BADGE_SIZE) + 30;
|
||||
let loading: React.ReactNode = null;
|
||||
if (!this.state.loaded) {
|
||||
loading = (
|
||||
|
||||
// Reserve enough height one row of badges and the "and more" button
|
||||
<div style={{height: BADGE_SIZE, minWidth: 66, maxWidth}}>
|
||||
<FormattedMessage
|
||||
id='badges.loading'
|
||||
defaultMessage='Загрузка...'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div id='badgePlugin'>
|
||||
<div><b>
|
||||
|
vladimir.khablak
commented
немного поплыли стили немного поплыли стили
|
||||
<FormattedMessage
|
||||
id='badges.popover.title'
|
||||
defaultMessage='Значки'
|
||||
defaultMessage='Достижения'
|
||||
/>
|
||||
</b></div>
|
||||
<div id='contentContainer'>
|
||||
{content}
|
||||
{andMore}
|
||||
{visibleGroups.map(({badge, count}) => (
|
||||
<TooltipWrapper
|
||||
key={badge.id}
|
||||
tooltipContent={
|
||||
<BadgeTooltip
|
||||
badge={badge}
|
||||
count={count}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<a onClick={() => handleBadgeClick(badge)}>
|
||||
<span className='badge-stacked'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={BADGE_SIZE}
|
||||
/>
|
||||
{count > 1 && (
|
||||
<span className='badge-stack-count'>
|
||||
{'×'}{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</TooltipWrapper>
|
||||
))}
|
||||
{groups.length > MAX_BADGES && (
|
||||
<TooltipWrapper
|
||||
tooltipContent={intl.formatMessage(
|
||||
{id: 'badges.and_more', defaultMessage: 'и ещё {count}. Нажмите, чтобы увидеть все.'},
|
||||
{count: groups.length - MAX_BADGES},
|
||||
)}
|
||||
>
|
||||
<button
|
||||
id='showMoreButton'
|
||||
onClick={handleMoreClick}
|
||||
>
|
||||
<span className={'fa fa-angle-right'}/>
|
||||
</button>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
</div>
|
||||
{loading}
|
||||
{!loaded && (
|
||||
<div style={{height: BADGE_SIZE, minWidth: 66, maxWidth}}>
|
||||
<div className='spinner'/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
id='grantBadgeButton'
|
||||
onClick={this.onGrantClick}
|
||||
onClick={handleGrantClick}
|
||||
>
|
||||
<span className={'fa fa-plus-circle'}/>
|
||||
<FormattedMessage
|
||||
id='badges.grant_badge'
|
||||
defaultMessage='Выдать значок'
|
||||
defaultMessage='Выдать достижение'
|
||||
/>
|
||||
</button>
|
||||
<hr className='divider divider--expanded'/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(BadgeList);
|
||||
export default BadgeList;
|
||||
|
||||
62
webapp/src/components/user_popover/badge_tooltip.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {UserBadge} from 'types/badges';
|
||||
|
||||
import {truncateText} from 'components/utils/badge_list_utils';
|
||||
|
||||
type Props = {
|
||||
badge: UserBadge;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const BadgeTooltip: React.FC<Props> = ({badge, count}) => {
|
||||
const intl = useIntl();
|
||||
const desc = badge.description ? truncateText(badge.description) : '—';
|
||||
|
||||
const nameRow = intl.formatMessage(
|
||||
{id: 'badges.label.name', defaultMessage: 'Название:'},
|
||||
) + ' ' + badge.name;
|
||||
|
||||
const descRow = intl.formatMessage(
|
||||
{id: 'badges.label.description', defaultMessage: 'Описание:'},
|
||||
) + ' ' + desc;
|
||||
|
||||
if (count > 1) {
|
||||
const countRow = intl.formatMessage(
|
||||
{id: 'badges.label.count', defaultMessage: 'Количество: {count}'},
|
||||
{count},
|
||||
);
|
||||
return <>{nameRow}{'\n'}{descRow}{'\n'}{countRow}</>;
|
||||
}
|
||||
|
||||
const time = new Date(badge.time);
|
||||
const grantedBy = intl.formatMessage(
|
||||
{id: 'badges.label.granted_by', defaultMessage: 'Выдал: {username}'},
|
||||
{username: badge.granted_by_name},
|
||||
);
|
||||
const grantedAt = intl.formatMessage(
|
||||
{id: 'badges.label.granted_at', defaultMessage: 'Выдан: {date}'},
|
||||
{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{nameRow}{'\n'}
|
||||
|
vladimir.khablak
commented
такой конструкции я еще не видел, жестка такой конструкции я еще не видел, жестка
kirill.moos
commented
Что тут происходит?) Согласен с Владмиром Что тут происходит?) Согласен с Владмиром
|
||||
{descRow}{'\n'}
|
||||
{badge.reason && (
|
||||
<>
|
||||
{intl.formatMessage(
|
||||
{id: 'badges.label.reason', defaultMessage: 'Причина: {reason}'},
|
||||
{reason: badge.reason},
|
||||
)}{'\n'}
|
||||
</>
|
||||
)}
|
||||
{grantedBy}{'\n'}
|
||||
{grantedAt}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeTooltip;
|
||||
@ -1,49 +1 @@
|
||||
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||
|
||||
import {getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
|
||||
|
||||
import {setRHSView, setRHSBadge, setRHSUser, openGrant} from '../../actions/actions';
|
||||
|
||||
import {getShowRHS} from 'selectors';
|
||||
import {RHSState} from 'types/general';
|
||||
import {BadgeID} from 'types/badges';
|
||||
|
||||
import BadgeList from './badge_list';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
return {
|
||||
openRHS: getShowRHS(state),
|
||||
currentUserID: getCurrentUserId(state),
|
||||
debug: state,
|
||||
};
|
||||
}
|
||||
|
||||
type Actions = {
|
||||
setRHSView: (view: RHSState) => Promise<void>;
|
||||
setRHSBadge: (id: BadgeID | null) => Promise<void>;
|
||||
setRHSUser: (id: string | null) => Promise<void>;
|
||||
openGrant: (user?: string, badge?: string) => Promise<void>;
|
||||
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators<ActionCreatorsMapObject, Actions>({
|
||||
setRHSView,
|
||||
setRHSBadge,
|
||||
setRHSUser,
|
||||
openGrant,
|
||||
getCustomEmojisByName,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BadgeList);
|
||||
export {default} from './badge_list';
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, {ReactNode, useState, useRef, useEffect} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
25
webapp/src/components/utils/badge_list_utils.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {BadgeID, UserBadge} from 'types/badges';
|
||||
|
||||
export type BadgeGroup = {
|
||||
badge: UserBadge;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const MAX_DESC_LENGTH = 40;
|
||||
|
||||
export function groupBadges(badges: UserBadge[]): BadgeGroup[] {
|
||||
const map = new Map<BadgeID, BadgeGroup>();
|
||||
for (const badge of badges) {
|
||||
const existing = map.get(badge.id);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
map.set(badge.id, {badge, count: 1});
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function truncateText(text: string): string {
|
||||
return text.length > MAX_DESC_LENGTH ? text.slice(0, MAX_DESC_LENGTH) + '...' : text;
|
||||
}
|
||||
@ -8,10 +8,21 @@ 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,
|
||||
prevRhsView: RHS_STATE_MY,
|
||||
rhsBadge: null,
|
||||
rhsUser: null,
|
||||
rhsTypeId: null,
|
||||
rhsTypeName: null,
|
||||
createBadgeModalVisible: false,
|
||||
editBadgeModalData: null,
|
||||
createTypeModalVisible: false,
|
||||
editTypeModalData: null,
|
||||
grantModalData: null,
|
||||
subscriptionModalData: null,
|
||||
};
|
||||
|
||||
@ -17,6 +17,10 @@ import {useSelector} from 'react-redux';
|
||||
import {openAddSubscription, openCreateBadge, openCreateType, openRemoveSubscription, setRHSView, setShowRHSAction} from 'actions/actions';
|
||||
|
||||
import RHSComponent from 'components/rhs';
|
||||
import BadgeModal from 'components/badge_modal';
|
||||
import TypeModal from 'components/type_modal';
|
||||
import GrantModal from 'components/grant_modal';
|
||||
import SubscriptionModal from 'components/subscription_modal';
|
||||
|
||||
import ChannelHeaderButton from 'components/channel_header_button';
|
||||
|
||||
@ -60,6 +64,11 @@ export default class Plugin {
|
||||
|
||||
registry.registerPopoverUserAttributesComponent(WrappedBadgeList);
|
||||
|
||||
registry.registerRootComponent(withIntl(BadgeModal));
|
||||
registry.registerRootComponent(withIntl(TypeModal));
|
||||
registry.registerRootComponent(withIntl(GrantModal));
|
||||
registry.registerRootComponent(withIntl(SubscriptionModal));
|
||||
|
||||
const locale = getCurrentUser(store.getState())?.locale || 'ru';
|
||||
const messages = getTranslations(locale);
|
||||
|
||||
|
||||
@ -23,6 +23,15 @@ function rhsView(state = RHS_STATE_MY, action: GenericAction) {
|
||||
}
|
||||
}
|
||||
|
||||
function prevRhsView(state = RHS_STATE_MY, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.RECEIVED_RHS_VIEW:
|
||||
return action.prevView || state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function rhsUser(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.RECEIVED_RHS_USER:
|
||||
@ -41,9 +50,102 @@ 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:
|
||||
return true;
|
||||
case ActionTypes.CLOSE_CREATE_BADGE_MODAL:
|
||||
return false;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function editBadgeModalData(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.OPEN_EDIT_BADGE_MODAL:
|
||||
return action.data;
|
||||
case ActionTypes.CLOSE_EDIT_BADGE_MODAL:
|
||||
return null;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function grantModalData(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.OPEN_GRANT_MODAL:
|
||||
return action.data || {};
|
||||
case ActionTypes.CLOSE_GRANT_MODAL:
|
||||
return null;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function subscriptionModalData(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.OPEN_SUBSCRIPTION_MODAL:
|
||||
return action.data;
|
||||
case ActionTypes.CLOSE_SUBSCRIPTION_MODAL:
|
||||
return null;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
showRHS,
|
||||
rhsView,
|
||||
prevRhsView,
|
||||
rhsUser,
|
||||
rhsBadge,
|
||||
rhsTypeId,
|
||||
rhsTypeName,
|
||||
createBadgeModalVisible,
|
||||
editBadgeModalData,
|
||||
createTypeModalVisible,
|
||||
editTypeModalData,
|
||||
grantModalData,
|
||||
subscriptionModalData,
|
||||
});
|
||||
|
||||
@ -30,6 +30,13 @@ export const getRHSView = createSelector(
|
||||
},
|
||||
);
|
||||
|
||||
export const getPrevRHSView = createSelector(
|
||||
|
vladimir.khablak
commented
разве первым аргументом не должна идти строка? типо 'getPrevRHSView' или что-то такое? разве первым аргументом не должна идти строка? типо 'getPrevRHSView' или что-то такое?
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.prevRhsView;
|
||||
},
|
||||
);
|
||||
|
||||
export const getRHSUser = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
@ -43,3 +50,59 @@ export const getRHSBadge = createSelector(
|
||||
return state.rhsBadge;
|
||||
},
|
||||
);
|
||||
|
||||
export const getRHSTypeId = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.rhsTypeId;
|
||||
},
|
||||
);
|
||||
|
||||
export const getRHSTypeName = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.rhsTypeName;
|
||||
},
|
||||
);
|
||||
|
||||
export const isCreateBadgeModalVisible = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.createBadgeModalVisible;
|
||||
},
|
||||
);
|
||||
|
||||
export const getEditBadgeModalData = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.editBadgeModalData;
|
||||
},
|
||||
);
|
||||
|
||||
export const isCreateTypeModalVisible = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.createTypeModalVisible;
|
||||
},
|
||||
);
|
||||
|
||||
export const getEditTypeModalData = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.editTypeModalData;
|
||||
},
|
||||
);
|
||||
|
||||
export const getGrantModalData = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.grantModalData;
|
||||
},
|
||||
);
|
||||
|
||||
export const getSubscriptionModalData = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.subscriptionModalData;
|
||||
},
|
||||
);
|
||||
|
||||
@ -29,6 +29,7 @@ export type BadgeDetails = Badge & {
|
||||
owners: OwnershipList;
|
||||
created_by_username: string;
|
||||
type_name: string;
|
||||
can_edit: boolean;
|
||||
}
|
||||
export type AllBadgesBadge = Badge & {
|
||||
granted: number;
|
||||
@ -42,4 +43,96 @@ export type BadgeTypeDefinition = {
|
||||
id: BadgeType;
|
||||
name: string;
|
||||
frame: string;
|
||||
created_by: string;
|
||||
created_by_username: string;
|
||||
can_grant: PermissionScheme;
|
||||
can_create: PermissionScheme;
|
||||
badge_count: number;
|
||||
is_default: boolean;
|
||||
allowlist_can_create: string;
|
||||
allowlist_can_grant: string;
|
||||
}
|
||||
|
||||
export type PermissionScheme = {
|
||||
everyone: boolean;
|
||||
roles: Record<string, boolean>;
|
||||
allow_list: Record<string, boolean>;
|
||||
block_list: Record<string, boolean>;
|
||||
}
|
||||
|
||||
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 = {
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
type: string;
|
||||
multiple: boolean;
|
||||
channel_id?: string;
|
||||
}
|
||||
|
||||
export type UpdateBadgeRequest = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
type: string;
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type GrantBadgeRequest = {
|
||||
badge_id: string;
|
||||
user_id: string;
|
||||
reason: string;
|
||||
notify_here: boolean;
|
||||
channel_id: string;
|
||||
}
|
||||
|
||||
export type SubscriptionRequest = {
|
||||
type_id: string;
|
||||
channel_id: string;
|
||||
}
|
||||
|
||||
export type RevokeOwnershipRequest = {
|
||||
badge_id: string;
|
||||
user_id: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
@ -1,10 +1,28 @@
|
||||
import {BadgeID} from './badges';
|
||||
import {BadgeDetails, BadgeID, BadgeTypeDefinition} from './badges';
|
||||
|
||||
export type RHSState = string;
|
||||
|
||||
export type GrantModalData = {
|
||||
prefillUser?: string;
|
||||
prefillBadgeId?: string;
|
||||
}
|
||||
|
||||
export type SubscriptionModalData = {
|
||||
mode: 'create' | 'delete';
|
||||
}
|
||||
|
||||
export type PluginState = {
|
||||
showRHS: (() => void)| null;
|
||||
rhsView: RHSState;
|
||||
prevRhsView: RHSState;
|
||||
rhsUser: string | null;
|
||||
rhsBadge: BadgeID | null;
|
||||
rhsTypeId: number | null;
|
||||
rhsTypeName: string | null;
|
||||
createBadgeModalVisible: boolean;
|
||||
editBadgeModalData: BadgeDetails | null;
|
||||
createTypeModalVisible: boolean;
|
||||
editTypeModalData: BadgeTypeDefinition | null;
|
||||
grantModalData: GrantModalData | null;
|
||||
subscriptionModalData: SubscriptionModalData | null;
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ export interface PluginRegistry {
|
||||
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode);
|
||||
registerTranslations(getTranslationsForLocale: (locale: string) => Record<string, string>): void;
|
||||
registerAdminConsoleCustomSetting(key: string, component: React.ElementType, options?: {showTitle: boolean}): void;
|
||||
registerRootComponent(component: React.ElementType): void;
|
||||
|
||||
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
|
||||
}
|
||||
|
||||
29
webapp/src/utils/helpers.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {UserProfile} from 'mattermost-redux/types/users';
|
||||
|
||||
export function getUserDisplayName(user: UserProfile): string {
|
||||
if (user.nickname) {
|
||||
return user.nickname;
|
||||
}
|
||||
if (user.first_name || user.last_name) {
|
||||
return `${user.first_name} ${user.last_name}`.trim();
|
||||
}
|
||||
return user.username;
|
||||
}
|
||||
|
||||
export function debounce<T extends(...args: any[]) => void>(fn: T, delay: number): T {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return ((...args: any[]) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), delay);
|
||||
}) as unknown as T;
|
||||
}
|
||||
|
||||
export function getServerErrorId(err: unknown): string {
|
||||
const msg = (err as {message?: string})?.message || '';
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
return parsed.id || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,13 @@ module.exports = {
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.mjs$/,
|
||||
include: /node_modules/,
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(js|jsx|ts|tsx)$/,
|
||||
exclude: /node_modules/,
|
||||
@ -108,6 +115,7 @@ module.exports = {
|
||||
path: path.join(__dirname, '/dist'),
|
||||
publicPath: '/',
|
||||
filename: 'main.js',
|
||||
hashFunction: 'xxhash64',
|
||||
},
|
||||
devtool,
|
||||
mode,
|
||||
|
||||
495
webapp/yarn.lock
@ -1768,33 +1768,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@giphy/js-fetch-api@npm:^5.1.0":
|
||||
version: 5.7.0
|
||||
resolution: "@giphy/js-fetch-api@npm:5.7.0"
|
||||
dependencies:
|
||||
"@giphy/js-types": "npm:*"
|
||||
"@giphy/js-util": "npm:*"
|
||||
checksum: 10c0/af1990c49ed4d633be04e497f6575e4f798c61348cfca9907f74a8450746bb6ab7336b53eea99e647b902076016d994264979bd09a9aacaa85d40cd610a525ac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@giphy/js-types@npm:*":
|
||||
version: 5.1.0
|
||||
resolution: "@giphy/js-types@npm:5.1.0"
|
||||
checksum: 10c0/8a76b9fd72d10d47486f26902a2fdc083712b4e0582bb2b27698b2c9c58fd3ecfa07d14ac30bcb80ec0f2ec35b3de7f7791d9d9751b0e6cb7d6fa5c39d52479f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@giphy/js-util@npm:*":
|
||||
version: 5.2.0
|
||||
resolution: "@giphy/js-util@npm:5.2.0"
|
||||
dependencies:
|
||||
"@giphy/js-types": "npm:*"
|
||||
uuid: "npm:^9.0.0"
|
||||
checksum: 10c0/0782a4fa1d7b037b4010f76966f5b8347c5732f57516d191471d746cd733f2d212f7461c4ff613961e51cbd65584a1f5e7202dbe5ae2231978b5c52f51a2e7f7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@isaacs/balanced-match@npm:^4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "@isaacs/balanced-match@npm:4.0.1"
|
||||
@ -2395,7 +2368,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/eslint-scope@npm:^3.7.0":
|
||||
"@types/eslint-scope@npm:^3.7.7":
|
||||
version: 3.7.7
|
||||
resolution: "@types/eslint-scope@npm:3.7.7"
|
||||
dependencies:
|
||||
@ -2415,20 +2388,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree@npm:*":
|
||||
"@types/estree@npm:*, @types/estree@npm:^1.0.8":
|
||||
version: 1.0.8
|
||||
resolution: "@types/estree@npm:1.0.8"
|
||||
checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree@npm:^0.0.47":
|
||||
version: 0.0.47
|
||||
resolution: "@types/estree@npm:0.0.47"
|
||||
checksum: 10c0/f4541984097640b8fd594ce5870c7cc4116d0be50caa5f992ab1c6731831cb2083deb8aac4644ef83d638bb0321ac5e42cd315d45c3c62f5a3e54fa10b59c6fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/graceful-fs@npm:^4.1.2":
|
||||
version: 4.1.9
|
||||
resolution: "@types/graceful-fs@npm:4.1.9"
|
||||
@ -2491,7 +2457,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.3, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
|
||||
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.3, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
|
||||
version: 7.0.15
|
||||
resolution: "@types/json-schema@npm:7.0.15"
|
||||
checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db
|
||||
@ -2639,25 +2605,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:*":
|
||||
version: 19.2.14
|
||||
resolution: "@types/react@npm:19.2.14"
|
||||
dependencies:
|
||||
csstype: "npm:^3.2.2"
|
||||
checksum: 10c0/7d25bf41b57719452d86d2ac0570b659210402707313a36ee612666bf11275a1c69824f8c3ee1fdca077ccfe15452f6da8f1224529b917050eb2d861e52b59b7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:16 || 17 || 18":
|
||||
version: 18.3.28
|
||||
resolution: "@types/react@npm:18.3.28"
|
||||
dependencies:
|
||||
"@types/prop-types": "npm:*"
|
||||
csstype: "npm:^3.2.2"
|
||||
checksum: 10c0/683e19cd12b5c691215529af2e32b5ffbaccae3bf0ba93bfafa0e460e8dfee18423afed568be2b8eadf4b837c3749dd296a4f64e2d79f68fa66962c05f5af661
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:17.0.3":
|
||||
version: 17.0.3
|
||||
resolution: "@types/react@npm:17.0.3"
|
||||
@ -2799,154 +2746,154 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/ast@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/ast@npm:1.11.0"
|
||||
"@webassemblyjs/ast@npm:1.14.1, @webassemblyjs/ast@npm:^1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/ast@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/helper-numbers": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
checksum: 10c0/8cf4369381f5212fa04e9e42517c1dbfb5e71c8612226ff48924f2816dc48cec025ee753f4ff56258d1be0ee9f90f409f8ccea7ee738411f81677acf9fabe9e5
|
||||
"@webassemblyjs/helper-numbers": "npm:1.13.2"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
checksum: 10c0/67a59be8ed50ddd33fbb2e09daa5193ac215bf7f40a9371be9a0d9797a114d0d1196316d2f3943efdb923a3d809175e1563a3cb80c814fb8edccd1e77494972b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/floating-point-hex-parser@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.11.0"
|
||||
checksum: 10c0/8e14aa3e0eaecbfe193660af7b9feb0724797a503975fdd8eff10ce8e5da23c4faf3073248f194848eb283d4cf63d57b218d15f2a2203aef7d02dea598e36a54
|
||||
"@webassemblyjs/floating-point-hex-parser@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.13.2"
|
||||
checksum: 10c0/0e88bdb8b50507d9938be64df0867f00396b55eba9df7d3546eb5dc0ca64d62e06f8d881ec4a6153f2127d0f4c11d102b6e7d17aec2f26bb5ff95a5e60652412
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-api-error@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-api-error@npm:1.11.0"
|
||||
checksum: 10c0/6a2cebd7f7846d36d1bbb213ce2135d5fd0d6b16617619108c95a993a39470a2238adcc6c1d56b569212b8a711b9559d758f6a87bc16c80c383b70142961e8d7
|
||||
"@webassemblyjs/helper-api-error@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/helper-api-error@npm:1.13.2"
|
||||
checksum: 10c0/31be497f996ed30aae4c08cac3cce50c8dcd5b29660383c0155fce1753804fc55d47fcba74e10141c7dd2899033164e117b3bcfcda23a6b043e4ded4f1003dfb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-buffer@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-buffer@npm:1.11.0"
|
||||
checksum: 10c0/73037720ffa6a5e12444cf753eff780fcceba0b0f654b531a4d24d93882c9e77cd92455602837dc4cd040f11228ef674ecca55d40e05292d187eed9653ebf8fd
|
||||
"@webassemblyjs/helper-buffer@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/helper-buffer@npm:1.14.1"
|
||||
checksum: 10c0/0d54105dc373c0fe6287f1091e41e3a02e36cdc05e8cf8533cdc16c59ff05a646355415893449d3768cda588af451c274f13263300a251dc11a575bc4c9bd210
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-numbers@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-numbers@npm:1.11.0"
|
||||
"@webassemblyjs/helper-numbers@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/helper-numbers@npm:1.13.2"
|
||||
dependencies:
|
||||
"@webassemblyjs/floating-point-hex-parser": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-api-error": "npm:1.11.0"
|
||||
"@webassemblyjs/floating-point-hex-parser": "npm:1.13.2"
|
||||
"@webassemblyjs/helper-api-error": "npm:1.13.2"
|
||||
"@xtuc/long": "npm:4.2.2"
|
||||
checksum: 10c0/2dab8e53898a89363e778b54439cf9a8c1c2c31cddb99b54e642b2947e9f9949aed42d3a0f3bb57238a56b8205140de25a4ee07f78c605458f38b7effd9462be
|
||||
checksum: 10c0/9c46852f31b234a8fb5a5a9d3f027bc542392a0d4de32f1a9c0075d5e8684aa073cb5929b56df565500b3f9cc0a2ab983b650314295b9bf208d1a1651bfc825a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-wasm-bytecode@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.11.0"
|
||||
checksum: 10c0/947e2132d1137d51e93d6ccd7380c35ad98e9e76e0bb203d341c00b8d6ad643792c0d584a89f415edca467327ae28305cd8fb20a04df37d804c0438c71e5b1f0
|
||||
"@webassemblyjs/helper-wasm-bytecode@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.13.2"
|
||||
checksum: 10c0/c4355d14f369b30cf3cbdd3acfafc7d0488e086be6d578e3c9780bd1b512932352246be96e034e2a7fcfba4f540ec813352f312bfcbbfe5bcfbf694f82ccc682
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-wasm-section@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-wasm-section@npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-section@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/helper-wasm-section@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.11.0"
|
||||
checksum: 10c0/94472ea408338be95a5b879e8e360d00ec8f5d1f079e8f18d8d4d11baf7bad4c11a83711605e09b224749e35f54b1d9ba97d8e751c005641979bd6c57a977a8a
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.14.1"
|
||||
checksum: 10c0/1f9b33731c3c6dbac3a9c483269562fa00d1b6a4e7133217f40e83e975e636fd0f8736e53abd9a47b06b66082ecc976c7384391ab0a68e12d509ea4e4b948d64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/ieee754@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/ieee754@npm:1.11.0"
|
||||
"@webassemblyjs/ieee754@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/ieee754@npm:1.13.2"
|
||||
dependencies:
|
||||
"@xtuc/ieee754": "npm:^1.2.0"
|
||||
checksum: 10c0/c036fe4c933e77caaaa0850bfa87e3b9be0416a87c8915e62511fbebe98b0b21425bc2910f94839f80c15fd1d1f7bcf858ac7ea51cbfd28d3e6f93f79a933ae1
|
||||
checksum: 10c0/2e732ca78c6fbae3c9b112f4915d85caecdab285c0b337954b180460290ccd0fb00d2b1dc4bb69df3504abead5191e0d28d0d17dfd6c9d2f30acac8c4961c8a7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/leb128@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/leb128@npm:1.11.0"
|
||||
"@webassemblyjs/leb128@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/leb128@npm:1.13.2"
|
||||
dependencies:
|
||||
"@xtuc/long": "npm:4.2.2"
|
||||
checksum: 10c0/efd79fc32923e857907e0ab9cdc24b2694f79f09f820fe031e64c055db0d39450310e39cee220b55a9c7dc899715030c4722dbfa65cdad3ef7b4ac931a1a2f6e
|
||||
checksum: 10c0/dad5ef9e383c8ab523ce432dfd80098384bf01c45f70eb179d594f85ce5db2f80fa8c9cba03adafd85684e6d6310f0d3969a882538975989919329ac4c984659
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/utf8@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/utf8@npm:1.11.0"
|
||||
checksum: 10c0/86cb717f4b174c8ad4a4ee720d85f2e926bd5a7e3a996fc47e2cd5097547be24f2406f46b83775d820c4d6dca68fc4f447a5ce40267c591126967d2087d13773
|
||||
"@webassemblyjs/utf8@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/utf8@npm:1.13.2"
|
||||
checksum: 10c0/d3fac9130b0e3e5a1a7f2886124a278e9323827c87a2b971e6d0da22a2ba1278ac9f66a4f2e363ecd9fac8da42e6941b22df061a119e5c0335f81006de9ee799
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wasm-edit@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wasm-edit@npm:1.11.0"
|
||||
"@webassemblyjs/wasm-edit@npm:^1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wasm-edit@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-section": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-opt": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.11.0"
|
||||
"@webassemblyjs/wast-printer": "npm:1.11.0"
|
||||
checksum: 10c0/123b8368ec13b7645f190eba24ac0eb0c1a69878c4d6fee3be5f640cf124a1889244e55a28b07196a16a5dff43a3c2d6afebf819794d7c00f29d74309adea77e
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
"@webassemblyjs/helper-wasm-section": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-opt": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.14.1"
|
||||
"@webassemblyjs/wast-printer": "npm:1.14.1"
|
||||
checksum: 10c0/5ac4781086a2ca4b320bdbfd965a209655fe8a208ca38d89197148f8597e587c9a2c94fb6bd6f1a7dbd4527c49c6844fcdc2af981f8d793a97bf63a016aa86d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wasm-gen@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wasm-gen@npm:1.11.0"
|
||||
"@webassemblyjs/wasm-gen@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wasm-gen@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
"@webassemblyjs/ieee754": "npm:1.11.0"
|
||||
"@webassemblyjs/leb128": "npm:1.11.0"
|
||||
"@webassemblyjs/utf8": "npm:1.11.0"
|
||||
checksum: 10c0/4783c98b2852ee9b2477c48a61861943b25a4e1fc9e93677919ede593790d85026ac7d8e92470cb6b864acede89a28f8395118bdaa60946faec8deec22e92c4f
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
"@webassemblyjs/ieee754": "npm:1.13.2"
|
||||
"@webassemblyjs/leb128": "npm:1.13.2"
|
||||
"@webassemblyjs/utf8": "npm:1.13.2"
|
||||
checksum: 10c0/d678810d7f3f8fecb2e2bdadfb9afad2ec1d2bc79f59e4711ab49c81cec578371e22732d4966f59067abe5fba8e9c54923b57060a729d28d408e608beef67b10
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wasm-opt@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wasm-opt@npm:1.11.0"
|
||||
"@webassemblyjs/wasm-opt@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wasm-opt@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.11.0"
|
||||
checksum: 10c0/25c83c7a3cd923d413e0bae8df01e0a43d381314936a8473bdcff05a968d9398c4b7e42b8d86c1aaefef52fbfac5f52acab72822208bb7cff31c2d9d6c53de8c
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.14.1"
|
||||
checksum: 10c0/515bfb15277ee99ba6b11d2232ddbf22aed32aad6d0956fe8a0a0a004a1b5a3a277a71d9a3a38365d0538ac40d1b7b7243b1a244ad6cd6dece1c1bb2eb5de7ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wasm-parser@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wasm-parser@npm:1.11.0"
|
||||
"@webassemblyjs/wasm-parser@npm:1.14.1, @webassemblyjs/wasm-parser@npm:^1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wasm-parser@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-api-error": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
"@webassemblyjs/ieee754": "npm:1.11.0"
|
||||
"@webassemblyjs/leb128": "npm:1.11.0"
|
||||
"@webassemblyjs/utf8": "npm:1.11.0"
|
||||
checksum: 10c0/b63ae587d841c5545a232e4acd57f61e1f7e5e24ce842b3948d841ce4d38c2a5f51918a71448e4c55454721eaab5dce28a75c74e2229ada1c115e30ee65896cc
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-api-error": "npm:1.13.2"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
"@webassemblyjs/ieee754": "npm:1.13.2"
|
||||
"@webassemblyjs/leb128": "npm:1.13.2"
|
||||
"@webassemblyjs/utf8": "npm:1.13.2"
|
||||
checksum: 10c0/95427b9e5addbd0f647939bd28e3e06b8deefdbdadcf892385b5edc70091bf9b92fa5faac3fce8333554437c5d85835afef8c8a7d9d27ab6ba01ffab954db8c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wast-printer@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wast-printer@npm:1.11.0"
|
||||
"@webassemblyjs/wast-printer@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wast-printer@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@xtuc/long": "npm:4.2.2"
|
||||
checksum: 10c0/05fc5cc9a167d36df50a825be77da845d0834f0f819ae8d36cb2fd2aa1f524012c01ee9f463bd251e39b6bcf1f79e327fc444775e74f4af578fdd5794c9d97f2
|
||||
checksum: 10c0/8d7768608996a052545251e896eac079c98e0401842af8dd4de78fba8d90bd505efb6c537e909cd6dae96e09db3fa2e765a6f26492553a675da56e2db51f9d24
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -3021,6 +2968,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn-import-phases@npm:^1.0.3":
|
||||
version: 1.0.4
|
||||
resolution: "acorn-import-phases@npm:1.0.4"
|
||||
peerDependencies:
|
||||
acorn: ^8.14.0
|
||||
checksum: 10c0/338eb46fc1aed5544f628344cb9af189450b401d152ceadbf1f5746901a5d923016cd0e7740d5606062d374fdf6941c29bb515d2bd133c4f4242d5d4cd73a3c7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn-jsx@npm:^5.3.1":
|
||||
version: 5.3.2
|
||||
resolution: "acorn-jsx@npm:5.3.2"
|
||||
@ -3046,7 +3002,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.0.4, acorn@npm:^8.15.0, acorn@npm:^8.2.4":
|
||||
"acorn@npm:^8.15.0, acorn@npm:^8.2.4":
|
||||
version: 8.15.0
|
||||
resolution: "acorn@npm:8.15.0"
|
||||
bin:
|
||||
@ -3055,6 +3011,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.16.0":
|
||||
version: 8.16.0
|
||||
resolution: "acorn@npm:8.16.0"
|
||||
bin:
|
||||
acorn: bin/acorn
|
||||
checksum: 10c0/c9c52697227661b68d0debaf972222d4f622aa06b185824164e153438afa7b08273432ca43ea792cadb24dada1d46f6f6bb1ef8de9956979288cc1b96bf9914e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"add-px-to-style@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "add-px-to-style@npm:1.0.0"
|
||||
@ -3481,18 +3446,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"babel-loader@npm:8.2.2":
|
||||
version: 8.2.2
|
||||
resolution: "babel-loader@npm:8.2.2"
|
||||
"babel-loader@npm:^8.3.0":
|
||||
version: 8.4.1
|
||||
resolution: "babel-loader@npm:8.4.1"
|
||||
dependencies:
|
||||
find-cache-dir: "npm:^3.3.1"
|
||||
loader-utils: "npm:^1.4.0"
|
||||
loader-utils: "npm:^2.0.4"
|
||||
make-dir: "npm:^3.1.0"
|
||||
schema-utils: "npm:^2.6.5"
|
||||
peerDependencies:
|
||||
"@babel/core": ^7.0.0
|
||||
webpack: ">=2"
|
||||
checksum: 10c0/c4e1e042af99a0c1ed83d4a9e3436c333b66a10459561eff921ebe274d75425b649cc317e1389a149931f29c5b03a5a46acc1daeb849d10582ce2bb65f697a5f
|
||||
checksum: 10c0/efdca9c3ef502af58b923a32123d660c54fd0be125b7b64562c8a43bda0a3a55dac0db32331674104e7e5184061b75c3a0e395b2c5ccdc7cb2125dd9ec7108d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -3835,7 +3800,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"browserslist@npm:^4.14.5, browserslist@npm:^4.24.0, browserslist@npm:^4.28.1":
|
||||
"browserslist@npm:^4.24.0, browserslist@npm:^4.28.1":
|
||||
version: 4.28.1
|
||||
resolution: "browserslist@npm:4.28.1"
|
||||
dependencies:
|
||||
@ -4549,7 +4514,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"csstype@npm:^3.0.2, csstype@npm:^3.2.2":
|
||||
"csstype@npm:^3.0.2":
|
||||
version: 3.2.3
|
||||
resolution: "csstype@npm:3.2.3"
|
||||
checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce
|
||||
@ -4966,13 +4931,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"enhanced-resolve@npm:^5.8.0":
|
||||
version: 5.19.0
|
||||
resolution: "enhanced-resolve@npm:5.19.0"
|
||||
"enhanced-resolve@npm:^5.20.0":
|
||||
version: 5.20.0
|
||||
resolution: "enhanced-resolve@npm:5.20.0"
|
||||
dependencies:
|
||||
graceful-fs: "npm:^4.2.4"
|
||||
tapable: "npm:^2.3.0"
|
||||
checksum: 10c0/966b1dffb82d5f6a4d6a86e904e812104a999066aa29f9223040aaa751e7c453b462a3f5ef91f8bd4408131ff6f7f90651dd1c804bdcb7944e2099a9c2e45ee2
|
||||
checksum: 10c0/4ed5f38406fc9ad74c58a3d63b8215862243ab0ed6b0efc51ccdb72cdcedd3ac8638abe298680b279d7a83c3cb140e5eea7a5f8bd99696c74588f07ad89a95a7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -5213,10 +5178,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-module-lexer@npm:^0.4.0":
|
||||
version: 0.4.1
|
||||
resolution: "es-module-lexer@npm:0.4.1"
|
||||
checksum: 10c0/6463778f04367979d7770cefb1969b6bfc277319e8437a39718b3516df16b1b496b725ceec96a2d24975837a15cf4d56838f16d9c8c7640ad13ad9c8f93ad6fc
|
||||
"es-module-lexer@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "es-module-lexer@npm:2.0.0"
|
||||
checksum: 10c0/ae78dbbd43035a4b972c46cfb6877e374ea290adfc62bc2f5a083fea242c0b2baaab25c5886af86be55f092f4a326741cb94334cd3c478c383fdc8a9ec5ff817
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -5405,7 +5370,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint-scope@npm:^5.0.0, eslint-scope@npm:^5.1.1":
|
||||
"eslint-scope@npm:5.1.1, eslint-scope@npm:^5.0.0, eslint-scope@npm:^5.1.1":
|
||||
version: 5.1.1
|
||||
resolution: "eslint-scope@npm:5.1.1"
|
||||
dependencies:
|
||||
@ -5921,19 +5886,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"form-data@npm:^4.0.0":
|
||||
version: 4.0.5
|
||||
resolution: "form-data@npm:4.0.5"
|
||||
dependencies:
|
||||
asynckit: "npm:^0.4.0"
|
||||
combined-stream: "npm:^1.0.8"
|
||||
es-set-tostringtag: "npm:^2.1.0"
|
||||
hasown: "npm:^2.0.2"
|
||||
mime-types: "npm:^2.1.12"
|
||||
checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fragment-cache@npm:^0.2.1":
|
||||
version: 0.2.1
|
||||
resolution: "fragment-cache@npm:0.2.1"
|
||||
@ -6229,7 +6181,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
|
||||
"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
|
||||
version: 4.2.11
|
||||
resolution: "graceful-fs@npm:4.2.11"
|
||||
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
|
||||
@ -7800,14 +7752,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-parse-better-errors@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "json-parse-better-errors@npm:1.0.2"
|
||||
checksum: 10c0/2f1287a7c833e397c9ddd361a78638e828fc523038bb3441fd4fc144cfd2c6cd4963ffb9e207e648cf7b692600f1e1e524e965c32df5152120910e4903a47dcb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-parse-even-better-errors@npm:^2.3.0":
|
||||
"json-parse-even-better-errors@npm:^2.3.0, json-parse-even-better-errors@npm:^2.3.1":
|
||||
version: 2.3.1
|
||||
resolution: "json-parse-even-better-errors@npm:2.3.1"
|
||||
checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3
|
||||
@ -7842,7 +7787,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json5@npm:^1.0.1, json5@npm:^1.0.2":
|
||||
"json5@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "json5@npm:1.0.2"
|
||||
dependencies:
|
||||
@ -7965,25 +7910,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loader-runner@npm:^4.2.0":
|
||||
"loader-runner@npm:^4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "loader-runner@npm:4.3.1"
|
||||
checksum: 10c0/a523b6329f114e0a98317158e30a7dfce044b731521be5399464010472a93a15ece44757d1eaed1d8845019869c5390218bc1c7c3110f4eeaef5157394486eac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loader-utils@npm:^1.4.0":
|
||||
version: 1.4.2
|
||||
resolution: "loader-utils@npm:1.4.2"
|
||||
dependencies:
|
||||
big.js: "npm:^5.2.2"
|
||||
emojis-list: "npm:^3.0.0"
|
||||
json5: "npm:^1.0.1"
|
||||
checksum: 10c0/2b726088b5526f7605615e3e28043ae9bbd2453f4a85898e1151f3c39dbf7a2b65d09f3996bc588d92ac7e717ded529d3e1ea3ea42c433393be84a58234a2f53
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loader-utils@npm:^2.0.0":
|
||||
"loader-utils@npm:^2.0.0, loader-utils@npm:^2.0.4":
|
||||
version: 2.0.4
|
||||
resolution: "loader-utils@npm:2.0.4"
|
||||
dependencies:
|
||||
@ -8062,20 +7996,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loop-plugin-sdk@https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz":
|
||||
version: 0.1.6
|
||||
resolution: "loop-plugin-sdk@https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz"
|
||||
dependencies:
|
||||
"@giphy/js-fetch-api": "npm:^5.1.0"
|
||||
form-data: "npm:^4.0.0"
|
||||
rudder-sdk-js: "npm:^2.41.0"
|
||||
serialize-error: "npm:^11.0.2"
|
||||
shallow-equals: "npm:^1.0.0"
|
||||
timezones.json: "npm:^1.7.1"
|
||||
checksum: 10c0/661ed3b99bb666a5fe024dadbbb2f1cf6d7d43bed3e94e87171e76985b31f82b8b4042358f30b60bd097e452185c957ec55fc03b15f45ac1ca70a6239fac1b71
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "loose-envify@npm:1.4.0"
|
||||
@ -9648,6 +9568,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"
|
||||
@ -10206,7 +10136,7 @@ __metadata:
|
||||
"@typescript-eslint/parser": "npm:4.22.0"
|
||||
babel-eslint: "npm:10.1.0"
|
||||
babel-jest: "npm:26.6.3"
|
||||
babel-loader: "npm:8.2.2"
|
||||
babel-loader: "npm:^8.3.0"
|
||||
babel-plugin-typescript-to-proptypes: "npm:1.4.2"
|
||||
core-js: "npm:3.10.2"
|
||||
css-loader: "npm:5.2.4"
|
||||
@ -10223,19 +10153,19 @@ __metadata:
|
||||
jest: "npm:26.6.3"
|
||||
jest-canvas-mock: "npm:2.3.1"
|
||||
jest-junit: "npm:12.0.0"
|
||||
loop-plugin-sdk: "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz"
|
||||
mattermost-redux: "npm:5.33.1"
|
||||
memoize-one: "npm:^5.2.1"
|
||||
react: "npm:17.0.2"
|
||||
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"
|
||||
style-loader: "npm:2.0.0"
|
||||
typescript: "npm:4.2.4"
|
||||
webpack: "npm:5.34.0"
|
||||
webpack: "npm:^5.54.0"
|
||||
webpack-cli: "npm:4.6.0"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@ -10264,13 +10194,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rudder-sdk-js@npm:^2.41.0":
|
||||
version: 2.52.8
|
||||
resolution: "rudder-sdk-js@npm:2.52.8"
|
||||
checksum: 10c0/62732c3402bf1858c1100b287bd72d7e54c293b308f2e96c633aa29dfd8758bbee50b1aa93011ea98d4960b8fb98d2ab88a69536b5fe35bd794e85d5cd4ae861
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"run-parallel@npm:^1.1.9":
|
||||
version: 1.2.0
|
||||
resolution: "run-parallel@npm:1.2.0"
|
||||
@ -10466,7 +10389,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"schema-utils@npm:^4.3.0":
|
||||
"schema-utils@npm:^4.3.0, schema-utils@npm:^4.3.3":
|
||||
version: 4.3.3
|
||||
resolution: "schema-utils@npm:4.3.3"
|
||||
dependencies:
|
||||
@ -10514,24 +10437,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"serialize-error@npm:^11.0.2":
|
||||
version: 11.0.3
|
||||
resolution: "serialize-error@npm:11.0.3"
|
||||
dependencies:
|
||||
type-fest: "npm:^2.12.2"
|
||||
checksum: 10c0/7263603883b8936650819f0fd5150d41427b317432678b21722c54b85367ae15b8552865eb7f3f39ba71a32a003730a2e2e971e6909431eb54db70a3ef8eca17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"serialize-javascript@npm:^6.0.2":
|
||||
version: 6.0.2
|
||||
resolution: "serialize-javascript@npm:6.0.2"
|
||||
dependencies:
|
||||
randombytes: "npm:^2.1.0"
|
||||
checksum: 10c0/2dd09ef4b65a1289ba24a788b1423a035581bef60817bea1f01eda8e3bda623f86357665fe7ac1b50f6d4f583f97db9615b3f07b2a2e8cbcb75033965f771dd2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"set-blocking@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "set-blocking@npm:2.0.0"
|
||||
@ -10617,7 +10522,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shallow-equals@npm:1.0.0, shallow-equals@npm:^1.0.0":
|
||||
"shallow-equals@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "shallow-equals@npm:1.0.0"
|
||||
checksum: 10c0/ba7c87947126fcfdd31d6c473c5785c235c385dcc045dd6b1543366b1e86aa8e8f69289346ffee0b13a845365fc6f3d21badd8c00e2b6c2b1d0e84d69bcf4487
|
||||
@ -10839,13 +10744,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"source-list-map@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "source-list-map@npm:2.0.1"
|
||||
checksum: 10c0/2e5e421b185dcd857f46c3c70e2e711a65d717b78c5f795e2e248c9d67757882ea989b80ebc08cf164eeeda5f4be8aa95d3b990225070b2daaaf3257c5958149
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "source-map-js@npm:1.2.1"
|
||||
@ -11263,7 +11161,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tapable@npm:^2.1.1, tapable@npm:^2.3.0":
|
||||
"tapable@npm:^2.3.0":
|
||||
version: 2.3.0
|
||||
resolution: "tapable@npm:2.3.0"
|
||||
checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681
|
||||
@ -11293,14 +11191,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"terser-webpack-plugin@npm:^5.1.1":
|
||||
version: 5.3.16
|
||||
resolution: "terser-webpack-plugin@npm:5.3.16"
|
||||
"terser-webpack-plugin@npm:^5.3.17":
|
||||
version: 5.4.0
|
||||
resolution: "terser-webpack-plugin@npm:5.4.0"
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping": "npm:^0.3.25"
|
||||
jest-worker: "npm:^27.4.5"
|
||||
schema-utils: "npm:^4.3.0"
|
||||
serialize-javascript: "npm:^6.0.2"
|
||||
terser: "npm:^5.31.1"
|
||||
peerDependencies:
|
||||
webpack: ^5.1.0
|
||||
@ -11311,7 +11208,7 @@ __metadata:
|
||||
optional: true
|
||||
uglify-js:
|
||||
optional: true
|
||||
checksum: 10c0/39e37c5b3015c1a5354a3633f77235677bfa06eac2608ce26d258b1d1a74070a99910319a6f2f2c437eb61dc321f66434febe01d78e73fa96b4d4393b813f4cf
|
||||
checksum: 10c0/1feed4b9575af795dae6af0c8f0d76d6e1fb7b357b8628d90e834c23a651b918a58cdc48d0ae6c1f0581f74bc8169b33c3b8d049f2d2190bac4e310964e59fde
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -11363,13 +11260,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"timezones.json@npm:^1.7.1":
|
||||
version: 1.7.2
|
||||
resolution: "timezones.json@npm:1.7.2"
|
||||
checksum: 10c0/209da3d2334118790f57ad060de68ba799adde9df051a6428c348079ed0df20a753e190f85cceadab336f6b24a68302bcca84c895c70270126b15ea0bcde77cd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyglobby@npm:^0.2.12":
|
||||
version: 0.2.15
|
||||
resolution: "tinyglobby@npm:0.2.15"
|
||||
@ -11586,13 +11476,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"type-fest@npm:^2.12.2":
|
||||
version: 2.19.0
|
||||
resolution: "type-fest@npm:2.19.0"
|
||||
checksum: 10c0/a5a7ecf2e654251613218c215c7493574594951c08e52ab9881c9df6a6da0aeca7528c213c622bc374b4e0cb5c443aa3ab758da4e3c959783ce884c3194e12cb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typed-array-buffer@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "typed-array-buffer@npm:1.0.3"
|
||||
@ -11895,15 +11778,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uuid@npm:^9.0.0":
|
||||
version: 9.0.1
|
||||
resolution: "uuid@npm:9.0.1"
|
||||
bin:
|
||||
uuid: dist/bin/uuid
|
||||
checksum: 10c0/1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"v8-compile-cache@npm:^2.0.3, v8-compile-cache@npm:^2.2.0":
|
||||
version: 2.4.0
|
||||
resolution: "v8-compile-cache@npm:2.4.0"
|
||||
@ -11966,7 +11840,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"watchpack@npm:^2.0.0":
|
||||
"watchpack@npm:^2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "watchpack@npm:2.5.1"
|
||||
dependencies:
|
||||
@ -12036,49 +11910,48 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webpack-sources@npm:^2.1.1":
|
||||
version: 2.3.1
|
||||
resolution: "webpack-sources@npm:2.3.1"
|
||||
dependencies:
|
||||
source-list-map: "npm:^2.0.1"
|
||||
source-map: "npm:^0.6.1"
|
||||
checksum: 10c0/caf56a9a478eca7e77feca2b6ddc7673f1384eb870280014b300c40cf42abca656f639ff58a8d55a889a92a810ae3c22e71e578aa38fde416e8c2e6827a6ddfd
|
||||
"webpack-sources@npm:^3.3.4":
|
||||
version: 3.3.4
|
||||
resolution: "webpack-sources@npm:3.3.4"
|
||||
checksum: 10c0/94a42508531338eb41939cf1d48a4a8a6db97f3a47e5453cff2133a68d3169ca779d4bcbe9dfed072ce16611959eba1e16f085bc2dc56714e1a1c1783fd661a3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webpack@npm:5.34.0":
|
||||
version: 5.34.0
|
||||
resolution: "webpack@npm:5.34.0"
|
||||
"webpack@npm:^5.54.0":
|
||||
version: 5.105.4
|
||||
resolution: "webpack@npm:5.105.4"
|
||||
dependencies:
|
||||
"@types/eslint-scope": "npm:^3.7.0"
|
||||
"@types/estree": "npm:^0.0.47"
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-edit": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.11.0"
|
||||
acorn: "npm:^8.0.4"
|
||||
browserslist: "npm:^4.14.5"
|
||||
"@types/eslint-scope": "npm:^3.7.7"
|
||||
"@types/estree": "npm:^1.0.8"
|
||||
"@types/json-schema": "npm:^7.0.15"
|
||||
"@webassemblyjs/ast": "npm:^1.14.1"
|
||||
"@webassemblyjs/wasm-edit": "npm:^1.14.1"
|
||||
"@webassemblyjs/wasm-parser": "npm:^1.14.1"
|
||||
acorn: "npm:^8.16.0"
|
||||
acorn-import-phases: "npm:^1.0.3"
|
||||
browserslist: "npm:^4.28.1"
|
||||
chrome-trace-event: "npm:^1.0.2"
|
||||
enhanced-resolve: "npm:^5.8.0"
|
||||
es-module-lexer: "npm:^0.4.0"
|
||||
eslint-scope: "npm:^5.1.1"
|
||||
enhanced-resolve: "npm:^5.20.0"
|
||||
es-module-lexer: "npm:^2.0.0"
|
||||
eslint-scope: "npm:5.1.1"
|
||||
events: "npm:^3.2.0"
|
||||
glob-to-regexp: "npm:^0.4.1"
|
||||
graceful-fs: "npm:^4.2.4"
|
||||
json-parse-better-errors: "npm:^1.0.2"
|
||||
loader-runner: "npm:^4.2.0"
|
||||
graceful-fs: "npm:^4.2.11"
|
||||
json-parse-even-better-errors: "npm:^2.3.1"
|
||||
loader-runner: "npm:^4.3.1"
|
||||
mime-types: "npm:^2.1.27"
|
||||
neo-async: "npm:^2.6.2"
|
||||
schema-utils: "npm:^3.0.0"
|
||||
tapable: "npm:^2.1.1"
|
||||
terser-webpack-plugin: "npm:^5.1.1"
|
||||
watchpack: "npm:^2.0.0"
|
||||
webpack-sources: "npm:^2.1.1"
|
||||
schema-utils: "npm:^4.3.3"
|
||||
tapable: "npm:^2.3.0"
|
||||
terser-webpack-plugin: "npm:^5.3.17"
|
||||
watchpack: "npm:^2.5.1"
|
||||
webpack-sources: "npm:^3.3.4"
|
||||
peerDependenciesMeta:
|
||||
webpack-cli:
|
||||
optional: true
|
||||
bin:
|
||||
webpack: bin/webpack.js
|
||||
checksum: 10c0/10eb0d3eb666661398974518b26e3682b280f73e8d5720562d6e1f46169b4cfca627543adc1a449f574081cced47da2c3f570c59062a3b336a3e057bde67ba10
|
||||
checksum: 10c0/e9896d20bac351b119d59942b7efae5b117056ecf203acc0d1a84ecbf0a5a9a80ca733735f96bd163e3530be6ab7f615cd67e5320bd3c47d709c9bfe376c3280
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
По сути код в методах для crud дублиркется. Можно было бы сократить