LP-5613 #2

Open
dmitrii.pichenikin wants to merge 37 commits from LP-5613 into dev
19 changed files with 994 additions and 69 deletions
Showing only changes of commit 0d582ec803 - Show all commits

View File

@ -68,6 +68,19 @@ type UpdateTypeRequest struct {
AllowlistCanGrant string `json:"allowlist_can_grant"` AllowlistCanGrant string `json:"allowlist_can_grant"`
} }
type GrantBadgeAPIRequest struct {
BadgeID string `json:"badge_id"`
UserID string `json:"user_id"`
Reason string `json:"reason"`
NotifyHere bool `json:"notify_here"`
ChannelID string `json:"channel_id"`
}
type SubscriptionAPIRequest struct {
TypeID string `json:"type_id"`
ChannelID string `json:"channel_id"`
}
type TypeWithBadgeCount struct { type TypeWithBadgeCount struct {
*badgesmodel.BadgeTypeDefinition *badgesmodel.BadgeTypeDefinition
BadgeCount int `json:"badge_count"` BadgeCount int `json:"badge_count"`
@ -100,6 +113,10 @@ func (p *Plugin) initializeAPI() {
apiRouter.HandleFunc("/updateType", p.extractUserMiddleWare(p.apiUpdateType, ResponseTypeJSON)).Methods(http.MethodPut) apiRouter.HandleFunc("/updateType", p.extractUserMiddleWare(p.apiUpdateType, ResponseTypeJSON)).Methods(http.MethodPut)
apiRouter.HandleFunc("/deleteBadge/{badgeID}", p.extractUserMiddleWare(p.apiDeleteBadge, ResponseTypeJSON)).Methods(http.MethodDelete) apiRouter.HandleFunc("/deleteBadge/{badgeID}", p.extractUserMiddleWare(p.apiDeleteBadge, ResponseTypeJSON)).Methods(http.MethodDelete)
apiRouter.HandleFunc("/deleteType/{typeID}", p.extractUserMiddleWare(p.apiDeleteType, ResponseTypeJSON)).Methods(http.MethodDelete) apiRouter.HandleFunc("/deleteType/{typeID}", p.extractUserMiddleWare(p.apiDeleteType, ResponseTypeJSON)).Methods(http.MethodDelete)
apiRouter.HandleFunc("/grantBadge", p.extractUserMiddleWare(p.apiGrantBadge, ResponseTypeJSON)).Methods(http.MethodPost)
apiRouter.HandleFunc("/getChannelSubscriptions/{channelID}", p.extractUserMiddleWare(p.apiGetChannelSubscriptions, ResponseTypeJSON)).Methods(http.MethodGet)
apiRouter.HandleFunc("/createSubscription", p.extractUserMiddleWare(p.apiCreateSubscription, ResponseTypeJSON)).Methods(http.MethodPost)
apiRouter.HandleFunc("/deleteSubscription", p.extractUserMiddleWare(p.apiDeleteSubscription, ResponseTypeJSON)).Methods(http.MethodPost)
pluginAPIRouter.HandleFunc(badgesmodel.PluginAPIPathEnsure, checkPluginRequest(p.ensureBadges)).Methods(http.MethodPost) pluginAPIRouter.HandleFunc(badgesmodel.PluginAPIPathEnsure, checkPluginRequest(p.ensureBadges)).Methods(http.MethodPost)
pluginAPIRouter.HandleFunc(badgesmodel.PluginAPIPathGrant, checkPluginRequest(p.grantBadge)).Methods(http.MethodPost) pluginAPIRouter.HandleFunc(badgesmodel.PluginAPIPathGrant, checkPluginRequest(p.grantBadge)).Methods(http.MethodPost)
@ -1586,3 +1603,211 @@ func (p *Plugin) getPluginURL() string {
func (p *Plugin) getDialogURL() string { func (p *Plugin) getDialogURL() string {
return p.getPluginURL() + DialogPath return p.getPluginURL() + DialogPath
} }
func (p *Plugin) apiGrantBadge(w http.ResponseWriter, r *http.Request, userID string) {
var req GrantBadgeAPIRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest,
})
return
}
req.BadgeID = strings.TrimSpace(req.BadgeID)
req.UserID = strings.TrimSpace(req.UserID)
if req.BadgeID == "" {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_badge_id", Message: "Badge ID is required", StatusCode: http.StatusBadRequest,
})
return
}
if req.UserID == "" {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_user_id", Message: "User ID is required", StatusCode: http.StatusBadRequest,
})
return
}
badge, err := p.store.GetBadge(badgesmodel.BadgeID(req.BadgeID))
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "badge_not_found", Message: "Badge not found", StatusCode: http.StatusNotFound,
})
return
}
granter, err := p.mm.User.Get(userID)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError,
})
return
}
badgeType, err := p.store.GetType(badge.Type)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "type_not_found", Message: "Badge type not found", StatusCode: http.StatusInternalServerError,
})
return
}
if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) {
p.writeAPIError(w, &APIErrorResponse{
ID: "no_permission_grant", Message: "No permission to grant this badge", StatusCode: http.StatusForbidden,
})
return
}
grantToUser, err := p.mm.User.Get(req.UserID)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "user_not_found", Message: "User not found", StatusCode: http.StatusNotFound,
})
return
}
shouldNotify, err := p.store.GrantBadge(badgesmodel.BadgeID(req.BadgeID), req.UserID, userID, req.Reason)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "cannot_grant_badge", Message: err.Error(), StatusCode: http.StatusInternalServerError,
})
return
}
if shouldNotify {
channelID := req.ChannelID
p.notifyGrant(badgesmodel.BadgeID(req.BadgeID), userID, grantToUser, req.NotifyHere, channelID, req.Reason)
}
resp := map[string]string{"status": "ok"}
b, _ := json.Marshal(resp)
_, _ = w.Write(b)
}
func (p *Plugin) apiCreateSubscription(w http.ResponseWriter, r *http.Request, userID string) {
var req SubscriptionAPIRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest,
})
return
}
u, err := p.mm.User.Get(userID)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError,
})
return
}
if !canCreateSubscription(u, p.badgeAdminUserIDs, req.ChannelID) {
p.writeAPIError(w, &APIErrorResponse{
ID: "no_permission_subscription", Message: "No permission to manage subscriptions", StatusCode: http.StatusForbidden,
})
return
}
req.TypeID = strings.TrimSpace(req.TypeID)
if req.TypeID == "" {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_type_id", Message: "Type ID is required", StatusCode: http.StatusBadRequest,
})
return
}
err = p.store.AddSubscription(badgesmodel.BadgeType(req.TypeID), req.ChannelID)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "cannot_create_subscription", Message: err.Error(), StatusCode: http.StatusInternalServerError,
})
return
}
T := p.getT(u.Locale)
p.mm.Post.SendEphemeralPost(userID, &model.Post{
UserId: p.BotUserID,
ChannelId: req.ChannelID,
Message: T("badges.api.subscription_added", "Подписка добавлена"),
})
resp := map[string]string{"status": "ok"}
b, _ := json.Marshal(resp)
_, _ = w.Write(b)
}
func (p *Plugin) apiDeleteSubscription(w http.ResponseWriter, r *http.Request, userID string) {
var req SubscriptionAPIRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest,
})
return
}
u, err := p.mm.User.Get(userID)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError,
})
return
}
if !canCreateSubscription(u, p.badgeAdminUserIDs, req.ChannelID) {
p.writeAPIError(w, &APIErrorResponse{
ID: "no_permission_subscription", Message: "No permission to manage subscriptions", StatusCode: http.StatusForbidden,
})
return
}
req.TypeID = strings.TrimSpace(req.TypeID)
if req.TypeID == "" {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_type_id", Message: "Type ID is required", StatusCode: http.StatusBadRequest,
})
return
}
err = p.store.RemoveSubscriptions(badgesmodel.BadgeType(req.TypeID), req.ChannelID)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "cannot_delete_subscription", Message: err.Error(), StatusCode: http.StatusInternalServerError,
})
return
}
T := p.getT(u.Locale)
p.mm.Post.SendEphemeralPost(userID, &model.Post{
UserId: p.BotUserID,
ChannelId: req.ChannelID,
Message: T("badges.api.subscription_removed", "Подписка удалена"),
})
resp := map[string]string{"status": "ok"}
b, _ := json.Marshal(resp)
_, _ = w.Write(b)
}
func (p *Plugin) apiGetChannelSubscriptions(w http.ResponseWriter, r *http.Request, userID string) {
channelID := mux.Vars(r)["channelID"]
if channelID == "" {
p.writeAPIError(w, &APIErrorResponse{
ID: "invalid_request", Message: "Channel ID is required", StatusCode: http.StatusBadRequest,
})
return
}
types, err := p.store.GetChannelSubscriptions(channelID)
if err != nil {
p.writeAPIError(w, &APIErrorResponse{
ID: "cannot_get_types", Message: err.Error(), StatusCode: http.StatusInternalServerError,
})
return
}
b, _ := json.Marshal(types)
_, _ = w.Write(b)
}

View File

@ -98,6 +98,34 @@
"badges.modal.allowlist_grant_help": "Users who can grant badges of this type.", "badges.modal.allowlist_grant_help": "Users who can grant badges of this type.",
"badges.modal.allowlist_placeholder": "user-1, user-2, user-3", "badges.modal.allowlist_placeholder": "user-1, user-2, user-3",
"badges.grant.title": "Grant Badge",
"badges.grant.intro": "Grant badge to @{username}",
"badges.grant.field_badge": "Badge",
"badges.grant.field_badge_placeholder": "Select a badge",
"badges.grant.no_badges": "No badges available",
"badges.grant.field_reason": "Reason",
"badges.grant.field_reason_placeholder": "Why is this badge being granted? (optional)",
"badges.grant.notify_here": "Notify in channel",
"badges.grant.btn_grant": "Grant",
"badges.subscription.title_create": "Add Subscription",
"badges.subscription.title_delete": "Remove Subscription",
"badges.subscription.field_type": "Badge Type",
"badges.subscription.field_type_placeholder": "Select badge type",
"badges.subscription.no_types": "No types available",
"badges.subscription.btn_create": "Add",
"badges.subscription.btn_delete": "Remove",
"badges.error.invalid_badge_id": "Badge not specified",
"badges.error.invalid_user_id": "User not specified",
"badges.error.no_permission_grant": "Insufficient permissions to grant this badge",
"badges.error.cannot_grant_badge": "Failed to grant badge",
"badges.error.user_not_found": "User not found",
"badges.error.invalid_type_id": "Badge 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.unknown": "An error occurred", "badges.error.unknown": "An error occurred",
"badges.error.cannot_get_user": "Failed to get user data", "badges.error.cannot_get_user": "Failed to get user data",
"badges.error.cannot_get_types": "Failed to load types", "badges.error.cannot_get_types": "Failed to load types",

View File

@ -98,6 +98,34 @@
"badges.modal.allowlist_grant_help": "Пользователи, которые могут выдавать значки этого типа.", "badges.modal.allowlist_grant_help": "Пользователи, которые могут выдавать значки этого типа.",
"badges.modal.allowlist_placeholder": "user-1, user-2, user-3", "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.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.unknown": "Произошла ошибка", "badges.error.unknown": "Произошла ошибка",
"badges.error.cannot_get_user": "Не удалось получить данные пользователя", "badges.error.cannot_get_user": "Не удалось получить данные пользователя",
"badges.error.cannot_get_types": "Не удалось загрузить типы", "badges.error.cannot_get_types": "Не удалось загрузить типы",

View File

@ -17,4 +17,8 @@ export default {
CLOSE_CREATE_TYPE_MODAL: pluginId + '_close_create_type_modal', CLOSE_CREATE_TYPE_MODAL: pluginId + '_close_create_type_modal',
OPEN_EDIT_TYPE_MODAL: pluginId + '_open_edit_type_modal', OPEN_EDIT_TYPE_MODAL: pluginId + '_open_edit_type_modal',
CLOSE_EDIT_TYPE_MODAL: pluginId + '_close_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',
}; };

View File

@ -1,14 +1,8 @@
import {AnyAction, Dispatch} from 'redux'; 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 ActionTypes from 'action_types/';
import {BadgeDetails, BadgeID, BadgeTypeDefinition} from 'types/badges'; import {BadgeDetails, BadgeID, BadgeTypeDefinition} from 'types/badges';
import {RHSState} from 'types/general'; import {GrantModalData, RHSState, SubscriptionModalData} from 'types/general';
/** /**
* Stores`showRHSPlugin` action returned by * Stores`showRHSPlugin` action returned by
@ -49,35 +43,16 @@ export function setRHSType(typeId: number | null, typeName: string | null) {
}; };
} }
export function setTriggerId(triggerId: string) {
return {
type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
data: triggerId,
};
}
export function openGrant(user?: string, badge?: string) { export function openGrant(user?: string, badge?: string) {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => { return (dispatch: Dispatch<AnyAction>) => {
let command = '/badges grant'; dispatch(openGrantModal({prefillUser: user, prefillBadgeId: badge}));
if (user) {
command += ` --user ${user}`;
}
if (badge) {
command += ` --badge ${badge}`;
}
clientExecuteCommand(dispatch, getState, command);
return {data: true}; return {data: true};
}; };
} }
export function openCreateType() { export function openCreateType() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => { return (dispatch: Dispatch<AnyAction>) => {
const command = '/badges create type'; dispatch(openCreateTypeModal());
clientExecuteCommand(dispatch, getState, command);
return {data: true}; return {data: true};
}; };
} }
@ -121,43 +96,33 @@ export function closeEditTypeModal() {
return {type: ActionTypes.CLOSE_EDIT_TYPE_MODAL}; return {type: ActionTypes.CLOSE_EDIT_TYPE_MODAL};
} }
export function openAddSubscription() { export function openGrantModal(data?: GrantModalData) {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => { return {type: ActionTypes.OPEN_GRANT_MODAL, data: data || {}};
const command = '/badges subscription create'; }
clientExecuteCommand(dispatch, getState, command);
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}; return {data: true};
}; };
} }
export function openRemoveSubscription() { export function openRemoveSubscription() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => { return (dispatch: Dispatch<AnyAction>) => {
const command = '/badges subscription remove'; dispatch(openSubscriptionModal({mode: 'delete'}));
clientExecuteCommand(dispatch, getState, command);
return {data: true}; 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
}
}

View File

@ -5,7 +5,7 @@ import {Client4} from 'mattermost-redux/client';
import {ClientError} from 'mattermost-redux/client/client4'; import {ClientError} from 'mattermost-redux/client/client4';
import manifest from 'manifest'; import manifest from 'manifest';
import {AllBadgesBadge, Badge, BadgeDetails, BadgeID, BadgeTypeDefinition, CreateBadgeRequest, CreateTypeRequest, GetTypesResponse, UpdateBadgeRequest, UpdateTypeRequest, UserBadge} from 'types/badges'; import {AllBadgesBadge, Badge, BadgeDetails, BadgeID, BadgeTypeDefinition, CreateBadgeRequest, CreateTypeRequest, GetTypesResponse, GrantBadgeRequest, SubscriptionRequest, UpdateBadgeRequest, UpdateTypeRequest, UserBadge} from 'types/badges';
export default class Client { export default class Client {
private url: string; private url: string;
@ -74,6 +74,27 @@ export default class Client {
await this.doDelete(`${this.url}/deleteType/${typeID}`); 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 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} = {}) => { private doGet = async (url: string, headers: {[x:string]: string} = {}) => {
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset()); headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());

View File

@ -1,3 +1,37 @@
@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 { .BadgeModal {
position: fixed; position: fixed;
top: 0; top: 0;
@ -16,6 +50,7 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
animation: badgeModalBackdropIn 0.2s ease-out;
} }
&__dialog { &__dialog {
@ -30,6 +65,27 @@
max-height: 90vh; max-height: 90vh;
display: flex; display: flex;
flex-direction: column; 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 { &__header {
@ -66,6 +122,12 @@
flex: 1; flex: 1;
} }
.grant-intro {
font-size: 14px;
margin: 0 0 16px;
opacity: 0.72;
}
&__footer { &__footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@ -84,6 +146,11 @@
margin-bottom: 4px; margin-bottom: 4px;
text-transform: uppercase; text-transform: uppercase;
opacity: 0.64; opacity: 0.64;
.required {
color: var(--error-text, #d24b4e);
margin-left: 2px;
}
} }
> input[type='text'], > input[type='text'],

View File

@ -60,6 +60,7 @@ const BadgeModal: React.FC = () => {
const [confirmDeleteTypeId, setConfirmDeleteTypeId] = useState<string | null>(null); const [confirmDeleteTypeId, setConfirmDeleteTypeId] = useState<string | null>(null);
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [closing, setClosing] = useState(false);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
@ -114,15 +115,21 @@ const BadgeModal: React.FC = () => {
setLoading(false); setLoading(false);
}, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps }, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps
const handleClose = useCallback(() => { const doClose = useCallback(() => {
if (createVisible) { if (createVisible) {
dispatch(closeCreateBadgeModal()); dispatch(closeCreateBadgeModal());
} }
if (editData) { if (editData) {
dispatch(closeEditBadgeModal()); dispatch(closeEditBadgeModal());
} }
setClosing(false);
}, [dispatch, createVisible, editData]); }, [dispatch, createVisible, editData]);
const handleClose = useCallback(() => {
setClosing(true);
setTimeout(doClose, 150);
}, [doClose]);
const handleTypeSelect = useCallback((val: string) => { const handleTypeSelect = useCallback((val: string) => {
if (val === NEW_TYPE_VALUE) { if (val === NEW_TYPE_VALUE) {
setShowCreateType(true); setShowCreateType(true);
@ -240,7 +247,7 @@ const BadgeModal: React.FC = () => {
} }
}, [editData, confirmDelete, handleClose, intl, dispatch]); }, [editData, confirmDelete, handleClose, intl, dispatch]);
if (!isOpen) { if (!isOpen && !closing) {
return null; return null;
} }
@ -253,7 +260,7 @@ const BadgeModal: React.FC = () => {
return ( return (
<div <div
className='BadgeModal' className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}
ref={modalRef} ref={modalRef}
> >
<div <div
@ -280,6 +287,7 @@ const BadgeModal: React.FC = () => {
id='badges.modal.field_name' id='badges.modal.field_name'
defaultMessage='Название' defaultMessage='Название'
/> />
<span className='required'>{'*'}</span>
</label> </label>
<input <input
type='text' type='text'
@ -309,6 +317,7 @@ const BadgeModal: React.FC = () => {
id='badges.modal.field_image' id='badges.modal.field_image'
defaultMessage='Эмодзи' defaultMessage='Эмодзи'
/> />
<span className='required'>{'*'}</span>
</label> </label>
<div className='emoji-input'> <div className='emoji-input'>
<button <button
@ -351,6 +360,7 @@ const BadgeModal: React.FC = () => {
id='badges.modal.field_type' id='badges.modal.field_type'
defaultMessage='Тип' defaultMessage='Тип'
/> />
<span className='required'>{'*'}</span>
</label> </label>
<TypeSelect <TypeSelect
types={types} types={types}

View File

@ -21,6 +21,7 @@ const InlineTypeForm: React.FC<Props> = ({form, onChange}) => {
id='badges.modal.new_type_name' id='badges.modal.new_type_name'
defaultMessage='Название типа' defaultMessage='Название типа'
/> />
<span className='required'>{'*'}</span>
</label> </label>
<input <input
type='text' type='text'

View 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/utils/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) => {
Review

получается что есть api а есть еще какое-то апи, на твое усмотрение можно запихнуть в api плагина

получается что есть api а есть еще какое-то апи, на твое усмотрение можно запихнуть в api плагина
setForm((prev) => ({
...prev,
userId: user.id,
userDisplayName: getUserDisplayName(user) || user.username,
}));
}).catch(() => {
// Если пользователь не найден — игнорируем
});
}
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// Закрытие выпадающих списков при клике снаружи
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (badgeDropdownRef.current && !badgeDropdownRef.current.contains(e.target as Node)) {
setBadgeDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const doClose = useCallback(() => {
dispatch(closeGrantModal());
setClosing(false);
}, [dispatch]);
const handleClose = useCallback(() => {
setClosing(true);
setTimeout(doClose, 150);
Review

на твое усмотрение - вынести число в константу

на твое усмотрение - вынести число в константу
}, [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;

View File

@ -41,12 +41,14 @@ const RHS: React.FC = () => {
const [canEditType, setCanEditType] = useState(false); const [canEditType, setCanEditType] = useState(false);
const [canCreateType, setCanCreateType] = useState(false); const [canCreateType, setCanCreateType] = useState(false);
const [canCreateBadge, setCanCreateBadge] = useState(false);
useEffect(() => { useEffect(() => {
const client = new Client(); const client = new Client();
client.getTypes().then((resp) => { client.getTypes().then((resp) => {
setCanEditType(resp.can_edit_type); setCanEditType(resp.can_edit_type);
setCanCreateType(resp.can_create_type); setCanCreateType(resp.can_create_type);
setCanCreateBadge(resp.types.length > 0 || resp.can_create_type);
}); });
}, []); }, []);
@ -89,7 +91,7 @@ const RHS: React.FC = () => {
</button> </button>
)} )}
</div> </div>
{currentView === RHS_STATE_ALL && ( {currentView === RHS_STATE_ALL && canCreateBadge && (
<button <button
className='AllBadges__createButton' className='AllBadges__createButton'
onClick={handleCreateBadge} onClick={handleCreateBadge}

View 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;

View File

@ -31,6 +31,7 @@ const TypeModal: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [closing, setClosing] = useState(false);
const updateForm = useCallback((updates: Partial<TypeFormData>) => { const updateForm = useCallback((updates: Partial<TypeFormData>) => {
setForm((prev) => ({...prev, ...updates})); setForm((prev) => ({...prev, ...updates}));
@ -57,15 +58,21 @@ const TypeModal: React.FC = () => {
setLoading(false); setLoading(false);
}, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps }, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps
const handleClose = useCallback(() => { const doClose = useCallback(() => {
if (createVisible) { if (createVisible) {
dispatch(closeCreateTypeModal()); dispatch(closeCreateTypeModal());
} }
if (editData) { if (editData) {
dispatch(closeEditTypeModal()); dispatch(closeEditTypeModal());
} }
setClosing(false);
}, [dispatch, createVisible, editData]); }, [dispatch, createVisible, editData]);
const handleClose = useCallback(() => {
setClosing(true);
setTimeout(doClose, 150);
}, [doClose]);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -112,7 +119,7 @@ const TypeModal: React.FC = () => {
} }
}, [editData, confirmDelete, handleClose, intl]); }, [editData, confirmDelete, handleClose, intl]);
if (!isOpen) { if (!isOpen && !closing) {
return null; return null;
} }
@ -124,7 +131,7 @@ const TypeModal: React.FC = () => {
: intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'}); : intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'});
return ( return (
<div className='BadgeModal'> <div className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}>
<div <div
className='BadgeModal__backdrop' className='BadgeModal__backdrop'
onClick={handleClose} onClick={handleClose}
@ -146,6 +153,7 @@ const TypeModal: React.FC = () => {
id='badges.modal.field_name' id='badges.modal.field_name'
defaultMessage='Название' defaultMessage='Название'
/> />
<span className='required'>{'*'}</span>
</label> </label>
<input <input
type='text' type='text'
@ -184,7 +192,7 @@ const TypeModal: React.FC = () => {
<span className='form-group__help'> <span className='form-group__help'>
<FormattedMessage <FormattedMessage
id='badges.modal.allowlist_create_help' id='badges.modal.allowlist_create_help'
defaultMessage='Пользователи, кото<EFBFBD><EFBFBD>ые могут создавать значки этого типа.' defaultMessage='Пользователи, которые могут создавать значки этого типа.'
/> />
</span> </span>
</div> </div>

View File

@ -22,4 +22,6 @@ export const initialState: PluginState = {
editBadgeModalData: null, editBadgeModalData: null,
createTypeModalVisible: false, createTypeModalVisible: false,
editTypeModalData: null, editTypeModalData: null,
grantModalData: null,
subscriptionModalData: null,
}; };

View File

@ -19,6 +19,8 @@ import {openAddSubscription, openCreateBadge, openCreateType, openRemoveSubscrip
import RHSComponent from 'components/rhs'; import RHSComponent from 'components/rhs';
import BadgeModal from 'components/badge_modal'; import BadgeModal from 'components/badge_modal';
import TypeModal from 'components/type_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'; import ChannelHeaderButton from 'components/channel_header_button';
@ -64,6 +66,8 @@ export default class Plugin {
registry.registerRootComponent(withIntl(BadgeModal)); registry.registerRootComponent(withIntl(BadgeModal));
registry.registerRootComponent(withIntl(TypeModal)); registry.registerRootComponent(withIntl(TypeModal));
registry.registerRootComponent(withIntl(GrantModal));
registry.registerRootComponent(withIntl(SubscriptionModal));
const locale = getCurrentUser(store.getState())?.locale || 'ru'; const locale = getCurrentUser(store.getState())?.locale || 'ru';
const messages = getTranslations(locale); const messages = getTranslations(locale);

View File

@ -103,6 +103,28 @@ function editTypeModalData(state = null, action: GenericAction) {
} }
} }
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({ export default combineReducers({
showRHS, showRHS,
rhsView, rhsView,
@ -114,4 +136,6 @@ export default combineReducers({
editBadgeModalData, editBadgeModalData,
createTypeModalVisible, createTypeModalVisible,
editTypeModalData, editTypeModalData,
grantModalData,
subscriptionModalData,
}); });

View File

@ -85,3 +85,17 @@ export const getEditTypeModalData = createSelector(
return state.editTypeModalData; return state.editTypeModalData;
}, },
); );
export const getGrantModalData = createSelector(
getPluginState,
(state) => {
return state.grantModalData;
},
);
export const getSubscriptionModalData = createSelector(
getPluginState,
(state) => {
return state.subscriptionModalData;
},
);

View File

@ -115,3 +115,16 @@ export type UpdateTypeRequest = {
allowlist_can_create: string; allowlist_can_create: string;
allowlist_can_grant: 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;
}

View File

@ -2,6 +2,15 @@ import {BadgeDetails, BadgeID, BadgeTypeDefinition} from './badges';
export type RHSState = string; export type RHSState = string;
export type GrantModalData = {
prefillUser?: string;
prefillBadgeId?: string;
}
export type SubscriptionModalData = {
mode: 'create' | 'delete';
}
export type PluginState = { export type PluginState = {
showRHS: (() => void)| null; showRHS: (() => void)| null;
rhsView: RHSState; rhsView: RHSState;
@ -13,4 +22,6 @@ export type PluginState = {
editBadgeModalData: BadgeDetails | null; editBadgeModalData: BadgeDetails | null;
createTypeModalVisible: boolean; createTypeModalVisible: boolean;
editTypeModalData: BadgeTypeDefinition | null; editTypeModalData: BadgeTypeDefinition | null;
grantModalData: GrantModalData | null;
subscriptionModalData: SubscriptionModalData | null;
} }