Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bb960a83f | |||
| 981f2aebd6 | |||
| 6f7c963d03 | |||
| 08d7638a89 | |||
| 779630ebd5 | |||
| 45e50f0467 | |||
| f8b9565cc1 | |||
| eaf1f305f4 | |||
| da8ee3897a | |||
| 02305aae92 | |||
| f7ccbb849a | |||
| 5fe054333a | |||
| 0942562085 | |||
| 3e8879245b | |||
| 47fbaef659 | |||
| eabb1603bd | |||
| d06422a711 | |||
| 157967c555 | |||
| 7f7b0cba58 | |||
| ec89c1f115 | |||
| b6c2a3b6f8 | |||
| 23bffe57f7 | |||
| 9079737523 | |||
| f53b8f3df1 | |||
| e7d0560bd3 | |||
| d99ed95cd5 | |||
| 3d84d75669 | |||
| 2006501a74 | |||
| 8596b0e7dc | |||
| 14186baadb | |||
| df25e1f6fc | |||
| 0d582ec803 | |||
| 9f4b2218b0 | |||
| a6b5bcd503 | |||
| edc20a252f | |||
| a88ce39a48 | |||
| dffe0685bb | |||
| 7c976233a7 | |||
| 754304e4ca | |||
| e47a63f1d5 | |||
| 04a001bc94 | |||
| b975b5f5f1 |
260
README.md
260
README.md
@ -1,175 +1,193 @@
|
||||
# Badges plugin
|
||||
Let your users show appreciation for their fellow colleagues by granting badges.
|
||||
# Плагин значков для Loop
|
||||
|
||||
## Install
|
||||
Get the latest release from [GitHub](https://github.com/larkox/mattermost-plugin-badges/releases) and [install it manually](https://developers.mattermost.com/integrate/plugins/server/hello-world/#installing-the-plugin) on your server.
|
||||
Позволяет пользователям выражать признательность коллегам, выдавая им значки.
|
||||
|
||||
## Configuration
|
||||
## Установка
|
||||
|
||||

|
||||
Скачайте последний релиз и установите его вручную через системную консоль Loop.
|
||||
|
||||
- **Badges admin**: Every System Admin is considered a badges admin. System Admins can assign the badges admin role to a single person by specifying their username. Only a single badge admin assignment is permitted.
|
||||
## Настройка
|
||||
|
||||
## Usage
|
||||
### Creating a type
|
||||
Badge admins can create different types of badges, and each type of badge can have its own permissions. You must be a badge admin to create a badge type.
|
||||
Run the slash command `/badges create type` to open the creation dialog.
|
||||
- **Администраторы значков**: все системные администраторы автоматически являются администраторами значков. Дополнительно можно назначить любое количество администраторов значков через мультиселект в настройках плагина.
|
||||
|
||||

|
||||
## Использование
|
||||
|
||||
- **Name**: The type of badge that's visible in the badges description.
|
||||
- **Everyone can create badge**: If you mark this checkbox, every user in your Mattermost instance can create badges of this type.
|
||||
- **Can create allowlist**: This list contains the usernames (comma separated) of all the people allowed to create badges of this type.
|
||||
- **Everyone can grant badge**: If you mark this checkbox, every user in your Mattermost instance can grant any badge of this type.
|
||||
- **Can grant allowlist**: This list contains the usernames (comma separated) of all the people allowed to grant badges of this type.
|
||||
### Создание типа значка
|
||||
|
||||
### Permissions details
|
||||
Badge admins can always create types, create badges for any type, and grant badges from any type, regardless of the permissions in place for a given badge type.
|
||||
A badge creator can always grant the badge they created.
|
||||
Any other user is subject to the permissions defined as part of the badge type.
|
||||
Администраторы значков могут создавать разные типы значков, каждый со своими правами доступа. Для создания типа необходимы права администратора значков.
|
||||
|
||||
Some examples of badge permissions by type are included below. Remember that badge admins have full control over badges, and badge creators can always grant badges. The examples below are intended to demonstrate how badge permissions can be configured for non-admin users to get the most out of badges.
|
||||
(ECC: Everyone Can Create, CC: Can Create Allowlist, ECG: Everyone Can Grant, CG: Can Grant Allowlist)
|
||||
| Permissions | Example | ECC | ECG | CC | CG |
|
||||
|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------|-------|--------------|--------------|
|
||||
| Only badge admins can create and grant badges | | false | false | empty | empty |
|
||||
| Only user1 can create badges, but everyone can grant them | for peer appreciation badges, like "Thank you" badge | false | true | user1 | empty |
|
||||
| Only user1 can create badges, and only user2 and user3 can grant them | lead appreciation badges, like "MVP" badge, where the management create the badges, and the team leads are the ones granting them to their team members | false | false | user1 | user2, user3 |
|
||||
| Only user1 and user2 can create badges, but they can only grant the badges they have created | can be used to have team specific badges without creating a new type for every team | false | false | user1, user2 | empty |
|
||||
| Everyone can create badges, but can only grant the badges they have created | | true | false | empty | empty |
|
||||
| Everyone can create and grant any badge | | true | true | empty | empty |
|
||||
Выполните слэш-команду `/badges create type`, чтобы открыть диалог создания.
|
||||
|
||||
### Creating a badge
|
||||
Run the slash command `/badges create badge` to open the creation dialog.
|
||||
- **Название**: название типа, отображаемое в описании значков.
|
||||
- **Все могут создавать значки**: если отмечено, любой пользователь Loop может создавать значки этого типа.
|
||||
- **Список допущенных к созданию**: username'ы через запятую — кто может создавать значки этого типа.
|
||||
- **Все могут выдавать значки**: если отмечено, любой пользователь Loop может выдавать значки этого типа.
|
||||
- **Список допущенных к выдаче**: username'ы через запятую — кто может выдавать значки этого типа.
|
||||
|
||||

|
||||
### Права доступа
|
||||
|
||||
- **Name**: Name of the badge.
|
||||
- **Description**: Description of the badge.
|
||||
- **Image**: Only emojis are allowed. You must input the emoji name as you would to add it to a message (e.g. `:+1:` or `:smile:`). Custom emojis are also allowed.
|
||||
- **Type**: The type of badge. This list will show only types you have permissions to create.
|
||||
- **Multiple**: Whether this badge can be granted more than once to the same person.
|
||||
Администраторы значков всегда могут создавать типы, создавать значки любого типа и выдавать любые значки — независимо от настроек прав типа. Создатель значка всегда может выдавать созданный им значок. Остальные пользователи подчиняются правам, настроенным для типа.
|
||||
|
||||
### Details about Multiple
|
||||
All badges can be assigned to any number of people. What the **Multiple** setting controls is whether this badge can be granted more than once to the same person. For example, a "Thank you" badge should be grantable many times (many people can be thankful to you on more than one occasion), and therefore, a Thank You badge should have the **Multiple** option selected. However, a "First year in the company" badge should be granted only once since a user won't celebrate this milestone multiple times at the same company. This type of badge should have the **Multiple** option unselected.
|
||||
Примеры конфигураций (ECC: все могут создавать, CC: список допущенных к созданию, ECG: все могут выдавать, CG: список допущенных к выдаче):
|
||||
|
||||
### Granting a badge
|
||||
There are two ways to open the grant dialog:
|
||||
- Run the `/badges grant` command.
|
||||
- Click on the **Grant badge** link available in the Profile Popover, visible when you click on someone's username.
|
||||
| Права | Пример использования | ECC | ECG | CC | CG |
|
||||
|-------|---------------------|-----|-----|----|----|
|
||||
| Только администраторы могут создавать и выдавать | | false | false | пусто | пусто |
|
||||
| Только user1 создаёт, но выдавать может каждый | Значок "Спасибо" от коллег | false | true | user1 | пусто |
|
||||
| Только user1 создаёт, выдают только user2 и user3 | Значок "MVP" — руководство создаёт, тимлиды выдают | false | false | user1 | user2, user3 |
|
||||
| user1 и user2 создают, выдают только свои значки | Командные значки без создания нового типа на каждую команду | false | false | user1, user2 | пусто |
|
||||
| Все создают и выдают только свои значки | | true | false | пусто | пусто |
|
||||
| Все создают и выдают любые значки | | true | true | пусто | пусто |
|
||||
|
||||

|
||||
### Создание значка
|
||||
|
||||
The dialog looks like this:
|
||||
Выполните слэш-команду `/badges create badge`, чтобы открыть диалог создания.
|
||||
|
||||

|
||||
- **Название**: название значка.
|
||||
- **Описание**: описание значка.
|
||||
- **Изображение**: только эмодзи. Укажите имя эмодзи как в сообщении (например `:+1:` или `:smile:`). Кастомные эмодзи тоже поддерживаются.
|
||||
- **Тип**: тип значка. В списке отображаются только типы, для которых у вас есть права на создание.
|
||||
- **Множественный**: можно ли выдавать этот значок одному человеку несколько раз.
|
||||
|
||||
- **User**: The user you want to grant the badge to (may be prepopulated if you clicked the grant button from the profile popover, or added the username in the command).
|
||||
- **Badge**: The badge you want to grant (may be prepopulated if you added the badge id in the command).
|
||||
- **Reason**: An optional reason why you are awarding this badge. (Specially useful for badges like "Thank you").
|
||||
- **Notify on this channel**: If you select this option, a message from the badges bot will be posted in the current channel, letting everyone in that channel know that you granted this badge to that person.
|
||||
The user that received the badge will always receive a DM from the badges bot letting them know they have been awarded a badge. In addition, the following may happen:
|
||||
- If **Notify on this channel** was marked, the badges bot will post a message on the current channel letting everyone know that the user has been awarded a badge.
|
||||
- If a subscription for this badge type is set, the badges bot will post a message on all subscribed channels letting everyone know that the user has been awarded a badge.
|
||||
### Подробнее о параметре "Множественный"
|
||||
|
||||

|
||||
Любой значок можно выдать любому количеству людей. Параметр **Множественный** определяет, можно ли выдать значок одному и тому же человеку более одного раза. Например, значок "Спасибо" стоит сделать множественным — один человек может получить благодарность многократно. А значок "Первый год в компании" должен быть невозможно выдать дважды.
|
||||
|
||||
If you try to award a badge that can't be awarded more than once to a single recipient, the badge won't be granted.
|
||||
### Выдача значка
|
||||
|
||||
### Subscriptions
|
||||
In order to create a subscription, you must be a badges admin.
|
||||
Subscriptions will create posts into a channel every time a badge is granted. There is no limit to the number of subscriptions per channel or per type.
|
||||
There are two ways to open the subscription creation dialog:
|
||||
- Run the `/badges subscription create` command.
|
||||
- Click on the **Add badge subscription** menu from the channel menu.
|
||||
Открыть диалог выдачи можно двумя способами:
|
||||
- Выполнить команду `/badges grant`.
|
||||
- Нажать **Выдать значок** в поповере профиля пользователя.
|
||||
|
||||

|
||||
- **Пользователь**: кому выдаётся значок.
|
||||
- **Значок**: какой значок выдаётся.
|
||||
- **Причина**: необязательное пояснение (особенно полезно для значков типа "Спасибо").
|
||||
- **Уведомить в этом канале**: если отмечено, бот опубликует сообщение в текущем канале о выдаче значка.
|
||||
|
||||
The dialog looks like this:
|
||||
Получатель значка всегда получает личное сообщение от бота. Дополнительно:
|
||||
- Если отмечено **Уведомить в этом канале** — бот публикует сообщение в текущем канале.
|
||||
- Если настроена подписка для этого типа — бот публикует сообщение во всех подписанных каналах.
|
||||
|
||||

|
||||
### Подписки
|
||||
|
||||
- **Type**: The type of badges you want to subscribe to this channel.
|
||||
Подписки позволяют автоматически публиковать сообщение в канале каждый раз, когда выдаётся значок определённого типа. Количество подписок не ограничено.
|
||||
|
||||
In order to remove subscriptions, a similar dialog can be opened by using the `/badges subscription remove` and the **Remove badge subscription** option from the channel menu.
|
||||
Для создания подписки необходимы права администратора значков. Открыть диалог создания подписки можно двумя способами:
|
||||
- Выполнить команду `/badges subscription create`.
|
||||
- Нажать **Добавить подписку на значки** в меню канала.
|
||||
|
||||
### Editing a deleting badges and types
|
||||
In order to edit or delete types you must be a badge admin. In order to edit or delete a badge, you must be a badge admin or the creator.
|
||||
Run `/badges edit type --type typeID` or `/badges edit badge --id badgeID` to open a dialog pretty similar to the creation dialog. IDs are not human readable, but Autocomplete will help you select the right badge.
|
||||
- **Тип**: тип значков, на который подписывается канал.
|
||||
|
||||

|
||||

|
||||
Для удаления подписки используйте `/badges subscription remove` или **Удалить подписку на значки** в меню канала.
|
||||
|
||||
The only difference to the creation is one extra checkbox to remove the current type or badge. If you mark this checkbox and click **Edit**, the badge or type will be removed.
|
||||
When you remove a badge, the badge is deleted permanently, along with any information about who that badge was granted to. When you remove a type, the type and all the associated badges are removed completely.
|
||||
### Редактирование и удаление значков и типов
|
||||
|
||||
### Badge list
|
||||
Badges show on several places. On the profile popover of the users, they show up to the last 20 badges granted to that user. Hovering over the badges will give you more information, and cliking on them will open the Right Hand Sidebar (RHS) with the badge details.
|
||||
Для редактирования и удаления типов необходимы права администратора значков. Для редактирования и удаления значка — права администратора или создателя значка.
|
||||
|
||||

|
||||
Выполните `/badges edit type --type typeID` или `/badges edit badge --id badgeID`, чтобы открыть диалог редактирования. Автодополнение поможет выбрать нужный значок или тип.
|
||||
|
||||
The channel header button will open the RHS with the list of all badges.
|
||||
Диалог редактирования аналогичен диалогу создания, но с дополнительным чекбоксом удаления. Если отметить его и нажать **Изменить** — значок или тип будет удалён.
|
||||
|
||||

|
||||

|
||||
При удалении значка безвозвратно удаляется вся история его выдачи. При удалении типа удаляются тип и все связанные с ним значки.
|
||||
|
||||
Clicking on any badge will lead you to the badge details. Here you can check all the users that have been granted this badge.
|
||||
### Список значков
|
||||
|
||||

|
||||
Значки отображаются в нескольких местах. В поповере профиля — до 20 последних полученных значков. При наведении на значок появляется подсказка, при клике открывается панель с деталями значка.
|
||||
|
||||
Clicking on any username on the badge details screen will lead you to the badges granted to that user.
|
||||
Кнопка в шапке канала открывает панель со списком всех значков. Клик на любой значок показывает его детали и список пользователей, которым он был выдан. Клик на имя пользователя показывает все его значки.
|
||||
|
||||

|
||||
---
|
||||
|
||||
## Using the Plugin API to create and grant badges
|
||||
This plugin can be integrated with any other plugin in your system, to automatize the creation and granting of badges.
|
||||
## REST API
|
||||
|
||||
Using the [PluginHTTP](https://developers.mattermost.com/integrate/plugins/server/reference/#API.PluginHTTP) API method, you can create a request to the badges plugin to "Ensure" and to "Grant" the badges needed.
|
||||
Базовый URL: `https://<сервер>/plugins/ru.loop.plugin.achievements`
|
||||
|
||||
The badges plugin exposes the `badgesmodel` package to simplify handling several parts of this process. Some important exposed objects:
|
||||
- badgesmodel.PluginPath (`/com.mattermost.badges`): The base URL for the plugin (the plugin id).
|
||||
- badgesmodel.PluginAPIPath (`/papi/v1`): The plugin api route.
|
||||
- badgesmodel.PluginAPIPathEnsure (`/ensure`): The ensure endpoint route.
|
||||
- badgesmodel.PluginAPIPathGrant (`/grant`): The grant endpoint route.
|
||||
- badgesmodel.Badge: The data model for badges.
|
||||
- badgesmodel.EnsureBadgesRequest: The data model of the body of a Ensure Badges Request.
|
||||
- badgesmodel.GrantBadgeRequest: The data model of the body of a Grant Badge Request.
|
||||
- badgesmodel.ImageTypeEmoj (`emoji`): The emoji image type. Other image types are considered, but we recommend using emojis.
|
||||
Все запросы требуют токен в заголовке:
|
||||
```
|
||||
Authorization: Bearer <токен>
|
||||
```
|
||||
|
||||
### Ensure badges
|
||||
URL: `/com.mattermost.badges/papi/v1/ensure`
|
||||
Токен можно получить в настройках аккаунта Loop: **Профиль → Безопасность → Персональные токены доступа**.
|
||||
|
||||
Method: `POST`
|
||||
### Типы значков
|
||||
|
||||
| Метод | Эндпоинт | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/api/v1/getTypes` | Список типов, доступных текущему пользователю |
|
||||
| `POST` | `/api/v1/createType` | Создать тип (только администраторы) |
|
||||
| `PUT` | `/api/v1/updateType` | Обновить тип (только администраторы) |
|
||||
| `DELETE` | `/api/v1/deleteType/{typeID}` | Удалить тип и все его значки (только администраторы) |
|
||||
|
||||
### Значки
|
||||
|
||||
| Метод | Эндпоинт | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/api/v1/getAllBadges` | Полный список значков |
|
||||
| `GET` | `/api/v1/getUserBadges/{userID}` | Значки конкретного пользователя |
|
||||
| `GET` | `/api/v1/getBadgeDetails/{badgeID}` | Детали значка и список получателей |
|
||||
| `POST` | `/api/v1/createBadge` | Создать значок |
|
||||
| `PUT` | `/api/v1/updateBadge` | Обновить значок |
|
||||
| `DELETE` | `/api/v1/deleteBadge/{badgeID}` | Удалить значок и историю его выдачи |
|
||||
|
||||
### Выдача значков
|
||||
|
||||
| Метод | Эндпоинт | Описание |
|
||||
|-------|----------|----------|
|
||||
| `POST` | `/api/v1/grantBadge` | Выдать значок пользователю |
|
||||
| `POST` | `/api/v1/revokeOwnership` | Отозвать выдачу значка |
|
||||
|
||||
### Подписки
|
||||
|
||||
| Метод | Эндпоинт | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/api/v1/getChannelSubscriptions/{channelID}` | Список подписок канала |
|
||||
| `POST` | `/api/v1/createSubscription` | Создать подписку (только администраторы) |
|
||||
| `POST` | `/api/v1/deleteSubscription` | Удалить подписку (только администраторы) |
|
||||
|
||||
---
|
||||
|
||||
## Интеграция с другими плагинами (Plugin API)
|
||||
|
||||
Плагин поддерживает интеграцию с другими плагинами Loop через механизм `PluginHTTP`. Авторизация выполняется автоматически ядром Loop — токен не нужен.
|
||||
|
||||
Доступны два эндпоинта:
|
||||
|
||||
### Ensure — создать значки если не существуют
|
||||
|
||||
URL: `/ru.loop.plugin.achievements/papi/v1/ensure`
|
||||
|
||||
Метод: `POST`
|
||||
|
||||
Body example:
|
||||
```json
|
||||
{
|
||||
"Badges":[
|
||||
{
|
||||
"name":"My badge",
|
||||
"description":"Awesome badge",
|
||||
"image":"smile",
|
||||
"image_type":"emoji",
|
||||
"multiple":true
|
||||
}
|
||||
],
|
||||
"BotId":"myBotId"
|
||||
"Badges": [
|
||||
{
|
||||
"name": "My badge",
|
||||
"description": "Awesome badge",
|
||||
"image": "smile",
|
||||
"image_type": "emoji",
|
||||
"multiple": true
|
||||
}
|
||||
],
|
||||
"BotId": "botUserId"
|
||||
}
|
||||
```
|
||||
Ensure badges will create badges if they already do not exist, and return the list of badges including the ids. In order to check whether a badge exist or not, it will only check the name of the badge.
|
||||
|
||||
### Grant badges
|
||||
URL: `/com.mattermost.badges/papi/v1/grant`
|
||||
Создаёт значки, если они ещё не существуют (проверка по имени), и возвращает их с заполненными `id`.
|
||||
|
||||
Method: `POST`
|
||||
### Grant — выдать значок
|
||||
|
||||
URL: `/ru.loop.plugin.achievements/papi/v1/grant`
|
||||
|
||||
Метод: `POST`
|
||||
|
||||
Body example:
|
||||
```json
|
||||
{
|
||||
"BadgeID":"badgeID",
|
||||
"BotId":"myBotId",
|
||||
"UserID":"userID",
|
||||
"Reason":""
|
||||
"BadgeID": "badgeID",
|
||||
"BotId": "botUserId",
|
||||
"UserID": "userID",
|
||||
"Reason": "За крутую работу"
|
||||
}
|
||||
```
|
||||
Grant badges will grant the badge with the badge id provided from the bot to the user defined. Reason is optional.
|
||||
|
||||
Выдаёт значок от имени указанного бота пользователю. `Reason` — необязательное поле.
|
||||
|
||||
@ -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 != ""
|
||||
}
|
||||
|
||||
|
||||
68
go.mod
68
go.mod
@ -1,13 +1,77 @@
|
||||
module github.com/larkox/mattermost-plugin-badges
|
||||
|
||||
go 1.12
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.3
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.14
|
||||
github.com/mattermost/mattermost-server/v5 v5.3.2-0.20210422214809-ff657bfdef24
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.7.0
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/blang/semver v3.5.1+incompatible // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect
|
||||
github.com/fatih/color v1.10.0 // indirect
|
||||
github.com/francoispqt/gojay v1.2.13 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.3 // indirect
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/golang/protobuf v1.5.1 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/uuid v1.2.0 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-hclog v0.15.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-plugin v1.4.0 // indirect
|
||||
github.com/hashicorp/yamux v0.0.0-20210316155119-a95892c5f864 // indirect
|
||||
github.com/json-iterator/go v1.1.10 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.0.5 // indirect
|
||||
github.com/lib/pq v1.10.0 // indirect
|
||||
github.com/mattermost/go-i18n v1.11.0 // indirect
|
||||
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect
|
||||
github.com/mattermost/logr v1.0.13 // indirect
|
||||
github.com/mattn/go-colorable v0.1.8 // indirect
|
||||
github.com/mattn/go-isatty v0.0.12 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.10 // indirect
|
||||
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.1 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/pborman/uuid v1.2.1 // indirect
|
||||
github.com/pelletier/go-toml v1.8.1 // indirect
|
||||
github.com/philhofer/fwd v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.2.1 // indirect
|
||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||
github.com/tinylib/msgp v1.1.5 // indirect
|
||||
github.com/wiggin77/cfg v1.0.2 // indirect
|
||||
github.com/wiggin77/merror v1.0.3 // indirect
|
||||
github.com/wiggin77/srslog v1.0.1 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/zap v1.16.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20210322173543-5f0e89347f5a // indirect
|
||||
google.golang.org/grpc v1.36.0 // indirect
|
||||
google.golang.org/protobuf v1.26.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
40
go.sum
40
go.sum
@ -50,8 +50,9 @@ github.com/Azure/azure-sdk-for-go v26.5.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo
|
||||
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
|
||||
github.com/Azure/go-autorest v11.5.2+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
|
||||
github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/ClickHouse/clickhouse-go v1.3.12/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||
@ -279,7 +280,6 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gorp/gorp v2.0.0+incompatible/go.mod h1:7IfkAQnO7jfT/9IQ3R9wL1dFhukN6aQxzKTHnkxzA/E=
|
||||
github.com/go-gorp/gorp v2.2.0+incompatible h1:xAUh4QgEeqPPhK3vxZN+bzrim1z5Av6q837gtjUlshc=
|
||||
github.com/go-gorp/gorp v2.2.0+incompatible/go.mod h1:7IfkAQnO7jfT/9IQ3R9wL1dFhukN6aQxzKTHnkxzA/E=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
@ -362,8 +362,9 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@ -550,7 +551,6 @@ github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid v1.3.1 h1:5JNjFYYQrZeKRJ0734q51WCEEn2huer72Dc7K+R/b6s=
|
||||
github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@ -639,7 +639,6 @@ github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
|
||||
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
@ -700,12 +699,13 @@ github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxzi
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||
github.com/nelsam/hel/v2 v2.3.2 h1:tXRsJBqRxj4ISSPCrXhbqF8sT+BXA/UaIvjhYjP5Bhk=
|
||||
github.com/nelsam/hel/v2 v2.3.2/go.mod h1:1ZTGfU2PFTOd5mx22i5O0Lc2GY933lQ2wb/ggy+rL3w=
|
||||
github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba/go.mod h1:ncO5VaFWh0Nrt+4KT4mOZboaczBZcLuHrG+/sUeP8gI=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/ngdinhtoan/glide-cleanup v0.2.0/go.mod h1:UQzsmiDOb8YV3nOsCxK/c9zPpCZVNoHScRE3EO9pVMM=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.0.3/go.mod h1:oDab7q8XCYMRlcrBnaY/7B1eOectbvj6B1UPBT+p5jo=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
|
||||
@ -785,7 +785,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/poy/onpar v0.0.0-20200406201722-06f95a1c68e8/go.mod h1:nSbFQvMj97ZyhFRSJYtut+msi4sOY6zJDGCdSc+/rZU=
|
||||
github.com/poy/onpar v1.0.0 h1:MfdQ9bnas+J1si8vUHAABXKxqOqDVaH4T3LRDYYv5Lo=
|
||||
github.com/poy/onpar v1.0.0/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
|
||||
github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
@ -1013,7 +1012,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
@ -1058,6 +1056,8 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM=
|
||||
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE=
|
||||
golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw=
|
||||
golang.org/x/build v0.0.0-20190314133821-5284462c4bec/go.mod h1:atTaCNAy0f16Ah5aV1gMSwgiKVHwu/JncqDpuRr7lS4=
|
||||
@ -1081,8 +1081,9 @@ golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@ -1125,8 +1126,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@ -1182,8 +1184,9 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4 h1:b0LrWgu8+q7z4J+0Y3Umo5q1dL7NXBkKBWkaVkAq17E=
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@ -1206,6 +1209,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@ -1276,8 +1281,9 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 h1:EZ2mChiOa8udjfp6rRmswTbtZN/QzUQp4ptM4rnjHvc=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@ -1286,8 +1292,9 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@ -1357,14 +1364,14 @@ golang.org/x/tools v0.0.0-20200818005847-188abfa75333/go.mod h1:njjCfa9FT2d7l9Bc
|
||||
golang.org/x/tools v0.0.0-20200928182047-19e03678916f/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
|
||||
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
||||
google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
||||
@ -1521,8 +1528,9 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "ru.loop.plugin.achievements",
|
||||
"name": "Badges for Mattermost",
|
||||
"description": "This plugin add badges support to Mattermost.",
|
||||
"name": "Achievements",
|
||||
"description": "Плагин достижений и значков для Loop.",
|
||||
"homepage_url": "https://github.com/larkox/mattermost-plugin-badges",
|
||||
"support_url": "https://github.com/larkox/mattermost-plugin-badges/issues",
|
||||
"release_notes_url": "https://github.com/larkox/mattermost-plugin-badges/releases/tag/v0.2.1",
|
||||
@ -24,9 +24,8 @@
|
||||
"settings": [
|
||||
{
|
||||
"key": "BadgesAdmin",
|
||||
"display_name": "Badges admin:",
|
||||
"type": "text",
|
||||
"help_text": "This user will be considered as an admin for the badges plugin. They can create types, and modify and grant any badge."
|
||||
"display_name": "Achievements Admin:",
|
||||
"type": "custom"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1029
server/api.go
1029
server/api.go
File diff suppressed because it is too large
Load Diff
@ -19,8 +19,8 @@ func getHelp() string {
|
||||
func (p *Plugin) getCommand() *model.Command {
|
||||
return &model.Command{
|
||||
Trigger: "badges",
|
||||
DisplayName: "Badges Bot",
|
||||
Description: "Badges",
|
||||
DisplayName: "Achievements Bot",
|
||||
Description: "Achievements",
|
||||
AutoComplete: true,
|
||||
AutoCompleteDesc: "Available commands:",
|
||||
AutoCompleteHint: "[command]",
|
||||
@ -77,7 +77,13 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo
|
||||
p.postCommandResponse(args, fmt.Sprintf("__Error: %s__", err.Error()))
|
||||
} else {
|
||||
p.mm.Log.Error(err.Error())
|
||||
p.postCommandResponse(args, "An unknown error occurred. Please talk to your system administrator for help.")
|
||||
u, _ := p.mm.User.Get(args.UserId)
|
||||
locale := "ru"
|
||||
if u != nil {
|
||||
locale = u.Locale
|
||||
}
|
||||
T := p.getT(locale)
|
||||
p.postCommandResponse(args, T("badges.error.unknown", "Произошла неизвестная ошибка. Обратитесь к системному администратору."))
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,11 +99,12 @@ func (p *Plugin) runClean(args []string, extra *model.CommandArgs) (bool, *model
|
||||
if err != nil {
|
||||
return false, &model.CommandResponse{Text: "Cannot get user."}, nil
|
||||
}
|
||||
T := p.getT(user.Locale)
|
||||
if !user.IsSystemAdmin() {
|
||||
return false, &model.CommandResponse{Text: "Only a system admin can clean the badges database."}, nil
|
||||
return false, &model.CommandResponse{Text: T("badges.error.only_sysadmin_clean", "Только системный администратор может очистить базу значков.")}, nil
|
||||
}
|
||||
_ = p.mm.KV.DeleteAll()
|
||||
return false, &model.CommandResponse{Text: "Clean"}, nil
|
||||
return false, &model.CommandResponse{Text: T("badges.success.clean", "Очищено")}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) runCreate(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||
@ -105,7 +112,13 @@ func (p *Plugin) runCreate(args []string, extra *model.CommandArgs) (bool, *mode
|
||||
restOfArgs := []string{}
|
||||
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
|
||||
if lengthOfArgs == 0 {
|
||||
return false, &model.CommandResponse{Text: "Specify what you want to create."}, nil
|
||||
u, _ := p.mm.User.Get(extra.UserId)
|
||||
locale := "ru"
|
||||
if u != nil {
|
||||
locale = u.Locale
|
||||
}
|
||||
T := p.getT(locale)
|
||||
return false, &model.CommandResponse{Text: T("badges.error.specify_create", "Укажите, что вы хотите создать.")}, nil
|
||||
}
|
||||
command := args[0]
|
||||
if lengthOfArgs > 1 {
|
||||
@ -117,7 +130,13 @@ func (p *Plugin) runCreate(args []string, extra *model.CommandArgs) (bool, *mode
|
||||
case "type":
|
||||
handler = p.runCreateType
|
||||
default:
|
||||
return false, &model.CommandResponse{Text: "You can create either badge or type"}, nil
|
||||
u, _ := p.mm.User.Get(extra.UserId)
|
||||
locale := "ru"
|
||||
if u != nil {
|
||||
locale = u.Locale
|
||||
}
|
||||
T := p.getT(locale)
|
||||
return false, &model.CommandResponse{Text: T("badges.error.create_badge_or_type", "Можно создать badge или type")}, nil
|
||||
}
|
||||
|
||||
return handler(restOfArgs, extra)
|
||||
@ -128,6 +147,7 @@ func (p *Plugin) runCreateBadge(args []string, extra *model.CommandArgs) (bool,
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
T := p.getT(u.Locale)
|
||||
|
||||
typeSuggestions, err := p.filterCreateBadgeTypes(u)
|
||||
if err != nil {
|
||||
@ -141,45 +161,45 @@ func (p *Plugin) runCreateBadge(args []string, extra *model.CommandArgs) (bool,
|
||||
}
|
||||
|
||||
if len(typeOptions) == 0 {
|
||||
return commandError("You cannot create badges from any type.")
|
||||
return commandError(T("badges.error.no_types_available", "Вы не можете создать значки ни одного типа."))
|
||||
}
|
||||
|
||||
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||
TriggerId: extra.TriggerId,
|
||||
URL: p.getDialogURL() + DialogPathCreateBadge,
|
||||
Dialog: model.Dialog{
|
||||
Title: "Create badge",
|
||||
SubmitLabel: "Create",
|
||||
Title: T("badges.dialog.create_badge.title", "Создать значок"),
|
||||
SubmitLabel: T("badges.dialog.create_badge.submit", "Создать"),
|
||||
Elements: []model.DialogElement{
|
||||
{
|
||||
DisplayName: "Name",
|
||||
DisplayName: T("badges.field.name", "Название"),
|
||||
Type: "text",
|
||||
Name: DialogFieldBadgeName,
|
||||
MaxLength: badgesmodel.NameMaxLength,
|
||||
},
|
||||
{
|
||||
DisplayName: "Description",
|
||||
DisplayName: T("badges.field.description", "Описание"),
|
||||
Type: "text",
|
||||
Name: DialogFieldBadgeDescription,
|
||||
MaxLength: badgesmodel.DescriptionMaxLength,
|
||||
},
|
||||
{
|
||||
DisplayName: "Image",
|
||||
DisplayName: T("badges.field.image", "Изображение"),
|
||||
Type: "text",
|
||||
Name: DialogFieldBadgeImage,
|
||||
HelpText: "Insert a emoticon name",
|
||||
HelpText: T("badges.field.image.help", "Введите название эмодзи"),
|
||||
},
|
||||
{
|
||||
DisplayName: "Type",
|
||||
DisplayName: T("badges.field.type", "Тип"),
|
||||
Type: "select",
|
||||
Name: DialogFieldBadgeType,
|
||||
Options: typeOptions,
|
||||
},
|
||||
{
|
||||
DisplayName: "Multiple",
|
||||
DisplayName: T("badges.field.multiple", "Многократный"),
|
||||
Type: "bool",
|
||||
Name: DialogFieldBadgeMultiple,
|
||||
HelpText: "Whether the badge can be granted multiple times",
|
||||
HelpText: T("badges.field.multiple.help", "Можно ли выдавать этот значок несколько раз"),
|
||||
Optional: true,
|
||||
},
|
||||
},
|
||||
@ -198,7 +218,13 @@ func (p *Plugin) runEdit(args []string, extra *model.CommandArgs) (bool, *model.
|
||||
restOfArgs := []string{}
|
||||
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
|
||||
if lengthOfArgs == 0 {
|
||||
return false, &model.CommandResponse{Text: "Specify what you want to create."}, nil
|
||||
u, _ := p.mm.User.Get(extra.UserId)
|
||||
locale := "ru"
|
||||
if u != nil {
|
||||
locale = u.Locale
|
||||
}
|
||||
T := p.getT(locale)
|
||||
return false, &model.CommandResponse{Text: T("badges.error.specify_edit", "Укажите, что вы хотите отредактировать.")}, nil
|
||||
}
|
||||
command := args[0]
|
||||
if lengthOfArgs > 1 {
|
||||
@ -210,7 +236,13 @@ func (p *Plugin) runEdit(args []string, extra *model.CommandArgs) (bool, *model.
|
||||
case "type":
|
||||
handler = p.runEditType
|
||||
default:
|
||||
return false, &model.CommandResponse{Text: "You can create either badge or type"}, nil
|
||||
u, _ := p.mm.User.Get(extra.UserId)
|
||||
locale := "ru"
|
||||
if u != nil {
|
||||
locale = u.Locale
|
||||
}
|
||||
T := p.getT(locale)
|
||||
return false, &model.CommandResponse{Text: T("badges.error.edit_badge_or_type", "Можно редактировать badge или type")}, nil
|
||||
}
|
||||
|
||||
return handler(restOfArgs, extra)
|
||||
@ -221,6 +253,7 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
T := p.getT(u.Locale)
|
||||
|
||||
var badgeIDStr string
|
||||
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||
@ -230,7 +263,7 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
|
||||
}
|
||||
|
||||
if badgeIDStr == "" {
|
||||
return commandError("You must set the badge ID")
|
||||
return commandError(T("badges.error.must_set_badge_id", "Необходимо указать ID значка"))
|
||||
}
|
||||
|
||||
badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeIDStr))
|
||||
@ -238,8 +271,13 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canEditBadge(u, p.badgeAdminUserID, badge) {
|
||||
return commandError("you cannot edit this 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", "У вас нет прав на редактирование этого значка"))
|
||||
}
|
||||
|
||||
typeSuggestions, err := p.filterCreateBadgeTypes(u)
|
||||
@ -254,58 +292,58 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
|
||||
}
|
||||
|
||||
if len(typeOptions) == 0 {
|
||||
return commandError("You cannot create badges from any type.")
|
||||
return commandError(T("badges.error.no_types_available", "Вы не можете создать значки ни одного типа."))
|
||||
}
|
||||
|
||||
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||
TriggerId: extra.TriggerId,
|
||||
URL: p.getDialogURL() + DialogPathEditBadge,
|
||||
Dialog: model.Dialog{
|
||||
Title: "Create badge",
|
||||
SubmitLabel: "Edit",
|
||||
Title: T("badges.dialog.edit_badge.title", "Редактировать значок"),
|
||||
SubmitLabel: T("badges.dialog.edit_badge.submit", "Сохранить"),
|
||||
State: string(badge.ID),
|
||||
Elements: []model.DialogElement{
|
||||
{
|
||||
DisplayName: "Name",
|
||||
DisplayName: T("badges.field.name", "Название"),
|
||||
Type: "text",
|
||||
Name: DialogFieldBadgeName,
|
||||
MaxLength: badgesmodel.NameMaxLength,
|
||||
Default: badge.Name,
|
||||
},
|
||||
{
|
||||
DisplayName: "Description",
|
||||
DisplayName: T("badges.field.description", "Описание"),
|
||||
Type: "text",
|
||||
Name: DialogFieldBadgeDescription,
|
||||
MaxLength: badgesmodel.DescriptionMaxLength,
|
||||
Default: badge.Description,
|
||||
},
|
||||
{
|
||||
DisplayName: "Image",
|
||||
DisplayName: T("badges.field.image", "Изображение"),
|
||||
Type: "text",
|
||||
Name: DialogFieldBadgeImage,
|
||||
HelpText: "Insert a emoticon name",
|
||||
HelpText: T("badges.field.image.help", "Введите название эмодзи"),
|
||||
Default: badge.Image,
|
||||
},
|
||||
{
|
||||
DisplayName: "Type",
|
||||
DisplayName: T("badges.field.type", "Тип"),
|
||||
Type: "select",
|
||||
Name: DialogFieldBadgeType,
|
||||
Options: typeOptions,
|
||||
Default: string(badge.Type),
|
||||
},
|
||||
{
|
||||
DisplayName: "Multiple",
|
||||
DisplayName: T("badges.field.multiple", "Многократный"),
|
||||
Type: "bool",
|
||||
Name: DialogFieldBadgeMultiple,
|
||||
HelpText: "Whether the badge can be granted multiple times",
|
||||
HelpText: T("badges.field.multiple.help", "Можно ли выдавать этот значок несколько раз"),
|
||||
Optional: true,
|
||||
Default: getBooleanString(badge.Multiple),
|
||||
},
|
||||
{
|
||||
DisplayName: "Delete badge",
|
||||
DisplayName: T("badges.field.delete_badge", "Удалить значок"),
|
||||
Type: "bool",
|
||||
Name: DialogFieldBadgeDelete,
|
||||
HelpText: "WARNING: Checking this will remove this badge permanently.",
|
||||
HelpText: T("badges.field.delete_badge.help", "ВНИМАНИЕ: если отметить, значок будет удалён безвозвратно."),
|
||||
Optional: true,
|
||||
},
|
||||
},
|
||||
@ -324,9 +362,10 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
T := p.getT(u.Locale)
|
||||
|
||||
if !canCreateType(u, p.badgeAdminUserID, false) {
|
||||
return commandError("You have no permissions to edit a badge type.")
|
||||
if !canCreateType(u, p.badgeAdminUserIDs, false) {
|
||||
return commandError(T("badges.error.no_permissions_edit_type", "У вас нет прав на редактирование типа значков."))
|
||||
}
|
||||
|
||||
var badgeTypeStr string
|
||||
@ -337,7 +376,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
|
||||
}
|
||||
|
||||
if badgeTypeStr == "" {
|
||||
return commandError("You must provide a type id")
|
||||
return commandError(T("badges.error.must_provide_type_id", "Необходимо указать ID типа"))
|
||||
}
|
||||
|
||||
typeDefinition, err := p.store.GetType(badgesmodel.BadgeType(badgeTypeStr))
|
||||
@ -345,8 +384,8 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canEditType(u, p.badgeAdminUserID, typeDefinition) {
|
||||
return commandError("you cannot edit this type")
|
||||
if !canEditType(u, p.badgeAdminUserIDs, typeDefinition) {
|
||||
return commandError(T("badges.error.cannot_edit_type", "У вас нет прав на редактирование этого типа"))
|
||||
}
|
||||
|
||||
canGrantAllowList := ""
|
||||
@ -389,56 +428,56 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
|
||||
TriggerId: extra.TriggerId,
|
||||
URL: p.getDialogURL() + DialogPathEditType,
|
||||
Dialog: model.Dialog{
|
||||
Title: "Edit type",
|
||||
SubmitLabel: "Edit",
|
||||
Title: T("badges.dialog.edit_type.title", "Редактировать тип"),
|
||||
SubmitLabel: T("badges.dialog.edit_type.submit", "Сохранить"),
|
||||
State: badgeTypeStr,
|
||||
Elements: []model.DialogElement{
|
||||
{
|
||||
DisplayName: "Name",
|
||||
DisplayName: T("badges.field.name", "Название"),
|
||||
Type: "text",
|
||||
Name: DialogFieldTypeName,
|
||||
MaxLength: badgesmodel.NameMaxLength,
|
||||
Default: typeDefinition.Name,
|
||||
},
|
||||
{
|
||||
DisplayName: "Everyone can create badge",
|
||||
DisplayName: T("badges.field.everyone_can_create", "Все могут создавать значки"),
|
||||
Type: "bool",
|
||||
Name: DialogFieldTypeEveryoneCanCreate,
|
||||
HelpText: "Whether any user can create a badge of this type",
|
||||
HelpText: T("badges.field.everyone_can_create.help", "Любой пользователь может создать значок этого типа"),
|
||||
Optional: true,
|
||||
Default: getBooleanString(typeDefinition.CanCreate.Everyone),
|
||||
},
|
||||
{
|
||||
DisplayName: "Can create allowlist",
|
||||
DisplayName: T("badges.field.allowlist_create", "Список допущенных к созданию"),
|
||||
Type: "text",
|
||||
Name: DialogFieldTypeAllowlistCanCreate,
|
||||
HelpText: "Fill the usernames separated by comma (,) of the people that can create badges of this type.",
|
||||
HelpText: T("badges.field.allowlist_create.help", "Укажите имена пользователей через запятую (,), которые могут создавать значки этого типа."),
|
||||
Placeholder: "user-1, user-2, user-3",
|
||||
Optional: true,
|
||||
Default: canCreateAllowList,
|
||||
},
|
||||
{
|
||||
DisplayName: "Everyone can grant badge",
|
||||
DisplayName: T("badges.field.everyone_can_grant", "Все могут выдавать значки"),
|
||||
Type: "bool",
|
||||
Name: DialogFieldTypeEveryoneCanGrant,
|
||||
HelpText: "Whether any user can grant a badge of this type",
|
||||
HelpText: T("badges.field.everyone_can_grant.help", "Любой пользователь может выдать значок этого типа"),
|
||||
Optional: true,
|
||||
Default: getBooleanString(typeDefinition.CanGrant.Everyone),
|
||||
},
|
||||
{
|
||||
DisplayName: "Can grant allowlist",
|
||||
DisplayName: T("badges.field.allowlist_grant", "Список допущенных к выдаче"),
|
||||
Type: "text",
|
||||
Name: DialogFieldTypeAllowlistCanGrant,
|
||||
HelpText: "Fill the usernames separated by comma (,) of the people that can grant badges of this type.",
|
||||
HelpText: T("badges.field.allowlist_grant.help", "Укажите имена пользователей через запятую (,), которые могут выдавать значки этого типа."),
|
||||
Placeholder: "user-1, user-2, user-3",
|
||||
Optional: true,
|
||||
Default: canGrantAllowList,
|
||||
},
|
||||
{
|
||||
DisplayName: "Remove type",
|
||||
DisplayName: T("badges.field.delete_type", "Удалить тип"),
|
||||
Type: "bool",
|
||||
Name: DialogFieldTypeDelete,
|
||||
HelpText: "WARNING: checking this will remove this type and all associated badges permanently.",
|
||||
HelpText: T("badges.field.delete_type.help", "ВНИМАНИЕ: если отметить, этот тип и все связанные значки будут удалены безвозвратно."),
|
||||
Optional: true,
|
||||
},
|
||||
},
|
||||
@ -457,51 +496,52 @@ func (p *Plugin) runCreateType(args []string, extra *model.CommandArgs) (bool, *
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
T := p.getT(u.Locale)
|
||||
|
||||
if !canCreateType(u, p.badgeAdminUserID, false) {
|
||||
return commandError("You have no permissions to create a badge type.")
|
||||
if !canCreateType(u, p.badgeAdminUserIDs, false) {
|
||||
return commandError(T("badges.error.no_permissions_create_type", "У вас нет прав на создание типа значков."))
|
||||
}
|
||||
|
||||
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||
TriggerId: extra.TriggerId,
|
||||
URL: p.getDialogURL() + DialogPathCreateType,
|
||||
Dialog: model.Dialog{
|
||||
Title: "Create type",
|
||||
SubmitLabel: "Create",
|
||||
Title: T("badges.dialog.create_type.title", "Создать тип"),
|
||||
SubmitLabel: T("badges.dialog.create_type.submit", "Создать"),
|
||||
Elements: []model.DialogElement{
|
||||
{
|
||||
DisplayName: "Name",
|
||||
DisplayName: T("badges.field.name", "Название"),
|
||||
Type: "text",
|
||||
Name: DialogFieldTypeName,
|
||||
MaxLength: badgesmodel.NameMaxLength,
|
||||
},
|
||||
{
|
||||
DisplayName: "Everyone can create badge",
|
||||
DisplayName: T("badges.field.everyone_can_create", "Все могут создавать значки"),
|
||||
Type: "bool",
|
||||
Name: DialogFieldTypeEveryoneCanCreate,
|
||||
HelpText: "Whether any user can create a badge of this type",
|
||||
HelpText: T("badges.field.everyone_can_create.help", "Любой пользователь может создать значок этого типа"),
|
||||
Optional: true,
|
||||
},
|
||||
{
|
||||
DisplayName: "Can create allowlist",
|
||||
DisplayName: T("badges.field.allowlist_create", "Список допущенных к созданию"),
|
||||
Type: "text",
|
||||
Name: DialogFieldTypeAllowlistCanCreate,
|
||||
HelpText: "Fill the usernames separated by comma (,) of the people that can create badges of this type.",
|
||||
HelpText: T("badges.field.allowlist_create.help", "Укажите имена пользователей через запятую (,), которые могут создавать значки этого типа."),
|
||||
Placeholder: "user-1, user-2, user-3",
|
||||
Optional: true,
|
||||
},
|
||||
{
|
||||
DisplayName: "Everyone can grant badge",
|
||||
DisplayName: T("badges.field.everyone_can_grant", "Все могут выдавать значки"),
|
||||
Type: "bool",
|
||||
Name: DialogFieldTypeEveryoneCanGrant,
|
||||
HelpText: "Whether any user can grant a badge of this type",
|
||||
HelpText: T("badges.field.everyone_can_grant.help", "Любой пользователь может выдать значок этого типа"),
|
||||
Optional: true,
|
||||
},
|
||||
{
|
||||
DisplayName: "Can grant allowlist",
|
||||
DisplayName: T("badges.field.allowlist_grant", "Список допущенных к выдаче"),
|
||||
Type: "text",
|
||||
Name: DialogFieldTypeAllowlistCanGrant,
|
||||
HelpText: "Fill the usernames separated by comma (,) of the people that can grant badges of this type.",
|
||||
HelpText: T("badges.field.allowlist_grant.help", "Укажите имена пользователей через запятую (,), которые могут выдавать значки этого типа."),
|
||||
Placeholder: "user-1, user-2, user-3",
|
||||
Optional: true,
|
||||
},
|
||||
@ -535,6 +575,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
T := p.getT(granter.Locale)
|
||||
|
||||
badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeStr))
|
||||
if err != nil {
|
||||
@ -546,8 +587,8 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) {
|
||||
return commandError("you have no permissions to grant this badge")
|
||||
if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) {
|
||||
return commandError(T("badges.error.no_permissions_grant", "У вас нет прав на выдачу этого значка"))
|
||||
}
|
||||
|
||||
user, err := p.mm.User.GetByUsername(username)
|
||||
@ -556,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())
|
||||
}
|
||||
@ -564,10 +608,16 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
p.notifyGrant(badgesmodel.BadgeID(badgeStr), extra.UserId, user, false, "", "")
|
||||
}
|
||||
|
||||
p.postCommandResponse(extra, "Granted")
|
||||
p.postCommandResponse(extra, T("badges.success.granted", "Выдано"))
|
||||
return false, &model.CommandResponse{}, nil
|
||||
}
|
||||
|
||||
actingUser, err := p.mm.User.Get(extra.UserId)
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
T := p.getT(actingUser.Locale)
|
||||
|
||||
elements := []model.DialogElement{}
|
||||
|
||||
stateText := ""
|
||||
@ -582,24 +632,19 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
introductionText = "Grant badge to @" + username
|
||||
introductionText = T("badges.dialog.grant.intro", "Выдать значок пользователю @%s", username)
|
||||
stateText = user.Id
|
||||
}
|
||||
|
||||
if stateText == "" {
|
||||
elements = append(elements, model.DialogElement{
|
||||
DisplayName: "User",
|
||||
DisplayName: T("badges.field.user", "Пользователь"),
|
||||
Type: "select",
|
||||
Name: DialogFieldUser,
|
||||
DataSource: "users",
|
||||
})
|
||||
}
|
||||
|
||||
actingUser, err := p.mm.User.Get(extra.UserId)
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
options := []*model.PostActionOptions{}
|
||||
grantableBadges, err := p.filterGrantBadges(actingUser)
|
||||
if err != nil {
|
||||
@ -610,7 +655,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
}
|
||||
|
||||
badgeElement := model.DialogElement{
|
||||
DisplayName: "Badge",
|
||||
DisplayName: T("badges.field.badge", "Значок"),
|
||||
Type: "select",
|
||||
Name: DialogFieldBadge,
|
||||
Options: options,
|
||||
@ -626,7 +671,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
}
|
||||
|
||||
if !found {
|
||||
return commandError("You cannot grant that badge")
|
||||
return commandError(T("badges.error.cannot_grant_badge", "Вы не можете выдать этот значок"))
|
||||
}
|
||||
|
||||
badgeElement.Default = badgeStr
|
||||
@ -635,18 +680,18 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
elements = append(elements, badgeElement)
|
||||
|
||||
elements = append(elements, model.DialogElement{
|
||||
DisplayName: "Reason",
|
||||
DisplayName: T("badges.field.reason", "Причина"),
|
||||
Name: DialogFieldGrantReason,
|
||||
Optional: true,
|
||||
HelpText: "Reason why you are granting this badge. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions).",
|
||||
HelpText: T("badges.field.reason.help", "Причина выдачи значка. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."),
|
||||
Type: "text",
|
||||
})
|
||||
|
||||
elements = append(elements, model.DialogElement{
|
||||
DisplayName: "Notify on this channel",
|
||||
DisplayName: T("badges.field.notify_here", "Уведомить в этом канале"),
|
||||
Name: DialogFieldNotifyHere,
|
||||
Type: "bool",
|
||||
HelpText: "If you mark this, the bot will send a message to this channel notifying that you granted this badge to this person.",
|
||||
HelpText: T("badges.field.notify_here.help", "Если отметить, бот отправит сообщение в этот канал о том, что вы выдали значок этому пользователю."),
|
||||
Optional: true,
|
||||
})
|
||||
|
||||
@ -654,9 +699,9 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
TriggerId: extra.TriggerId,
|
||||
URL: p.getDialogURL() + DialogPathGrant,
|
||||
Dialog: model.Dialog{
|
||||
Title: "Grant badge",
|
||||
Title: T("badges.dialog.grant.title", "Выдать значок"),
|
||||
IntroductionText: introductionText,
|
||||
SubmitLabel: "Grant",
|
||||
SubmitLabel: T("badges.dialog.grant.submit", "Выдать"),
|
||||
Elements: elements,
|
||||
State: stateText,
|
||||
},
|
||||
@ -674,7 +719,13 @@ func (p *Plugin) runSubscription(args []string, extra *model.CommandArgs) (bool,
|
||||
restOfArgs := []string{}
|
||||
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
|
||||
if lengthOfArgs == 0 {
|
||||
return false, &model.CommandResponse{Text: "Specify what you want to do."}, nil
|
||||
u, _ := p.mm.User.Get(extra.UserId)
|
||||
locale := "ru"
|
||||
if u != nil {
|
||||
locale = u.Locale
|
||||
}
|
||||
T := p.getT(locale)
|
||||
return false, &model.CommandResponse{Text: T("badges.error.specify_subscription", "Укажите, что вы хотите сделать.")}, nil
|
||||
}
|
||||
command := args[0]
|
||||
if lengthOfArgs > 1 {
|
||||
@ -686,7 +737,13 @@ func (p *Plugin) runSubscription(args []string, extra *model.CommandArgs) (bool,
|
||||
case "remove":
|
||||
handler = p.runDeleteSubscription
|
||||
default:
|
||||
return false, &model.CommandResponse{Text: "You can either create or delete subscriptions"}, nil
|
||||
u, _ := p.mm.User.Get(extra.UserId)
|
||||
locale := "ru"
|
||||
if u != nil {
|
||||
locale = u.Locale
|
||||
}
|
||||
T := p.getT(locale)
|
||||
return false, &model.CommandResponse{Text: T("badges.error.create_or_delete_subscription", "Можно создать или удалить подписку")}, nil
|
||||
}
|
||||
|
||||
return handler(restOfArgs, extra)
|
||||
@ -704,9 +761,10 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs)
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
T := p.getT(actingUser.Locale)
|
||||
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
|
||||
return commandError("You cannot create subscriptions")
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
|
||||
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
|
||||
}
|
||||
|
||||
if typeStr != "" {
|
||||
@ -716,7 +774,7 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs)
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
p.postCommandResponse(extra, "Granted")
|
||||
p.postCommandResponse(extra, T("badges.success.granted", "Выдано"))
|
||||
return false, &model.CommandResponse{}, nil
|
||||
}
|
||||
|
||||
@ -733,12 +791,12 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs)
|
||||
TriggerId: extra.TriggerId,
|
||||
URL: p.getDialogURL() + DialogPathCreateSubscription,
|
||||
Dialog: model.Dialog{
|
||||
Title: "Create subscription",
|
||||
IntroductionText: "Introduce the badge type you want to subscribe to this channel.",
|
||||
SubmitLabel: "Add",
|
||||
Title: T("badges.dialog.create_subscription.title", "Создать подписку"),
|
||||
IntroductionText: T("badges.dialog.create_subscription.intro", "Выберите тип значка, на который хотите подписать этот канал."),
|
||||
SubmitLabel: T("badges.dialog.create_subscription.submit", "Добавить"),
|
||||
Elements: []model.DialogElement{
|
||||
{
|
||||
DisplayName: "Type",
|
||||
DisplayName: T("badges.field.type", "Тип"),
|
||||
Type: "select",
|
||||
Name: DialogFieldBadgeType,
|
||||
Options: options,
|
||||
@ -766,9 +824,10 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs)
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
T := p.getT(actingUser.Locale)
|
||||
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
|
||||
return commandError("You cannot create subscriptions")
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
|
||||
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
|
||||
}
|
||||
|
||||
if typeStr != "" {
|
||||
@ -777,7 +836,7 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs)
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
p.postCommandResponse(extra, "Removed")
|
||||
p.postCommandResponse(extra, T("badges.success.removed", "Удалено"))
|
||||
return false, &model.CommandResponse{}, nil
|
||||
}
|
||||
|
||||
@ -794,12 +853,12 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs)
|
||||
TriggerId: extra.TriggerId,
|
||||
URL: p.getDialogURL() + DialogPathDeleteSubscription,
|
||||
Dialog: model.Dialog{
|
||||
Title: "Delete subscription",
|
||||
IntroductionText: "Introduce the badge type you want to remove from this channel.",
|
||||
SubmitLabel: "Remove",
|
||||
Title: T("badges.dialog.delete_subscription.title", "Удалить подписку"),
|
||||
IntroductionText: T("badges.dialog.delete_subscription.intro", "Выберите тип значка, подписку на который хотите удалить из этого канала."),
|
||||
SubmitLabel: T("badges.dialog.delete_subscription.submit", "Удалить"),
|
||||
Elements: []model.DialogElement{
|
||||
{
|
||||
DisplayName: "Type",
|
||||
DisplayName: T("badges.field.type", "Тип"),
|
||||
Type: "select",
|
||||
Name: DialogFieldBadgeType,
|
||||
Options: options,
|
||||
|
||||
@ -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)
|
||||
|
||||
108
server/i18n/en.json
Normal file
108
server/i18n/en.json
Normal file
@ -0,0 +1,108 @@
|
||||
[
|
||||
{"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 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 achievement"},
|
||||
{"id": "badges.dialog.grant.submit", "translation": "Grant"},
|
||||
{"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 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 achievement type you want to unsubscribe from this channel."},
|
||||
|
||||
{"id": "badges.field.name", "translation": "Name"},
|
||||
{"id": "badges.field.description", "translation": "Description"},
|
||||
{"id": "badges.field.image", "translation": "Image"},
|
||||
{"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 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 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 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 achievements permanently."},
|
||||
{"id": "badges.field.user", "translation": "User"},
|
||||
{"id": "badges.field.badge", "translation": "Achievement"},
|
||||
{"id": "badges.field.reason", "translation": "Reason"},
|
||||
{"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 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 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 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 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 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 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"},
|
||||
{"id": "badges.success.removed", "translation": "Removed"},
|
||||
|
||||
{"id": "badges.api.dialog_parse_error", "translation": "Could not get the dialog request"},
|
||||
{"id": "badges.api.cannot_get_user", "translation": "Cannot get user"},
|
||||
{"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 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"},
|
||||
{"id": "badges.api.type_created", "translation": "Type `%s` created."},
|
||||
{"id": "badges.api.cannot_get_type", "translation": "Cannot get type"},
|
||||
{"id": "badges.api.cannot_edit_type", "translation": "You cannot edit this type"},
|
||||
{"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 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": "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` achievement."},
|
||||
{"id": "badges.notify.dm_reason", "translation": "\nWhy? "},
|
||||
{"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."}
|
||||
]
|
||||
45
server/i18n/i18n.go
Normal file
45
server/i18n/i18n.go
Normal file
@ -0,0 +1,45 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
//go:embed *.json
|
||||
var i18nFiles embed.FS
|
||||
|
||||
type TranslationFunc func(translationId string, defaultMessage string, params ...any) string
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func LocalizerFunc(bundle *Bundle, lang string) TranslationFunc {
|
||||
localizer := i18n.NewLocalizer((*i18n.Bundle)(bundle), lang)
|
||||
|
||||
return func(translationId string, defaultMessage string, params ...any) string {
|
||||
if len(params) > 0 {
|
||||
return fmt.Sprintf(localizer.MustLocalize(&i18n.LocalizeConfig{
|
||||
DefaultMessage: &i18n.Message{
|
||||
ID: translationId,
|
||||
Other: defaultMessage,
|
||||
},
|
||||
}), params...)
|
||||
}
|
||||
return localizer.MustLocalize(&i18n.LocalizeConfig{
|
||||
DefaultMessage: &i18n.Message{
|
||||
ID: translationId,
|
||||
Other: defaultMessage,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
108
server/i18n/ru.json
Normal file
108
server/i18n/ru.json
Normal file
@ -0,0 +1,108 @@
|
||||
[
|
||||
{"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.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.submit", "translation": "Выдать"},
|
||||
{"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.delete_subscription.title", "translation": "Удалить подписку"},
|
||||
{"id": "badges.dialog.delete_subscription.submit", "translation": "Удалить"},
|
||||
{"id": "badges.dialog.delete_subscription.intro", "translation": "Выберите тип достижения, подписку на который хотите удалить из этого канала."},
|
||||
|
||||
{"id": "badges.field.name", "translation": "Название"},
|
||||
{"id": "badges.field.description", "translation": "Описание"},
|
||||
{"id": "badges.field.image", "translation": "Изображение"},
|
||||
{"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.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_grant", "translation": "Список допущенных к выдаче"},
|
||||
{"id": "badges.field.allowlist_grant.help", "translation": "Укажите имена пользователей через запятую (,), которые могут выдавать достижения этого типа."},
|
||||
{"id": "badges.field.delete_type", "translation": "Удалить тип"},
|
||||
{"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные достижения будут удалены безвозвратно."},
|
||||
{"id": "badges.field.user", "translation": "Пользователь"},
|
||||
{"id": "badges.field.badge", "translation": "Достижение"},
|
||||
{"id": "badges.field.reason", "translation": "Причина"},
|
||||
{"id": "badges.field.reason.help", "translation": "Причина выдачи достижения. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."},
|
||||
{"id": "badges.field.notify_here", "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.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.specify_edit", "translation": "Укажите, что вы хотите отредактировать."},
|
||||
{"id": "badges.error.edit_badge_or_type", "translation": "Можно редактировать badge или type"},
|
||||
{"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.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.already_owned", "translation": "Это достижение уже выдано этому пользователю"},
|
||||
|
||||
{"id": "badges.success.clean", "translation": "Очищено"},
|
||||
{"id": "badges.success.granted", "translation": "Выдано"},
|
||||
{"id": "badges.success.removed", "translation": "Удалено"},
|
||||
|
||||
{"id": "badges.api.dialog_parse_error", "translation": "Не удалось получить данные диалога"},
|
||||
{"id": "badges.api.cannot_get_user", "translation": "Не удалось найти пользователя"},
|
||||
{"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_type", "translation": "У вас нет прав на создание типа"},
|
||||
{"id": "badges.api.cannot_find_user", "translation": "Не удалось найти пользователя"},
|
||||
{"id": "badges.api.error_getting_user", "translation": "Ошибка получения пользователя %s: %v"},
|
||||
{"id": "badges.api.type_created", "translation": "Тип `%s` создан."},
|
||||
{"id": "badges.api.cannot_get_type", "translation": "Не удалось получить тип"},
|
||||
{"id": "badges.api.cannot_edit_type", "translation": "Вы не можете редактировать этот тип"},
|
||||
{"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.user_not_found", "translation": "Пользователь не найден"},
|
||||
{"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_reason", "translation": "\nПочему? "},
|
||||
{"id": "badges.notify.title", "translation": "%sдостижение выдано!"},
|
||||
{"id": "badges.notify.channel_text", "translation": "@%s выдал @%s достижение %s`%s`."},
|
||||
{"id": "badges.notify.no_permission_channel", "translation": "У вас нет прав на отправку уведомления о выдаче в этот канал."}
|
||||
]
|
||||
8
server/manifest.go
generated
8
server/manifest.go
generated
@ -13,8 +13,8 @@ var manifest *model.Manifest
|
||||
const manifestStr = `
|
||||
{
|
||||
"id": "ru.loop.plugin.achievements",
|
||||
"name": "Badges for Mattermost",
|
||||
"description": "This plugin add badges support to Mattermost.",
|
||||
"name": "Achievements",
|
||||
"description": "Плагин достижений и значков для Loop.",
|
||||
"homepage_url": "https://github.com/larkox/mattermost-plugin-badges",
|
||||
"support_url": "https://github.com/larkox/mattermost-plugin-badges/issues",
|
||||
"release_notes_url": "https://github.com/larkox/mattermost-plugin-badges/releases/tag/v0.2.1",
|
||||
@ -38,9 +38,9 @@ const manifestStr = `
|
||||
"settings": [
|
||||
{
|
||||
"key": "BadgesAdmin",
|
||||
"display_name": "Badges admin:",
|
||||
"display_name": "Администратор достижений:",
|
||||
"type": "text",
|
||||
"help_text": "This user will be considered as an admin for the badges plugin. They can create types, and modify and grant any badge.",
|
||||
"help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки.",
|
||||
"placeholder": "",
|
||||
"default": null
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ import (
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/plugin"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
i18n "github.com/larkox/mattermost-plugin-badges/server/i18n"
|
||||
)
|
||||
|
||||
// Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes.
|
||||
@ -26,7 +28,12 @@ type Plugin struct {
|
||||
BotUserID string
|
||||
store Store
|
||||
router *mux.Router
|
||||
badgeAdminUserID string
|
||||
badgeAdminUserIDs map[string]bool
|
||||
i18nBundle *i18n.Bundle
|
||||
}
|
||||
|
||||
func (p *Plugin) getT(locale string) i18n.TranslationFunc {
|
||||
return i18n.LocalizerFunc(p.i18nBundle, locale)
|
||||
}
|
||||
|
||||
// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world.
|
||||
@ -41,15 +48,19 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req
|
||||
func (p *Plugin) OnActivate() error {
|
||||
p.mm = pluginapi.NewClient(p.API)
|
||||
botID, err := p.Helpers.EnsureBot(&model.Bot{
|
||||
Username: "badges",
|
||||
DisplayName: "Badges Bot",
|
||||
Description: "Created by the Badges plugin.",
|
||||
Username: "achievements",
|
||||
DisplayName: "Achievements Bot",
|
||||
Description: "Created by the Achievements plugin.",
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to ensure badges bot")
|
||||
}
|
||||
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()
|
||||
|
||||
return p.mm.SlashCommand.Register(p.getCommand())
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
100
server/utils.go
100
server/utils.go
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
@ -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
|
||||
}
|
||||
|
||||
if badgeType != nil && canCreateBadge(user, badgeAdminIDs, badgeType) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func canCreateType(user *model.User, badgeAdminID string, isPlugin bool) bool {
|
||||
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
|
||||
}
|
||||
|
||||
@ -152,14 +161,17 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
|
||||
image = fmt.Sprintf(" ", b.Image)
|
||||
}
|
||||
|
||||
// DM to the granted user — use their locale
|
||||
Tdm := p.getT(granted.Locale)
|
||||
dmPost := &model.Post{}
|
||||
dmText := fmt.Sprintf("@%s granted you the %s`%s` badge.", granterUser.Username, image, b.Name)
|
||||
dmText := Tdm("badges.notify.dm_text", "@%s выдал вам значок %s`%s`.", granterUser.Username, image, b.Name)
|
||||
if reason != "" {
|
||||
dmText += "\nWhy? " + reason
|
||||
dmText += Tdm("badges.notify.dm_reason", "\nПочему? ") + reason
|
||||
}
|
||||
dmAttachment := model.SlackAttachment{
|
||||
Title: fmt.Sprintf("%sbadge granted!", image),
|
||||
Text: dmText,
|
||||
Fallback: dmText,
|
||||
Title: Tdm("badges.notify.title", "%sзначок выдан!", image),
|
||||
Text: dmText,
|
||||
}
|
||||
model.ParseSlackAttachment(dmPost, []*model.SlackAttachment{&dmAttachment})
|
||||
err := p.mm.Post.DM(p.BotUserID, granted.Id, dmPost)
|
||||
@ -167,16 +179,18 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
|
||||
p.mm.Log.Debug("dm error", "err", err)
|
||||
}
|
||||
|
||||
// Channel/subscription notifications — use granter's locale
|
||||
Tch := p.getT(granterUser.Locale)
|
||||
basePost := model.Post{
|
||||
UserId: p.BotUserID,
|
||||
ChannelId: channelID,
|
||||
}
|
||||
text := fmt.Sprintf("@%s granted @%s the %s`%s` badge.", granterUser.Username, granted.Username, image, b.Name)
|
||||
text := Tch("badges.notify.channel_text", "@%s выдал @%s значок %s`%s`.", granterUser.Username, granted.Username, image, b.Name)
|
||||
if reason != "" {
|
||||
text += "\nWhy? " + reason
|
||||
text += Tch("badges.notify.dm_reason", "\nПочему? ") + reason
|
||||
}
|
||||
attachment := model.SlackAttachment{
|
||||
Title: fmt.Sprintf("%sbadge granted!", image),
|
||||
Title: Tch("badges.notify.title", "%sзначок выдан!", image),
|
||||
Text: text,
|
||||
}
|
||||
model.ParseSlackAttachment(&basePost, []*model.SlackAttachment{&attachment})
|
||||
@ -188,9 +202,17 @@ 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) {
|
||||
p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: "You don't have permissions to notify the grant on this channel.", ChannelId: channelID})
|
||||
Tg := p.getT(granterUser.Locale)
|
||||
p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: Tg("badges.notify.no_permission_channel", "У вас нет прав на отправку уведомления о выдаче в этот канал."), ChannelId: channelID})
|
||||
} else {
|
||||
post := basePost.Clone()
|
||||
post.ChannelId = channelID
|
||||
@ -203,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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Binary file not shown.
@ -1 +1,181 @@
|
||||
{}
|
||||
{
|
||||
"badges.loading": "Loading...",
|
||||
"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 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 achievement",
|
||||
"badges.grant_badge": "Grant achievement",
|
||||
"badges.and_more": "and {count} more. Click to see all.",
|
||||
|
||||
"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": "Achievements",
|
||||
"badges.popover.title": "Achievements",
|
||||
|
||||
"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"
|
||||
}
|
||||
|
||||
181
webapp/i18n/ru.json
Normal file
181
webapp/i18n/ru.json
Normal file
@ -0,0 +1,181 @@
|
||||
{
|
||||
"badges.loading": "Загрузка...",
|
||||
"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.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.and_more": "и ещё {count}. Нажмите, чтобы увидеть все.",
|
||||
|
||||
"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.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,11 +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": {
|
||||
@ -72,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 {
|
||||
type: ActionTypes.RECEIVED_RHS_VIEW,
|
||||
data: view,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
50
webapp/src/components/admin/badges_admin_setting.tsx
Normal file
50
webapp/src/components/admin/badges_admin_setting.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, {useCallback} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import UserMultiSelect from 'components/user_multi_select';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
onChange: (id: string, value: any) => void;
|
||||
setSaveNeeded: () => void;
|
||||
config: any;
|
||||
license: any;
|
||||
setByEnv: boolean;
|
||||
registerSaveAction: (action: () => Promise<{error?: {message?: string}}>) => void;
|
||||
unRegisterSaveAction: (action: () => Promise<{error?: {message?: string}}>) => void;
|
||||
}
|
||||
|
||||
const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, setSaveNeeded}) => {
|
||||
const handleChange = useCallback((newValue: string) => {
|
||||
onChange(id, newValue);
|
||||
setSaveNeeded();
|
||||
}, [id, onChange, setSaveNeeded]);
|
||||
|
||||
return (
|
||||
<div className='form-group'>
|
||||
<label className='control-label col-sm-4'>
|
||||
<FormattedMessage
|
||||
id='badges.admin.label'
|
||||
defaultMessage='Администраторы достижений:'
|
||||
/>
|
||||
</label>
|
||||
<div className='col-sm-8'>
|
||||
<UserMultiSelect
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className='help-text'>
|
||||
<FormattedMessage
|
||||
id='badges.admin.help_text'
|
||||
defaultMessage='Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые достижения.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgesAdminSetting;
|
||||
13
webapp/src/components/back_button/back_button.scss
Normal file
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
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
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
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
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();
|
||||
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();
|
||||
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
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
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 ||
|
||||
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;
|
||||
|
||||
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
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
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,16 +14,21 @@ 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) {
|
||||
return null;
|
||||
emojiFromMap = emojiMap.get(FALLBACK_EMOJI);
|
||||
if (!emojiFromMap) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const emojiImageUrl = getEmojiImageUrl(emojiFromMap);
|
||||
|
||||
@ -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
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) => {
|
||||
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);
|
||||
}, [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
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
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
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
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;
|
||||
}
|
||||
|
||||
&__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);
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
> .badge-tooltip-wrapper {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 14px;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
&__fab {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--button-bg, #166de0);
|
||||
color: var(--button-color, #fff);
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,90 +1,154 @@
|
||||
import React from 'react';
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {Virtuoso} from 'react-virtuoso';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
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();
|
||||
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'>{'Loading...'}</div>);
|
||||
}
|
||||
|
||||
if (!this.state.badges || this.state.badges.length === 0) {
|
||||
return (<div className='AllBadges'>{'No badges yet.'}</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>{'All badges'}</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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
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';
|
||||
@ -11,36 +13,76 @@ type Props = {
|
||||
onClick: (badge: AllBadgesBadge) => void;
|
||||
}
|
||||
|
||||
function getGrantedText(badge: AllBadgesBadge): string {
|
||||
function getGrantedText(badge: AllBadgesBadge): React.ReactNode {
|
||||
if (badge.granted === 0) {
|
||||
return 'Not yet granted.';
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='badges.granted.not_yet'
|
||||
defaultMessage='Ещё не выдан.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (badge.multiple) {
|
||||
return `Granted ${badge.granted_times} to ${badge.granted} users.`;
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='badges.granted.multiple'
|
||||
defaultMessage='Выдан {times, plural, one {# раз} few {# раза} many {# раз} other {# раз}} {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.'
|
||||
values={{times: badge.granted_times, users: badge.granted}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return `Granted to ${badge.granted} users.`;
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='badges.granted.single'
|
||||
defaultMessage='Выдан {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.'
|
||||
values={{users: badge.granted}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AllBadgesRow: React.FC<Props> = ({badge, onClick}: Props) => {
|
||||
return (
|
||||
<div className='AllBadgesRow'>
|
||||
<a
|
||||
className='badge-icon'
|
||||
onClick={() => onClick(badge)}
|
||||
>
|
||||
<span>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={32}
|
||||
<div
|
||||
className='AllBadgesRow'
|
||||
onClick={() => onClick(badge)}
|
||||
>
|
||||
<span className='badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={36}
|
||||
/>
|
||||
</span>
|
||||
<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}}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<div>
|
||||
<div className='badge-name'>{badge.name}</div>
|
||||
<div className='badge-description'>{markdown(badge.description)}</div>
|
||||
<div className='badge-type'>{'Type: ' + badge.type_name}</div>
|
||||
<div className='granted-by'>{getGrantedText(badge)}</div>
|
||||
{' · '}
|
||||
{getGrantedText(badge)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
73
webapp/src/components/rhs/all_types.scss
Normal file
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
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]);
|
||||
|
||||
const handleDelete = useCallback(async (badgeType: BadgeTypeDefinition) => {
|
||||
const client = new Client();
|
||||
await client.deleteType(String(badgeType.id));
|
||||
setTypes((prev) => prev.filter((t) => t.id !== badgeType.id));
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((badgeType: BadgeTypeDefinition) => {
|
||||
dispatch(setRHSType(badgeType.id, badgeType.name));
|
||||
dispatch(setRHSView(RHS_STATE_TYPE_BADGES));
|
||||
}, [dispatch]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='AllTypes AllTypes--loading'>
|
||||
<div className='spinner'/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (types.length === 0) {
|
||||
return (
|
||||
<div className='AllTypes__empty'>
|
||||
<FormattedMessage
|
||||
id='badges.types.empty'
|
||||
defaultMessage='Типов пока нет'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
style={{flex: '1 1 auto'}}
|
||||
data={types}
|
||||
increaseViewportBy={300}
|
||||
overscan={200}
|
||||
itemContent={(_index, t) => (
|
||||
<AllTypesRow
|
||||
key={t.id}
|
||||
badgeType={t}
|
||||
onClick={handleClick}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllTypes;
|
||||
93
webapp/src/components/rhs/all_types_row.scss
Normal file
93
webapp/src/components/rhs/all_types_row.scss
Normal file
@ -0,0 +1,93 @@
|
||||
.AllTypesRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__default {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--button-bg, #166de0);
|
||||
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
|
||||
&:hover {
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
|
||||
&--edit:hover {
|
||||
color: var(--button-bg, #166de0);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.72);
|
||||
|
||||
&:hover {
|
||||
color: var(--error-text, #d24b4e);
|
||||
background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&--cancel:hover {
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
}
|
||||
|
||||
&__confirmDelete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__confirmText {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
118
webapp/src/components/rhs/all_types_row.tsx
Normal file
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;
|
||||
.badge-info {
|
||||
display: flex;
|
||||
|
||||
&--loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
.badge-icon {
|
||||
padding: 10px;
|
||||
}
|
||||
.badge-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.created-by {
|
||||
font-size: 10px;
|
||||
}
|
||||
.badge-descrition {
|
||||
p {
|
||||
margin: 0px
|
||||
}
|
||||
}
|
||||
.badge-type {
|
||||
font-size: 10px;
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
display: flex;
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.badge-description {
|
||||
font-size: 14px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
margin-top: 4px;
|
||||
word-break: break-word;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
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';
|
||||
|
||||
@ -19,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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -52,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) {
|
||||
@ -87,15 +93,27 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
render() {
|
||||
const {badge, loading} = this.state;
|
||||
if (this.props.badgeID == null) {
|
||||
return (<div>{'Badge not found.'}</div>);
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.badge_not_found'
|
||||
defaultMessage='Достижение не найдено.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (<div>{'Loading...'}</div>);
|
||||
return (<div className='BadgeDetails BadgeDetails--loading'>
|
||||
<div className='spinner'/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
if (!badge) {
|
||||
return (<div>{'Badge not found.'}</div>);
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.badge_not_found'
|
||||
defaultMessage='Достижение не найдено.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
const content = badge.owners.map((ownership) => {
|
||||
@ -109,23 +127,89 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
});
|
||||
return (
|
||||
<div className='BadgeDetails'>
|
||||
<div><b>{'Badge Details'}</b></div>
|
||||
<div className='BadgeDetails__backHeader'>
|
||||
<BackButton
|
||||
targetView={this.props.prevView}
|
||||
onNavigate={this.props.actions.setRHSView}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.back_to_achievements'
|
||||
defaultMessage='Назад к достижениям'
|
||||
/>
|
||||
</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'>{'Type: ' + badge.type_name}</div>
|
||||
<div className='created-by'>{`Created by: ${badge.created_by_username}`}</div>
|
||||
<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}}
|
||||
/>
|
||||
{' · '}
|
||||
<FormattedMessage
|
||||
id='badges.label.created_by'
|
||||
defaultMessage='Создал: {username}'
|
||||
values={{username: badge.created_by_username}}
|
||||
/>
|
||||
</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>{'Granted to:'}</b></div>
|
||||
<RHSScrollbars>{content}</RHSScrollbars>
|
||||
{badge.owners.length > 0 ? (
|
||||
<>
|
||||
<div className='section-title'>
|
||||
<FormattedMessage
|
||||
id='badges.granted_to'
|
||||
defaultMessage='Выдан:'
|
||||
/>
|
||||
</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,73 +13,229 @@ 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 TooltipWrapper from '../user_popover/tooltip_wrapper';
|
||||
|
||||
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);
|
||||
|
||||
switch (currentView) {
|
||||
case RHS_STATE_ALL:
|
||||
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 (
|
||||
<AllBadges
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
case RHS_STATE_DETAIL:
|
||||
};
|
||||
|
||||
const renderFab = () => {
|
||||
if (currentView === RHS_STATE_ALL && canCreateBadge) {
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tooltipContent={
|
||||
<FormattedMessage
|
||||
id='badges.rhs.create_badge'
|
||||
defaultMessage='Создать достижение'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
className='AllBadges__fab'
|
||||
onClick={handleCreateBadge}
|
||||
>
|
||||
{'+'}
|
||||
</button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
if (currentView === RHS_STATE_TYPES && canCreateType) {
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tooltipContent={
|
||||
<FormattedMessage
|
||||
id='badges.rhs.create_type'
|
||||
defaultMessage='Создать тип'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
className='AllBadges__fab'
|
||||
onClick={handleCreateType}
|
||||
>
|
||||
{'+'}
|
||||
</button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentView) {
|
||||
case RHS_STATE_TYPES:
|
||||
return <AllTypes/>;
|
||||
case RHS_STATE_TYPE_BADGES:
|
||||
return (
|
||||
<AllBadges
|
||||
filterTypeId={filterTypeId}
|
||||
filterTypeName={filterTypeName}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
openCreateBadgeModal: () => dispatch(openCreateBadgeModal()),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_ALL:
|
||||
return (
|
||||
<AllBadges
|
||||
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_DETAIL:
|
||||
return (
|
||||
<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)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_OTHER:
|
||||
return (
|
||||
<UserBadges
|
||||
user={currentUser}
|
||||
isCurrentUser={false}
|
||||
currentUserID={myUser.id}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_MY:
|
||||
default:
|
||||
return (
|
||||
<UserBadges
|
||||
user={myUser}
|
||||
isCurrentUser={true}
|
||||
currentUserID={myUser.id}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const needsWrapper = showTabs || currentView === RHS_STATE_TYPE_BADGES;
|
||||
|
||||
if (needsWrapper) {
|
||||
return (
|
||||
<BadgeDetailsComponent
|
||||
badgeID={currentBadge}
|
||||
currentUserID={myUser.id}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSUser: (user: string | null) => dispatch(setRHSUser(user)),
|
||||
getCustomEmojiByName: (names: string) => dispatch(getCustomEmojiByName(names)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_OTHER:
|
||||
return (
|
||||
<UserBadges
|
||||
user={currentUser}
|
||||
isCurrentUser={false}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_MY:
|
||||
default:
|
||||
return (
|
||||
<UserBadges
|
||||
user={myUser}
|
||||
isCurrentUser={true}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
<div className='AllBadges'>
|
||||
{renderTabs()}
|
||||
<div className='AllBadges__content'>
|
||||
{renderContent()}
|
||||
{renderFab()}
|
||||
</div>
|
||||
</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;
|
||||
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 {
|
||||
padding: 10px;
|
||||
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-granted-by {
|
||||
font-size: 10px;
|
||||
}
|
||||
.user-badge-granted-at {
|
||||
font-size: 10px;
|
||||
}
|
||||
.user-badge-descrition {
|
||||
|
||||
.user-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;
|
||||
}
|
||||
}
|
||||
.user-badge-type {
|
||||
font-size: 10px;
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&__yes {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__no {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,58 +1,171 @@
|
||||
import React from 'react';
|
||||
import React, {useState} from 'react';
|
||||
|
||||
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'>{'Why? ' + badge.reason}</div>);
|
||||
reason = (
|
||||
<div className='user-badge-reason'>
|
||||
<FormattedMessage
|
||||
id='badges.label.reason'
|
||||
defaultMessage='Причина: {reason}'
|
||||
values={{reason: badge.reason}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let setStatus = null;
|
||||
if (isCurrentUser && badge.image_type === 'emoji') {
|
||||
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});
|
||||
}}
|
||||
>
|
||||
{'Set status to this badge'}
|
||||
<FormattedMessage
|
||||
id='badges.set_status'
|
||||
defaultMessage='Установить как статус'
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='UserBadgesRow'>
|
||||
<a onClick={() => onClick(badge)}>
|
||||
<span className='user-badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={32}
|
||||
|
||||
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='Снять достижение'
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
if (confirmingRevoke) {
|
||||
revokeAction = (
|
||||
<>
|
||||
{revokeAction}
|
||||
<ConfirmDialog
|
||||
onConfirm={handleRevoke}
|
||||
onCancel={() => setConfirmingRevoke(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.revoke.confirm'
|
||||
defaultMessage='Снять достижение?'
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='UserBadgesRow'
|
||||
onClick={() => onClick(badge)}
|
||||
>
|
||||
<span className='user-badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={36}
|
||||
/>
|
||||
</span>
|
||||
<div className='user-badge-text'>
|
||||
<div className='user-badge-name'>{badge.name}</div>
|
||||
<div className='user-badge-description'>{markdown(badge.description)}</div>
|
||||
<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-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_by'
|
||||
defaultMessage='Выдал: {username}'
|
||||
values={{username: badge.granted_by_name}}
|
||||
/>
|
||||
</div>
|
||||
<div className='user-badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_at'
|
||||
defaultMessage='Выдан: {date}'
|
||||
values={{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}}
|
||||
/>
|
||||
</div>
|
||||
{reason}
|
||||
<div className='user-badge-type'>{'Type: ' + badge.type_name}</div>
|
||||
<div className='user-badge-granted-by'>{`Granted by: ${badge.granted_by_name}`}</div>
|
||||
<div className='user-badge-granted-at'>{`Granted at: ${time.toDateString()}`}</div>
|
||||
{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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
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';
|
||||
@ -16,6 +19,7 @@ import './user_badges.scss';
|
||||
|
||||
type Props = {
|
||||
isCurrentUser: boolean;
|
||||
currentUserID: string;
|
||||
user: UserProfile | null;
|
||||
actions: {
|
||||
setRHSView: (view: RHSState) => void;
|
||||
@ -56,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) {
|
||||
@ -82,37 +86,70 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||
}
|
||||
|
||||
onRevoke = () => {
|
||||
if (!this.props.user) {
|
||||
return;
|
||||
}
|
||||
const c = new Client();
|
||||
this.setState({loading: true});
|
||||
c.getUserBadges(this.props.user.id).then((badges) => {
|
||||
this.setState({badges, loading: false});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.user) {
|
||||
return (<div>{'User not found.'}</div>);
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.user_not_found'
|
||||
defaultMessage='Пользователь не найден.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
if (this.state.loading) {
|
||||
return (<div>{'Loading...'}</div>);
|
||||
return (<div className='UserBadges UserBadges--loading'>
|
||||
<div className='spinner'/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
if (!this.state.badges || this.state.badges.length === 0) {
|
||||
return (<div>{'No badges yet.'}</div>);
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.no_badges_yet'
|
||||
defaultMessage='Достижений пока нет.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
const content = this.state.badges.map((badge) => {
|
||||
return (
|
||||
<UserBadgeRow
|
||||
isCurrentUser={this.props.isCurrentUser}
|
||||
currentUserID={this.props.currentUserID}
|
||||
key={badge.time}
|
||||
badge={badge}
|
||||
onClick={this.onBadgeClick}
|
||||
onRevoke={this.onRevoke}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
let title = 'My badges';
|
||||
if (!this.props.isCurrentUser) {
|
||||
title = `@${this.props.user.username}'s badges`;
|
||||
}
|
||||
const title = this.props.isCurrentUser ? (
|
||||
<FormattedMessage
|
||||
id='badges.rhs.my_badges'
|
||||
defaultMessage='Мои достижения'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='badges.rhs.user_badges'
|
||||
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;
|
||||
.badge-user-username {
|
||||
font-weight: 600;
|
||||
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-granted-at {
|
||||
font-size: 10px;
|
||||
|
||||
.badge-user-username {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {useSelector} from 'react-redux';
|
||||
import {getUser} from 'mattermost-redux/selectors/entities/users';
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
@ -8,12 +10,14 @@ import {UserProfile} from 'mattermost-redux/types/users';
|
||||
import {Ownership} from '../../types/badges';
|
||||
|
||||
import './user_row.scss';
|
||||
|
||||
type Props = {
|
||||
ownership: Ownership;
|
||||
onClick: (user: string) => void;
|
||||
}
|
||||
|
||||
const UserBadgeRow: React.FC<Props> = ({ownership, onClick}: Props) => {
|
||||
const intl = useIntl();
|
||||
const user = useSelector<GlobalState, UserProfile>((state) => getUser(state, ownership.user));
|
||||
const grantedBy = useSelector<GlobalState, UserProfile>((state) => getUser(state, ownership.granted_by));
|
||||
|
||||
@ -21,17 +25,33 @@ const UserBadgeRow: React.FC<Props> = ({ownership, onClick}: Props) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
let grantedByName = 'unknown';
|
||||
let grantedByName = intl.formatMessage({id: 'badges.unknown', defaultMessage: 'неизвестно'});
|
||||
if (grantedBy) {
|
||||
grantedByName = '@' + grantedBy.username;
|
||||
}
|
||||
|
||||
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'>{`Granted by: ${grantedByName}`}</div>
|
||||
<div className='badge-user-granted-at'>{`Granted at: ${time.toDateString()}`}</div>
|
||||
<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}}
|
||||
/>
|
||||
{' · '}
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_at'
|
||||
defaultMessage='Выдан: {date}'
|
||||
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
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
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();
|
||||
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();
|
||||
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
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) {
|
||||
// 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) => (
|
||||
<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
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,189 +1,175 @@
|
||||
import {UserProfile} from 'mattermost-redux/types/users';
|
||||
import React from 'react';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {Tooltip, OverlayTrigger} from 'react-bootstrap';
|
||||
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 {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 {markdown} from 'utils/markdown';
|
||||
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 = {
|
||||
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();
|
||||
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.
|
||||
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;
|
||||
}
|
||||
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]);
|
||||
|
||||
this.props.actions.setRHSUser(this.props.user.id);
|
||||
this.props.actions.setRHSView(RHS_STATE_OTHER);
|
||||
this.props.openRHS();
|
||||
this.props.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();
|
||||
}
|
||||
const visibleGroups = groups.slice(0, MAX_BADGES);
|
||||
const maxWidth = (MAX_BADGES * BADGE_SIZE) + 30;
|
||||
|
||||
render() {
|
||||
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 = null;
|
||||
if (badge.reason) {
|
||||
reason = (<div>{'Why? ' + badge.reason}</div>);
|
||||
}
|
||||
const badgeComponent = (
|
||||
<OverlayTrigger
|
||||
overlay={<Tooltip id='badgeTooltip'>
|
||||
<div>{badge.name}</div>
|
||||
<div>{markdown(badge.description)}</div>
|
||||
{reason}
|
||||
<div>{`Granted by: ${badge.granted_by_name}`}</div>
|
||||
<div>{`Granted at: ${time.toDateString()}`}</div>
|
||||
</Tooltip>}
|
||||
>
|
||||
<span>
|
||||
<a onClick={() => this.onBadgeClick(badge)}>
|
||||
<BadgeImage
|
||||
return (
|
||||
<div id='badgePlugin'>
|
||||
<div><b>
|
||||
<FormattedMessage
|
||||
id='badges.popover.title'
|
||||
defaultMessage='Достижения'
|
||||
/>
|
||||
</b></div>
|
||||
<div id='contentContainer'>
|
||||
{visibleGroups.map(({badge, count}) => (
|
||||
<TooltipWrapper
|
||||
key={badge.id}
|
||||
tooltipContent={
|
||||
<BadgeTooltip
|
||||
badge={badge}
|
||||
size={BADGE_SIZE}
|
||||
count={count}
|
||||
/>
|
||||
</a>
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
content.push(badgeComponent);
|
||||
}
|
||||
let andMore: React.ReactNode = null;
|
||||
if (nBadges > MAX_BADGES) {
|
||||
andMore = (
|
||||
<OverlayTrigger
|
||||
overlay={<Tooltip id='badgeMoreTooltip'>
|
||||
{`and ${nBadges - MAX_BADGES} more. Click to see all.`}
|
||||
</Tooltip>}
|
||||
>
|
||||
<button
|
||||
id='showMoreButton'
|
||||
onClick={this.onMoreClick}
|
||||
}
|
||||
>
|
||||
<span className={'fa fa-angle-right'}/>
|
||||
</button>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
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}}>
|
||||
{'Loading...'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div id='badgePlugin'>
|
||||
<div><b>{'Badges'}</b></div>
|
||||
<div id='contentContainer' >
|
||||
{content}
|
||||
{andMore}
|
||||
</div>
|
||||
{loading}
|
||||
<button
|
||||
id='grantBadgeButton'
|
||||
onClick={this.onGrantClick}
|
||||
>
|
||||
<span className={'fa fa-plus-circle'}/>
|
||||
{'Grant badge'}
|
||||
</button>
|
||||
<hr className='divider divider--expanded'/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
{!loaded && (
|
||||
<div style={{height: BADGE_SIZE, minWidth: 66, maxWidth}}>
|
||||
<div className='spinner'/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
id='grantBadgeButton'
|
||||
onClick={handleGrantClick}
|
||||
>
|
||||
<span className={'fa fa-plus-circle'}/>
|
||||
<FormattedMessage
|
||||
id='badges.grant_badge'
|
||||
defaultMessage='Выдать достижение'
|
||||
/>
|
||||
</button>
|
||||
<hr className='divider divider--expanded'/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeList;
|
||||
|
||||
62
webapp/src/components/user_popover/badge_tooltip.tsx
Normal file
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'}
|
||||
{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';
|
||||
|
||||
31
webapp/src/components/user_popover/tooltip_wrapper.scss
Normal file
31
webapp/src/components/user_popover/tooltip_wrapper.scss
Normal file
@ -0,0 +1,31 @@
|
||||
.badge-tooltip-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-tooltip {
|
||||
position: absolute;
|
||||
background-color: #000;
|
||||
width: max-content;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
z-index: 10000;
|
||||
max-width: 220px;
|
||||
overflow-wrap: break-word;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
white-space: pre-line;
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.badge-tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #000;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
68
webapp/src/components/user_popover/tooltip_wrapper.tsx
Normal file
68
webapp/src/components/user_popover/tooltip_wrapper.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React, {ReactNode, useState, useRef, useEffect} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import './tooltip_wrapper.scss';
|
||||
|
||||
type TooltipWrapperProps = {
|
||||
children: ReactNode;
|
||||
tooltipContent: ReactNode;
|
||||
}
|
||||
|
||||
const TooltipWrapper: React.FC<TooltipWrapperProps> = ({children, tooltipContent}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({bottom: 0, left: 0});
|
||||
|
||||
const handleMouseEnter = () => setIsVisible(true);
|
||||
const handleMouseLeave = () => setIsVisible(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (wrapperRef.current && isVisible) {
|
||||
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
bottom: window.innerHeight - (wrapperRect.top + window.scrollY - 5),
|
||||
left: wrapperRect.left + window.scrollX + wrapperRect.width / 2,
|
||||
});
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
const tooltip = (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className='badge-tooltip'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: `${position.bottom}px`,
|
||||
left: `${position.left}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
visibility: isVisible ? 'visible' : 'hidden',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{tooltipContent}
|
||||
<div
|
||||
className='badge-tooltip-arrow'
|
||||
style={{
|
||||
bottom: '-4px',
|
||||
left: '50%',
|
||||
marginLeft: '-4px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className='badge-tooltip-wrapper'
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
{isVisible && ReactDOM.createPortal(tooltip as any, document.body)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TooltipWrapper;
|
||||
25
webapp/src/components/utils/badge_list_utils.ts
Normal file
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,
|
||||
};
|
||||
|
||||
@ -6,11 +6,21 @@ import {GenericAction} from 'mattermost-redux/types/actions';
|
||||
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {IntlProvider} from 'react-intl';
|
||||
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {openAddSubscription, openCreateBadge, openCreateType, openRemoveSubscription, setRHSView, setShowRHSAction} from 'actions/actions';
|
||||
|
||||
import UserBadges from 'components/rhs';
|
||||
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';
|
||||
|
||||
@ -20,28 +30,60 @@ import manifest from './manifest';
|
||||
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import {PluginRegistry} from './types/mattermost-webapp';
|
||||
import BadgeList from './components/user_popover/';
|
||||
import BadgeListConnected from './components/user_popover/';
|
||||
import {RHS_STATE_ALL} from './constants';
|
||||
import {getTranslations} from './utils/i18n';
|
||||
import BadgesAdminSetting from './components/admin/badges_admin_setting';
|
||||
|
||||
function withIntl(Component: React.ElementType): React.ElementType {
|
||||
const Wrapped: React.FC<any> = (props) => {
|
||||
const currentUser = useSelector(getCurrentUser);
|
||||
const locale = currentUser?.locale || 'ru';
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={locale}
|
||||
messages={getTranslations(locale)}
|
||||
>
|
||||
<Component {...props}/>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
return Wrapped;
|
||||
}
|
||||
|
||||
const WrappedRHS = withIntl(RHSComponent);
|
||||
const WrappedBadgeList = withIntl(BadgeListConnected as unknown as React.ElementType);
|
||||
|
||||
export default class Plugin {
|
||||
public async initialize(registry: PluginRegistry, store: Store<GlobalState, GenericAction>) {
|
||||
registry.registerReducer(Reducer);
|
||||
|
||||
registry.registerPopoverUserAttributesComponent(BadgeList);
|
||||
registry.registerTranslations(getTranslations);
|
||||
|
||||
const {showRHSPlugin, toggleRHSPlugin} = registry.registerRightHandSidebarComponent(UserBadges, 'Badges');
|
||||
registry.registerAdminConsoleCustomSetting('BadgesAdmin', withIntl(BadgesAdminSetting));
|
||||
|
||||
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);
|
||||
|
||||
const {showRHSPlugin, toggleRHSPlugin} = registry.registerRightHandSidebarComponent(WrappedRHS, messages['badges.sidebar.title']);
|
||||
store.dispatch(setShowRHSAction(() => store.dispatch(showRHSPlugin)));
|
||||
|
||||
const toggleRHS = () => {
|
||||
store.dispatch(setRHSView(RHS_STATE_ALL));
|
||||
store.dispatch(toggleRHSPlugin);
|
||||
}
|
||||
|
||||
};
|
||||
registry.registerChannelHeaderButtonAction(
|
||||
<ChannelHeaderButton/>,
|
||||
toggleRHS,
|
||||
'Badges',
|
||||
'Open the list of all badges.',
|
||||
messages['badges.sidebar.title'],
|
||||
messages['badges.menu.open_list'],
|
||||
);
|
||||
|
||||
if (registry.registerAppBarComponent) {
|
||||
@ -50,19 +92,19 @@ export default class Plugin {
|
||||
registry.registerAppBarComponent(
|
||||
iconURL,
|
||||
toggleRHS,
|
||||
'Open the list of all badges.',
|
||||
messages['badges.menu.open_list'],
|
||||
);
|
||||
}
|
||||
|
||||
registry.registerMainMenuAction(
|
||||
'Create badge',
|
||||
messages['badges.menu.create_badge'],
|
||||
() => {
|
||||
store.dispatch(openCreateBadge() as any);
|
||||
},
|
||||
null,
|
||||
);
|
||||
registry.registerMainMenuAction(
|
||||
'Create badge type',
|
||||
messages['badges.menu.create_type'],
|
||||
() => {
|
||||
store.dispatch(openCreateType() as any);
|
||||
},
|
||||
@ -70,13 +112,13 @@ export default class Plugin {
|
||||
);
|
||||
|
||||
registry.registerChannelHeaderMenuAction(
|
||||
'Add badge subscription',
|
||||
messages['badges.menu.add_subscription'],
|
||||
() => {
|
||||
store.dispatch(openAddSubscription() as any);
|
||||
},
|
||||
);
|
||||
registry.registerChannelHeaderMenuAction(
|
||||
'Remove badge subscription',
|
||||
messages['badges.menu.remove_subscription'],
|
||||
() => {
|
||||
store.dispatch(openRemoveSubscription() as any);
|
||||
},
|
||||
|
||||
@ -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(
|
||||
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;
|
||||
}
|
||||
|
||||
@ -11,7 +11,10 @@ export interface PluginRegistry {
|
||||
registerChannelHeaderButtonAction(icon: React.ReactNode, action: () => void, dropdownText: string, tooltip: string);
|
||||
registerMainMenuAction(text: React.ReactNode, action: () => void, mobileIcon: React.ReactNode);
|
||||
registerChannelHeaderMenuAction(text: string, action: (channelID: string) => void);
|
||||
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode)
|
||||
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
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 '';
|
||||
}
|
||||
}
|
||||
8
webapp/src/utils/i18n.ts
Normal file
8
webapp/src/utils/i18n.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import en from '../../i18n/en.json';
|
||||
import ru from '../../i18n/ru.json';
|
||||
|
||||
const translations: Record<string, Record<string, string>> = {en, ru};
|
||||
|
||||
export function getTranslations(locale: string): Record<string, string> {
|
||||
return translations[locale] || translations.ru;
|
||||
}
|
||||
@ -48,6 +48,13 @@ module.exports = {
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.mjs$/,
|
||||
include: /node_modules/,
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(js|jsx|ts|tsx)$/,
|
||||
exclude: /node_modules/,
|
||||
@ -96,6 +103,7 @@ module.exports = {
|
||||
},
|
||||
externals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
redux: 'Redux',
|
||||
'react-redux': 'ReactRedux',
|
||||
'prop-types': 'PropTypes',
|
||||
@ -107,6 +115,7 @@ module.exports = {
|
||||
path: path.join(__dirname, '/dist'),
|
||||
publicPath: '/',
|
||||
filename: 'main.js',
|
||||
hashFunction: 'xxhash64',
|
||||
},
|
||||
devtool,
|
||||
mode,
|
||||
|
||||
632
webapp/yarn.lock
632
webapp/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user