Content flagging systems console settings (#31411)
* Added enable/disable setting and feature flag * added rest of notifgication settings * Added backend for content flagging setting and populated notification values from server side defaults * WIP user selector * Added common reviewers UI * Added additonal reviewers section * WIP * WIP * Team table base * Added search in teams * Added search in teams * Added additional settings section * WIP * Inbtegrated reviewers settings * WIP * WIP * Added server side validation * cleanup * cleanup * [skip ci] * Some refactoring * type fixes * lint fix * test: add content flagging settings test file * test: add comprehensive unit tests for content flagging settings * enhanced tests * test: add test file for content flagging additional settings * test: add comprehensive unit tests for ContentFlaggingAdditionalSettingsSection * Added additoonal settings test * test: add empty test file for team reviewers section * test: add comprehensive unit tests for TeamReviewersSection component * test: update tests to handle async data fetching in team reviewers section * test: add empty test file for content reviewers component * feat: add comprehensive unit tests for ContentFlaggingContentReviewers component * Added ContentFlaggingContentReviewersContentFlaggingContentReviewers test * test: add notification settings test file for content flagging * test: add comprehensive unit tests for content flagging notification settings * Added ContentFlaggingNotificationSettingsSection tests * test: add user profile pill test file * test: add comprehensive unit tests for UserProfilePill component * refactor: Replace enzyme shallow with renderWithContext in user_profile_pill tests * Added UserProfilePill tests * test: add empty test file for content reviewers team option * test: add comprehensive unit tests for TeamOptionComponent * Added TeamOptionComponent tests * test: add empty test file for reason_option component * test: add comprehensive unit tests for ReasonOption component * Added ReasonOption tests * cleanup * Fixed i18n error * fixed e2e test lijnt issues * Updated test cases * Added snaoshot * Updated snaoshot * lint fix * lint fix * review fixes * updated snapshot * CI * Review fixes * Removed an test, updated comment * CI * Test update
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
254f641182
Коммит
d1e5fdea2c
@@ -804,4 +804,27 @@ const defaultServerConfig: AdminConfig = {
|
|||||||
EnableAttributeBasedAccessControl: false,
|
EnableAttributeBasedAccessControl: false,
|
||||||
EnableChannelScopeAccessControl: false,
|
EnableChannelScopeAccessControl: false,
|
||||||
},
|
},
|
||||||
|
ContentFlaggingSettings: {
|
||||||
|
NotificationSettings: {
|
||||||
|
ReviewerSettings: {
|
||||||
|
CommonReviewers: false,
|
||||||
|
CommonReviewerIds: [],
|
||||||
|
TeamReviewersSetting: {},
|
||||||
|
SystemAdminsAsReviewers: false,
|
||||||
|
TeamAdminsAsReviewers: false,
|
||||||
|
},
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers', 'author'],
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
removed: ['reviewers'],
|
||||||
|
dismissed: ['reviewers', 'author'],
|
||||||
|
},
|
||||||
|
AdditionalSettings: {
|
||||||
|
Reasons: ['Reason 1', 'Reason 2'],
|
||||||
|
ReporterCommentRequired: false,
|
||||||
|
ReviewerCommentRequired: false,
|
||||||
|
HideFlaggedContent: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9216,6 +9216,18 @@
|
|||||||
"id": "model.config.is_valid.collapsed_threads.autofollow.app_error",
|
"id": "model.config.is_valid.collapsed_threads.autofollow.app_error",
|
||||||
"translation": "ThreadAutoFollow must be true to enable CollapsedThreads"
|
"translation": "ThreadAutoFollow must be true to enable CollapsedThreads"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "model.config.is_valid.content_flagging.common_reviewers_not_set.app_error",
|
||||||
|
"translation": "Common reviewers or additional reviewers must be set when \"Same reviewers for all teams\" is enabled."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model.config.is_valid.content_flagging.reasons_not_set.app_error",
|
||||||
|
"translation": "Reasons for flagging cannot be empty."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model.config.is_valid.content_flagging.team_reviewers_not_set.app_error",
|
||||||
|
"translation": "Team reviewers or additional reviewers must be set when setting \"Enabled\" for a team."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "model.config.is_valid.data_retention.deletion_job_start_time.app_error",
|
"id": "model.config.is_valid.data_retention.deletion_job_start_time.app_error",
|
||||||
"translation": "Data retention job start time must be a 24-hour time stamp in the form HH:MM."
|
"translation": "Data retention job start time must be a 24-hour time stamp in the form HH:MM."
|
||||||
@@ -9552,6 +9564,18 @@
|
|||||||
"id": "model.config.is_valid.move_thread.domain_invalid.app_error",
|
"id": "model.config.is_valid.move_thread.domain_invalid.app_error",
|
||||||
"translation": "Invalid domain for move thread settings"
|
"translation": "Invalid domain for move thread settings"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "model.config.is_valid.notification_settings.invalid_event",
|
||||||
|
"translation": "Invalid flagging event specified."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model.config.is_valid.notification_settings.invalid_target",
|
||||||
|
"translation": "Invalid flaggig event target specified."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled",
|
||||||
|
"translation": "Notifications for new flagged post cannot be disabled for reviewers."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "model.config.is_valid.outgoing_integrations_request_timeout.app_error",
|
"id": "model.config.is_valid.outgoing_integrations_request_timeout.app_error",
|
||||||
"translation": "Invalid Outgoing Integrations Request Timeout for service settings. Must be a positive number."
|
"translation": "Invalid Outgoing Integrations Request Timeout for service settings. Must be a positive number."
|
||||||
|
|||||||
@@ -3884,6 +3884,7 @@ type Config struct {
|
|||||||
WranglerSettings WranglerSettings
|
WranglerSettings WranglerSettings
|
||||||
ConnectedWorkspacesSettings ConnectedWorkspacesSettings
|
ConnectedWorkspacesSettings ConnectedWorkspacesSettings
|
||||||
AccessControlSettings AccessControlSettings
|
AccessControlSettings AccessControlSettings
|
||||||
|
ContentFlaggingSettings ContentFlaggingSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Config) Auditable() map[string]any {
|
func (o *Config) Auditable() map[string]any {
|
||||||
@@ -4002,6 +4003,7 @@ func (o *Config) SetDefaults() {
|
|||||||
o.WranglerSettings.SetDefaults()
|
o.WranglerSettings.SetDefaults()
|
||||||
o.ConnectedWorkspacesSettings.SetDefaults(isUpdate, o.ExperimentalSettings)
|
o.ConnectedWorkspacesSettings.SetDefaults(isUpdate, o.ExperimentalSettings)
|
||||||
o.AccessControlSettings.SetDefaults()
|
o.AccessControlSettings.SetDefaults()
|
||||||
|
o.ContentFlaggingSettings.SetDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Config) IsValid() *AppError {
|
func (o *Config) IsValid() *AppError {
|
||||||
@@ -4129,6 +4131,10 @@ func (o *Config) IsValid() *AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if appErr := o.ContentFlaggingSettings.IsValid(); appErr != nil {
|
||||||
|
return appErr
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
229
server/public/model/content_flagging_settings.go
Обычный файл
229
server/public/model/content_flagging_settings.go
Обычный файл
@@ -0,0 +1,229 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
type ContentFlaggingEvent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventFlagged ContentFlaggingEvent = "flagged"
|
||||||
|
EventAssigned ContentFlaggingEvent = "assigned"
|
||||||
|
EventContentRemoved ContentFlaggingEvent = "removed"
|
||||||
|
EventContentDismissed ContentFlaggingEvent = "dismissed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationTarget string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TargetReviewers NotificationTarget = "reviewers"
|
||||||
|
TargetAuthor NotificationTarget = "author"
|
||||||
|
TargetReporter NotificationTarget = "reporter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContentFlaggingNotificationSettings struct {
|
||||||
|
EventTargetMapping map[ContentFlaggingEvent][]NotificationTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfs *ContentFlaggingNotificationSettings) SetDefaults() {
|
||||||
|
if cfs.EventTargetMapping == nil {
|
||||||
|
cfs.EventTargetMapping = make(map[ContentFlaggingEvent][]NotificationTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := cfs.EventTargetMapping[EventFlagged]; !exists {
|
||||||
|
cfs.EventTargetMapping[EventFlagged] = []NotificationTarget{TargetReviewers}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := cfs.EventTargetMapping[EventAssigned]; !exists {
|
||||||
|
cfs.EventTargetMapping[EventAssigned] = []NotificationTarget{TargetReviewers}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := cfs.EventTargetMapping[EventContentRemoved]; !exists {
|
||||||
|
cfs.EventTargetMapping[EventContentRemoved] = []NotificationTarget{TargetReviewers, TargetAuthor, TargetReporter}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := cfs.EventTargetMapping[EventContentDismissed]; !exists {
|
||||||
|
cfs.EventTargetMapping[EventContentDismissed] = []NotificationTarget{TargetReviewers, TargetReporter}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfs *ContentFlaggingNotificationSettings) IsValid() *AppError {
|
||||||
|
// Reviewers must be notified when content is flagged
|
||||||
|
// Disabling this option is not allowed in the UI, so this check is for safety and consistency.
|
||||||
|
|
||||||
|
// Only valid events and targets are allowed
|
||||||
|
for event, targets := range cfs.EventTargetMapping {
|
||||||
|
if event != EventFlagged && event != EventAssigned && event != EventContentRemoved && event != EventContentDismissed {
|
||||||
|
return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.invalid_event", nil, "", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range targets {
|
||||||
|
if target != TargetReviewers && target != TargetAuthor && target != TargetReporter {
|
||||||
|
return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.invalid_target", nil, "", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfs.EventTargetMapping[EventFlagged] == nil || len(cfs.EventTargetMapping[EventFlagged]) == 0 {
|
||||||
|
return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", nil, "", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the TargetReviewers in the EventFlagged event
|
||||||
|
reviewerFound := false
|
||||||
|
for _, target := range cfs.EventTargetMapping[EventFlagged] {
|
||||||
|
if target == TargetReviewers {
|
||||||
|
reviewerFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reviewerFound {
|
||||||
|
return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", nil, "", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamReviewerSetting struct {
|
||||||
|
Enabled *bool
|
||||||
|
ReviewerIds *[]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewerSettings struct {
|
||||||
|
CommonReviewers *bool
|
||||||
|
CommonReviewerIds *[]string
|
||||||
|
TeamReviewersSetting *map[string]TeamReviewerSetting
|
||||||
|
SystemAdminsAsReviewers *bool
|
||||||
|
TeamAdminsAsReviewers *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *ReviewerSettings) SetDefaults() {
|
||||||
|
if rs.CommonReviewers == nil {
|
||||||
|
rs.CommonReviewers = NewPointer(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rs.CommonReviewerIds == nil {
|
||||||
|
rs.CommonReviewerIds = &[]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rs.TeamReviewersSetting == nil {
|
||||||
|
rs.TeamReviewersSetting = &map[string]TeamReviewerSetting{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rs.SystemAdminsAsReviewers == nil {
|
||||||
|
rs.SystemAdminsAsReviewers = NewPointer(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rs.TeamAdminsAsReviewers == nil {
|
||||||
|
rs.TeamAdminsAsReviewers = NewPointer(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *ReviewerSettings) IsValid() *AppError {
|
||||||
|
additionalReviewersEnabled := *rs.SystemAdminsAsReviewers || *rs.TeamAdminsAsReviewers
|
||||||
|
|
||||||
|
// If common reviewers are enabled, there must be at least one specified reviewer, or additional viewers be specified
|
||||||
|
if *rs.CommonReviewers && (rs.CommonReviewerIds == nil || len(*rs.CommonReviewerIds) == 0) && !additionalReviewersEnabled {
|
||||||
|
return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.common_reviewers_not_set.app_error", nil, "", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if additional reviewers are specified, no extra validation is needed in team specific settings as
|
||||||
|
// settings team reviewers keeping team feature disabled is valid, as well as
|
||||||
|
// enabling team feature and not specified reviews is fine as well (since additional reviewers are set)
|
||||||
|
if !additionalReviewersEnabled {
|
||||||
|
for _, setting := range *rs.TeamReviewersSetting {
|
||||||
|
if *setting.Enabled && (setting.ReviewerIds == nil || len(*setting.ReviewerIds) == 0) {
|
||||||
|
return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.team_reviewers_not_set.app_error", nil, "", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdditionalContentFlaggingSettings struct {
|
||||||
|
Reasons *[]string
|
||||||
|
ReporterCommentRequired *bool
|
||||||
|
ReviewerCommentRequired *bool
|
||||||
|
HideFlaggedContent *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acfs *AdditionalContentFlaggingSettings) SetDefaults() {
|
||||||
|
if acfs.Reasons == nil {
|
||||||
|
acfs.Reasons = &[]string{
|
||||||
|
"Inappropriate content",
|
||||||
|
"Sensitive data",
|
||||||
|
"Security concern",
|
||||||
|
"Harassment or abuse",
|
||||||
|
"Spam or phishing",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if acfs.ReporterCommentRequired == nil {
|
||||||
|
acfs.ReporterCommentRequired = NewPointer(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if acfs.ReviewerCommentRequired == nil {
|
||||||
|
acfs.ReviewerCommentRequired = NewPointer(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if acfs.HideFlaggedContent == nil {
|
||||||
|
acfs.HideFlaggedContent = NewPointer(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acfs *AdditionalContentFlaggingSettings) IsValid() *AppError {
|
||||||
|
if acfs.Reasons == nil || len(*acfs.Reasons) == 0 {
|
||||||
|
return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.reasons_not_set.app_error", nil, "", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentFlaggingSettings struct {
|
||||||
|
EnableContentFlagging *bool
|
||||||
|
ReviewerSettings *ReviewerSettings
|
||||||
|
NotificationSettings *ContentFlaggingNotificationSettings
|
||||||
|
AdditionalSettings *AdditionalContentFlaggingSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfs *ContentFlaggingSettings) SetDefaults() {
|
||||||
|
if cfs.EnableContentFlagging == nil {
|
||||||
|
cfs.EnableContentFlagging = NewPointer(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfs.NotificationSettings == nil {
|
||||||
|
cfs.NotificationSettings = &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: make(map[ContentFlaggingEvent][]NotificationTarget),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfs.ReviewerSettings == nil {
|
||||||
|
cfs.ReviewerSettings = &ReviewerSettings{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfs.AdditionalSettings == nil {
|
||||||
|
cfs.AdditionalSettings = &AdditionalContentFlaggingSettings{}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfs.NotificationSettings.SetDefaults()
|
||||||
|
cfs.ReviewerSettings.SetDefaults()
|
||||||
|
cfs.AdditionalSettings.SetDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfs *ContentFlaggingSettings) IsValid() *AppError {
|
||||||
|
if err := cfs.NotificationSettings.IsValid(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfs.ReviewerSettings.IsValid(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfs.AdditionalSettings.IsValid(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
360
server/public/model/content_flagging_settings_test.go
Обычный файл
360
server/public/model/content_flagging_settings_test.go
Обычный файл
@@ -0,0 +1,360 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContentFlaggingNotificationSettings_SetDefault(t *testing.T) {
|
||||||
|
t.Run("should set default event target mappings", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingNotificationSettings{}
|
||||||
|
settings.SetDefaults()
|
||||||
|
|
||||||
|
require.Nil(t, settings.IsValid())
|
||||||
|
|
||||||
|
require.NotNil(t, settings.EventTargetMapping)
|
||||||
|
require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventFlagged])
|
||||||
|
require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventAssigned])
|
||||||
|
require.Equal(t, []NotificationTarget{TargetReviewers, TargetAuthor, TargetReporter}, settings.EventTargetMapping[EventContentRemoved])
|
||||||
|
require.Equal(t, []NotificationTarget{TargetReviewers, TargetReporter}, settings.EventTargetMapping[EventContentDismissed])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should not override existing mappings", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
|
||||||
|
EventFlagged: {TargetReviewers},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
settings.SetDefaults()
|
||||||
|
|
||||||
|
require.Nil(t, settings.IsValid())
|
||||||
|
require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventFlagged])
|
||||||
|
require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventAssigned])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentFlaggingNotificationSettings_IsValid(t *testing.T) {
|
||||||
|
t.Run("should be valid when reviewers are notified for flagged events", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
|
||||||
|
EventFlagged: {TargetReviewers, TargetAuthor},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.Nil(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when no targets for flagged events", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
|
||||||
|
EventFlagged: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", err.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when flagged event mapping is nil", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", err.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when reviewers not included in flagged event targets", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
|
||||||
|
EventFlagged: {TargetAuthor, TargetReporter},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", err.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when invalid events and targets are specified", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
|
||||||
|
"invalid_event": {TargetAuthor, TargetReporter},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.notification_settings.invalid_event", err.Id)
|
||||||
|
|
||||||
|
settings = &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
|
||||||
|
EventFlagged: {"invalid_target_1", "invalid_target_2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.notification_settings.invalid_target", err.Id)
|
||||||
|
|
||||||
|
settings = &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
|
||||||
|
"invalid_event": {"invalid_target_1", "invalid_target_2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.notification_settings.invalid_event", err.Id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewerSettings_SetDefault(t *testing.T) {
|
||||||
|
t.Run("should not override existing values", func(t *testing.T) {
|
||||||
|
commonReviewers := false
|
||||||
|
settings := &ReviewerSettings{
|
||||||
|
CommonReviewers: &commonReviewers,
|
||||||
|
}
|
||||||
|
settings.SetDefaults()
|
||||||
|
|
||||||
|
require.Nil(t, settings.IsValid())
|
||||||
|
require.False(t, *settings.CommonReviewers)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewerSettings_IsValid(t *testing.T) {
|
||||||
|
t.Run("should be valid when common reviewers enabled with reviewer IDs", func(t *testing.T) {
|
||||||
|
settings := &ReviewerSettings{
|
||||||
|
CommonReviewers: NewPointer(true),
|
||||||
|
CommonReviewerIds: &[]string{"user1", "user2"},
|
||||||
|
TeamReviewersSetting: &map[string]TeamReviewerSetting{},
|
||||||
|
SystemAdminsAsReviewers: NewPointer(false),
|
||||||
|
TeamAdminsAsReviewers: NewPointer(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.Nil(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be valid when common reviewers enabled with additional reviewers", func(t *testing.T) {
|
||||||
|
settings := &ReviewerSettings{
|
||||||
|
CommonReviewers: NewPointer(true),
|
||||||
|
CommonReviewerIds: &[]string{},
|
||||||
|
TeamReviewersSetting: &map[string]TeamReviewerSetting{},
|
||||||
|
SystemAdminsAsReviewers: NewPointer(true),
|
||||||
|
TeamAdminsAsReviewers: NewPointer(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.Nil(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when common reviewers enabled but no reviewers specified", func(t *testing.T) {
|
||||||
|
settings := &ReviewerSettings{
|
||||||
|
CommonReviewers: NewPointer(true),
|
||||||
|
CommonReviewerIds: &[]string{},
|
||||||
|
TeamReviewersSetting: &map[string]TeamReviewerSetting{},
|
||||||
|
SystemAdminsAsReviewers: NewPointer(false),
|
||||||
|
TeamAdminsAsReviewers: NewPointer(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.content_flagging.common_reviewers_not_set.app_error", err.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be valid when team reviewers enabled with reviewer IDs", func(t *testing.T) {
|
||||||
|
settings := &ReviewerSettings{
|
||||||
|
CommonReviewers: NewPointer(false),
|
||||||
|
CommonReviewerIds: &[]string{},
|
||||||
|
TeamReviewersSetting: &map[string]TeamReviewerSetting{
|
||||||
|
"team1": {
|
||||||
|
Enabled: NewPointer(true),
|
||||||
|
ReviewerIds: &[]string{"user1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SystemAdminsAsReviewers: NewPointer(false),
|
||||||
|
TeamAdminsAsReviewers: NewPointer(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.Nil(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when team reviewers enabled but no reviewer IDs", func(t *testing.T) {
|
||||||
|
settings := &ReviewerSettings{
|
||||||
|
CommonReviewers: NewPointer(false),
|
||||||
|
CommonReviewerIds: &[]string{},
|
||||||
|
TeamReviewersSetting: &map[string]TeamReviewerSetting{
|
||||||
|
"team1": {
|
||||||
|
Enabled: NewPointer(true),
|
||||||
|
ReviewerIds: &[]string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SystemAdminsAsReviewers: NewPointer(false),
|
||||||
|
TeamAdminsAsReviewers: NewPointer(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.content_flagging.team_reviewers_not_set.app_error", err.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be valid when team reviewers enabled but no reviewer IDs with additional reviewers", func(t *testing.T) {
|
||||||
|
settings := &ReviewerSettings{
|
||||||
|
CommonReviewers: NewPointer(false),
|
||||||
|
CommonReviewerIds: &[]string{},
|
||||||
|
TeamReviewersSetting: &map[string]TeamReviewerSetting{
|
||||||
|
"team1": {
|
||||||
|
Enabled: NewPointer(true),
|
||||||
|
ReviewerIds: &[]string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SystemAdminsAsReviewers: NewPointer(true),
|
||||||
|
TeamAdminsAsReviewers: NewPointer(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.Nil(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdditionalContentFlaggingSettings_SetDefault(t *testing.T) {
|
||||||
|
t.Run("should not override existing values", func(t *testing.T) {
|
||||||
|
customReasons := []string{"Custom reason"}
|
||||||
|
settings := &AdditionalContentFlaggingSettings{
|
||||||
|
Reasons: &customReasons,
|
||||||
|
}
|
||||||
|
settings.SetDefaults()
|
||||||
|
|
||||||
|
require.Nil(t, settings.IsValid())
|
||||||
|
require.Equal(t, customReasons, *settings.Reasons)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdditionalContentFlaggingSettings_IsValid(t *testing.T) {
|
||||||
|
t.Run("should be valid when reasons are provided", func(t *testing.T) {
|
||||||
|
settings := &AdditionalContentFlaggingSettings{
|
||||||
|
Reasons: &[]string{"Reason 1", "Reason 2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.Nil(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when reasons are nil", func(t *testing.T) {
|
||||||
|
settings := &AdditionalContentFlaggingSettings{
|
||||||
|
Reasons: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.content_flagging.reasons_not_set.app_error", err.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when reasons are empty", func(t *testing.T) {
|
||||||
|
settings := &AdditionalContentFlaggingSettings{
|
||||||
|
Reasons: &[]string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "model.config.is_valid.content_flagging.reasons_not_set.app_error", err.Id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentFlaggingSettings_SetDefault(t *testing.T) {
|
||||||
|
t.Run("should not override existing values", func(t *testing.T) {
|
||||||
|
enabled := true
|
||||||
|
settings := &ContentFlaggingSettings{
|
||||||
|
EnableContentFlagging: &enabled,
|
||||||
|
}
|
||||||
|
settings.SetDefaults()
|
||||||
|
|
||||||
|
require.Nil(t, settings.IsValid())
|
||||||
|
require.True(t, *settings.EnableContentFlagging)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentFlaggingSettings_IsValid(t *testing.T) {
|
||||||
|
t.Run("should be valid when all nested settings are valid", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingSettings{}
|
||||||
|
settings.SetDefaults()
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.Nil(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when notification settings are invalid", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingSettings{
|
||||||
|
NotificationSettings: &ContentFlaggingNotificationSettings{
|
||||||
|
EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{
|
||||||
|
EventFlagged: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ReviewerSettings: &ReviewerSettings{},
|
||||||
|
AdditionalSettings: &AdditionalContentFlaggingSettings{},
|
||||||
|
}
|
||||||
|
settings.ReviewerSettings.SetDefaults()
|
||||||
|
settings.AdditionalSettings.SetDefaults()
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Contains(t, err.Id, "notification_settings")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when reviewer settings are invalid", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingSettings{
|
||||||
|
NotificationSettings: &ContentFlaggingNotificationSettings{},
|
||||||
|
ReviewerSettings: &ReviewerSettings{
|
||||||
|
CommonReviewers: NewPointer(true),
|
||||||
|
CommonReviewerIds: &[]string{},
|
||||||
|
TeamReviewersSetting: &map[string]TeamReviewerSetting{},
|
||||||
|
SystemAdminsAsReviewers: NewPointer(false),
|
||||||
|
TeamAdminsAsReviewers: NewPointer(false),
|
||||||
|
},
|
||||||
|
AdditionalSettings: &AdditionalContentFlaggingSettings{},
|
||||||
|
}
|
||||||
|
settings.NotificationSettings.SetDefaults()
|
||||||
|
settings.AdditionalSettings.SetDefaults()
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Contains(t, err.Id, "common_reviewers_not_set")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be invalid when additional settings are invalid", func(t *testing.T) {
|
||||||
|
settings := &ContentFlaggingSettings{
|
||||||
|
NotificationSettings: &ContentFlaggingNotificationSettings{},
|
||||||
|
ReviewerSettings: &ReviewerSettings{},
|
||||||
|
AdditionalSettings: &AdditionalContentFlaggingSettings{
|
||||||
|
Reasons: &[]string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
settings.NotificationSettings.SetDefaults()
|
||||||
|
settings.ReviewerSettings.SetDefaults()
|
||||||
|
|
||||||
|
err := settings.IsValid()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Contains(t, err.Id, "reasons_not_set")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentFlaggingConstants(t *testing.T) {
|
||||||
|
t.Run("should have correct event constants", func(t *testing.T) {
|
||||||
|
require.Equal(t, ContentFlaggingEvent("flagged"), EventFlagged)
|
||||||
|
require.Equal(t, ContentFlaggingEvent("assigned"), EventAssigned)
|
||||||
|
require.Equal(t, ContentFlaggingEvent("removed"), EventContentRemoved)
|
||||||
|
require.Equal(t, ContentFlaggingEvent("dismissed"), EventContentDismissed)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should have correct target constants", func(t *testing.T) {
|
||||||
|
require.Equal(t, NotificationTarget("reviewers"), TargetReviewers)
|
||||||
|
require.Equal(t, NotificationTarget("author"), TargetAuthor)
|
||||||
|
require.Equal(t, NotificationTarget("reporter"), TargetReporter)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ type FeatureFlags struct {
|
|||||||
CustomProfileAttributes bool
|
CustomProfileAttributes bool
|
||||||
|
|
||||||
AttributeBasedAccessControl bool
|
AttributeBasedAccessControl bool
|
||||||
|
|
||||||
|
ContentFlagging bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FeatureFlags) SetDefaults() {
|
func (f *FeatureFlags) SetDefaults() {
|
||||||
@@ -96,6 +98,7 @@ func (f *FeatureFlags) SetDefaults() {
|
|||||||
f.ExperimentalAuditSettingsSystemConsoleUI = true
|
f.ExperimentalAuditSettingsSystemConsoleUI = true
|
||||||
f.CustomProfileAttributes = true
|
f.CustomProfileAttributes = true
|
||||||
f.AttributeBasedAccessControl = true
|
f.AttributeBasedAccessControl = true
|
||||||
|
f.ContentFlagging = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToMap returns the feature flags as a map[string]string
|
// ToMap returns the feature flags as a map[string]string
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import {
|
|||||||
} from 'actions/admin_actions';
|
} from 'actions/admin_actions';
|
||||||
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
||||||
|
|
||||||
|
import ContentFlaggingAdditionalSettingsSection from 'components/admin_console/content_flagging/additional_settings/additional_settings';
|
||||||
|
import ContentFlaggingContentReviewers from 'components/admin_console/content_flagging/content_reviewers/content_reviewers';
|
||||||
|
import ContentFlaggingNotificationSettingsSection from 'components/admin_console/content_flagging/notificatin_settings/notification_settings';
|
||||||
import CustomPluginSettings from 'components/admin_console/custom_plugin_settings';
|
import CustomPluginSettings from 'components/admin_console/custom_plugin_settings';
|
||||||
import CustomProfileAttributes from 'components/admin_console/custom_profile_attributes/custom_profile_attributes';
|
import CustomProfileAttributes from 'components/admin_console/custom_profile_attributes/custom_profile_attributes';
|
||||||
import PluginManagement from 'components/admin_console/plugin_management';
|
import PluginManagement from 'components/admin_console/plugin_management';
|
||||||
@@ -3266,6 +3269,43 @@ const AdminDefinition: AdminDefinitionType = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
content_flagging: {
|
||||||
|
url: 'site_config/content_flagging',
|
||||||
|
title: defineMessage({id: 'admin.sidebar.contentFlagging', defaultMessage: 'Content Flagging'}),
|
||||||
|
isHidden: it.any(
|
||||||
|
it.not(it.licensedForSku(LicenseSkus.EnterpriseAdvanced)),
|
||||||
|
it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)),
|
||||||
|
it.configIsFalse('FeatureFlags', 'ContentFlagging'),
|
||||||
|
),
|
||||||
|
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)),
|
||||||
|
restrictedIndicator: getRestrictedIndicator(false, LicenseSkus.EnterpriseAdvanced),
|
||||||
|
schema: {
|
||||||
|
id: 'ContentFlaggingSettings',
|
||||||
|
name: defineMessage({id: 'admin.contentFlagging.title', defaultMessage: 'Content Flagging'}),
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
type: 'bool',
|
||||||
|
key: 'ContentFlaggingSettings.EnableContentFlagging',
|
||||||
|
label: defineMessage({id: 'admin.contentFlagging.enableTitle', defaultMessage: 'Enable Content Flagging'}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
key: 'ContentFlaggingSettings.ReviewerSettings',
|
||||||
|
component: ContentFlaggingContentReviewers,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
key: 'ContentFlaggingSettings.NotificationSettings',
|
||||||
|
component: ContentFlaggingNotificationSettingsSection,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
key: 'ContentFlaggingSettings.AdditionalSettings',
|
||||||
|
component: ContentFlaggingAdditionalSettingsSection,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
wrangler: {
|
wrangler: {
|
||||||
url: 'site_config/wrangler',
|
url: 'site_config/wrangler',
|
||||||
title: defineMessage({id: 'admin.sidebar.move_thread', defaultMessage: 'Move Thread (Beta)'}),
|
title: defineMessage({id: 'admin.sidebar.move_thread', defaultMessage: 'Move Thread (Beta)'}),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import * as Utils from 'utils/utils';
|
|||||||
|
|
||||||
import SettingSet from './setting_set';
|
import SettingSet from './setting_set';
|
||||||
|
|
||||||
const Label = styled.label<{isDisabled: boolean}>`
|
export const Label = styled.label<{isDisabled: boolean}>`
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
opacity: ${({isDisabled}) => (isDisabled ? 0.5 : 1)};
|
opacity: ${({isDisabled}) => (isDisabled ? 0.5 : 1)};
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
.contentFlaggingReasons {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&___value-container {
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {screen, fireEvent} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type {ContentFlaggingAdditionalSettings} from '@mattermost/types/config';
|
||||||
|
|
||||||
|
import type {SystemConsoleCustomSettingsComponentProps} from 'components/admin_console/schema_admin_settings';
|
||||||
|
|
||||||
|
import {renderWithContext} from 'tests/react_testing_utils';
|
||||||
|
|
||||||
|
import ContentFlaggingAdditionalSettingsSection from './additional_settings';
|
||||||
|
|
||||||
|
describe('ContentFlaggingAdditionalSettingsSection', () => {
|
||||||
|
const defaultProps: SystemConsoleCustomSettingsComponentProps = {
|
||||||
|
id: 'ContentFlaggingAdditionalSettings',
|
||||||
|
onChange: jest.fn(),
|
||||||
|
value: {
|
||||||
|
Reasons: ['Spam', 'Inappropriate'],
|
||||||
|
ReporterCommentRequired: false,
|
||||||
|
ReviewerCommentRequired: true,
|
||||||
|
HideFlaggedContent: false,
|
||||||
|
} as ContentFlaggingAdditionalSettings,
|
||||||
|
} as unknown as SystemConsoleCustomSettingsComponentProps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render with initial values', () => {
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Additional Settings')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Configure how you want the flagging to behave')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Reasons for flagging')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Require reporters to add comment')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Require reviewers to add comment')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hide message from channel while it is being reviewed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render initial reason options', () => {
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Spam')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Inappropriate')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have correct initial radio button states', () => {
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
// Reporter comment required - should be false
|
||||||
|
expect(screen.getByTestId('requireReporterComment_false')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('requireReporterComment_true')).not.toBeChecked();
|
||||||
|
|
||||||
|
// Reviewer comment required - should be true
|
||||||
|
expect(screen.getByTestId('requireReviewerComment_true')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('requireReviewerComment_false')).not.toBeChecked();
|
||||||
|
|
||||||
|
// Hide flagged posts - should be false
|
||||||
|
expect(screen.getByTestId('setHideFlaggedPosts_false')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('hideFlaggedPosts_true')).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call onChange when reporter comment requirement changes', () => {
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('requireReporterComment_true'));
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('ContentFlaggingAdditionalSettings', {
|
||||||
|
...(defaultProps.value as ContentFlaggingAdditionalSettings),
|
||||||
|
ReporterCommentRequired: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call onChange when reviewer comment requirement changes', () => {
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('requireReviewerComment_false'));
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('ContentFlaggingAdditionalSettings', {
|
||||||
|
...(defaultProps.value as ContentFlaggingAdditionalSettings),
|
||||||
|
ReviewerCommentRequired: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call onChange when hide flagged content setting changes', () => {
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('hideFlaggedPosts_true'));
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('ContentFlaggingAdditionalSettings', {
|
||||||
|
...(defaultProps.value as ContentFlaggingAdditionalSettings),
|
||||||
|
HideFlaggedContent: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty reasons array', () => {
|
||||||
|
const propsWithEmptyReasons = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
...(defaultProps.value as ContentFlaggingAdditionalSettings),
|
||||||
|
Reasons: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...propsWithEmptyReasons}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Reasons for flagging')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Spam')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Inappropriate')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle all boolean settings as true', () => {
|
||||||
|
const propsAllTrue = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
...(defaultProps.value as ContentFlaggingAdditionalSettings),
|
||||||
|
ReporterCommentRequired: true,
|
||||||
|
ReviewerCommentRequired: true,
|
||||||
|
HideFlaggedContent: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...propsAllTrue}/>);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('requireReporterComment_true')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('requireReviewerComment_true')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('hideFlaggedPosts_true')).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle all boolean settings as false', () => {
|
||||||
|
const propsAllFalse = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
...(defaultProps.value as ContentFlaggingAdditionalSettings),
|
||||||
|
ReporterCommentRequired: false,
|
||||||
|
ReviewerCommentRequired: false,
|
||||||
|
HideFlaggedContent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...propsAllFalse}/>);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('requireReporterComment_false')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('requireReviewerComment_false')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('setHideFlaggedPosts_false')).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render CreatableReactSelect with correct props', () => {
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
const selectInput = screen.getByRole('combobox');
|
||||||
|
expect(selectInput).toBeInTheDocument();
|
||||||
|
expect(selectInput).toHaveAttribute('id', 'contentFlaggingReasons');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should maintain state consistency across multiple changes', () => {
|
||||||
|
renderWithContext(<ContentFlaggingAdditionalSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
// Change reporter comment requirement
|
||||||
|
fireEvent.click(screen.getByTestId('requireReporterComment_true'));
|
||||||
|
expect(defaultProps.onChange).toHaveBeenLastCalledWith('ContentFlaggingAdditionalSettings', {
|
||||||
|
...(defaultProps.value as ContentFlaggingAdditionalSettings),
|
||||||
|
ReporterCommentRequired: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change hide flagged content
|
||||||
|
fireEvent.click(screen.getByTestId('hideFlaggedPosts_true'));
|
||||||
|
expect(defaultProps.onChange).toHaveBeenLastCalledWith('ContentFlaggingAdditionalSettings', {
|
||||||
|
...(defaultProps.value as ContentFlaggingAdditionalSettings),
|
||||||
|
ReporterCommentRequired: true,
|
||||||
|
HideFlaggedContent: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {type ChangeEvent, useCallback, useMemo} from 'react';
|
||||||
|
import {FormattedMessage} from 'react-intl';
|
||||||
|
import type {OnChangeValue} from 'react-select';
|
||||||
|
import CreatableReactSelect from 'react-select/creatable';
|
||||||
|
|
||||||
|
import type {ContentFlaggingAdditionalSettings} from '@mattermost/types/config';
|
||||||
|
|
||||||
|
import {Label} from 'components/admin_console/boolean_setting';
|
||||||
|
import type {SystemConsoleCustomSettingsComponentProps} from 'components/admin_console/schema_admin_settings';
|
||||||
|
import {CreatableReactSelectInput} from 'components/user_settings/notifications/user_settings_notifications';
|
||||||
|
|
||||||
|
import {ReasonOption} from './reason_option';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdminSection,
|
||||||
|
SectionContent,
|
||||||
|
SectionHeader,
|
||||||
|
} from '../../system_properties/controls';
|
||||||
|
|
||||||
|
import '../content_flagging_section_base.scss';
|
||||||
|
import './additional_settings.scss';
|
||||||
|
|
||||||
|
export default function ContentFlaggingAdditionalSettingsSection({id, onChange, value}: SystemConsoleCustomSettingsComponentProps) {
|
||||||
|
const [additionalSettings, setAdditionalSettings] = React.useState<ContentFlaggingAdditionalSettings>(value as ContentFlaggingAdditionalSettings);
|
||||||
|
|
||||||
|
const handleReasonsChange = useCallback((newValues: OnChangeValue<{ value: string }, true>) => {
|
||||||
|
const updatedSettings: ContentFlaggingAdditionalSettings = {
|
||||||
|
...additionalSettings,
|
||||||
|
Reasons: newValues.map((v) => v.value),
|
||||||
|
};
|
||||||
|
setAdditionalSettings(updatedSettings);
|
||||||
|
onChange(id, updatedSettings);
|
||||||
|
}, [additionalSettings, id, onChange]);
|
||||||
|
|
||||||
|
const handleRequireReporterCommentChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const updatedSettings: ContentFlaggingAdditionalSettings = {
|
||||||
|
...additionalSettings,
|
||||||
|
ReporterCommentRequired: e.target.value === 'true',
|
||||||
|
};
|
||||||
|
setAdditionalSettings(updatedSettings);
|
||||||
|
onChange(id, updatedSettings);
|
||||||
|
}, [additionalSettings, id, onChange]);
|
||||||
|
|
||||||
|
const handleRequireReviewerCommentChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const updatedSettings: ContentFlaggingAdditionalSettings = {
|
||||||
|
...additionalSettings,
|
||||||
|
ReviewerCommentRequired: e.target.value === 'true',
|
||||||
|
};
|
||||||
|
setAdditionalSettings(updatedSettings);
|
||||||
|
onChange(id, updatedSettings);
|
||||||
|
}, [additionalSettings, id, onChange]);
|
||||||
|
|
||||||
|
const handleHideFlaggedPosts = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const updatedSettings: ContentFlaggingAdditionalSettings = {
|
||||||
|
...additionalSettings,
|
||||||
|
HideFlaggedContent: e.target.value === 'true',
|
||||||
|
};
|
||||||
|
setAdditionalSettings(updatedSettings);
|
||||||
|
onChange(id, updatedSettings);
|
||||||
|
}, [additionalSettings, id, onChange]);
|
||||||
|
|
||||||
|
const reasonOptions = useMemo(() => {
|
||||||
|
return additionalSettings.Reasons.map((reason) => ({
|
||||||
|
label: reason,
|
||||||
|
value: reason,
|
||||||
|
}));
|
||||||
|
}, [additionalSettings.Reasons]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSection>
|
||||||
|
<SectionHeader>
|
||||||
|
<hgroup>
|
||||||
|
<h1 className='content-flagging-section-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.additionalSettings.title'
|
||||||
|
defaultMessage='Additional Settings'
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<h5 className='content-flagging-section-description'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.additionalSettings.description'
|
||||||
|
defaultMessage='Configure how you want the flagging to behave'
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
</hgroup>
|
||||||
|
</SectionHeader>
|
||||||
|
<SectionContent>
|
||||||
|
<div className='content-flagging-section-setting-wrapper'>
|
||||||
|
|
||||||
|
{/*Reasons for flagging*/}
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.additionalSettings.reasonsForFlagging'
|
||||||
|
defaultMessage='Reasons for flagging'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<CreatableReactSelect
|
||||||
|
className='contentFlaggingReasons'
|
||||||
|
classNamePrefix='contentFlaggingReasons_'
|
||||||
|
inputId='contentFlaggingReasons'
|
||||||
|
isClearable={false}
|
||||||
|
isMulti={true}
|
||||||
|
value={reasonOptions}
|
||||||
|
placeholder={'Type and press Tab to add a reason'}
|
||||||
|
onChange={handleReasonsChange}
|
||||||
|
components={{
|
||||||
|
DropdownIndicator: () => null,
|
||||||
|
Menu: () => null,
|
||||||
|
MenuList: () => null,
|
||||||
|
IndicatorSeparator: () => null,
|
||||||
|
Input: CreatableReactSelectInput,
|
||||||
|
MultiValue: ReasonOption,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.additionalSettings.requireReporterComment'
|
||||||
|
defaultMessage='Require reporters to add comment'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<Label isDisabled={false}>
|
||||||
|
<input
|
||||||
|
data-testid='requireReporterComment_true'
|
||||||
|
id='requireReporterComment_true'
|
||||||
|
type='radio'
|
||||||
|
value='true'
|
||||||
|
checked={additionalSettings.ReporterCommentRequired}
|
||||||
|
onChange={handleRequireReporterCommentChange}
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.true'
|
||||||
|
defaultMessage='True'
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label isDisabled={false}>
|
||||||
|
<input
|
||||||
|
data-testid='requireReporterComment_false'
|
||||||
|
id='requireReporterComment_false'
|
||||||
|
type='radio'
|
||||||
|
value='false'
|
||||||
|
checked={!additionalSettings.ReporterCommentRequired}
|
||||||
|
onChange={handleRequireReporterCommentChange}
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.false'
|
||||||
|
defaultMessage='False'
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.additionalSettings.requireReviewerComment'
|
||||||
|
defaultMessage='Require reviewers to add comment'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<Label isDisabled={false}>
|
||||||
|
<input
|
||||||
|
data-testid='requireReviewerComment_true'
|
||||||
|
id='requireReviewerComment_true'
|
||||||
|
type='radio'
|
||||||
|
value='true'
|
||||||
|
checked={additionalSettings.ReviewerCommentRequired}
|
||||||
|
onChange={handleRequireReviewerCommentChange}
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.true'
|
||||||
|
defaultMessage='True'
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label isDisabled={false}>
|
||||||
|
<input
|
||||||
|
data-testid='requireReviewerComment_false'
|
||||||
|
id='requireReviewerComment_false'
|
||||||
|
type='radio'
|
||||||
|
value='false'
|
||||||
|
checked={!additionalSettings.ReviewerCommentRequired}
|
||||||
|
onChange={handleRequireReviewerCommentChange}
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.false'
|
||||||
|
defaultMessage='False'
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.additionalSettings.hideFlaggedPosts'
|
||||||
|
defaultMessage='Hide message from channel while it is being reviewed'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<Label isDisabled={false}>
|
||||||
|
<input
|
||||||
|
data-testid='hideFlaggedPosts_true'
|
||||||
|
id='hideFlaggedPosts_true'
|
||||||
|
type='radio'
|
||||||
|
value='true'
|
||||||
|
checked={additionalSettings.HideFlaggedContent}
|
||||||
|
onChange={handleHideFlaggedPosts}
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.true'
|
||||||
|
defaultMessage='True'
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label isDisabled={false}>
|
||||||
|
<input
|
||||||
|
data-testid='setHideFlaggedPosts_false'
|
||||||
|
id='setHideFlaggedPosts_false'
|
||||||
|
type='radio'
|
||||||
|
value='false'
|
||||||
|
checked={!additionalSettings.HideFlaggedContent}
|
||||||
|
onChange={handleHideFlaggedPosts}
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.false'
|
||||||
|
defaultMessage='False'
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionContent>
|
||||||
|
</AdminSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
.ReasonOption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 4px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--center-channel-color-08);
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {screen, fireEvent} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import type {MultiValueProps} from 'react-select/dist/declarations/src/components/MultiValue';
|
||||||
|
|
||||||
|
import {renderWithContext} from 'tests/react_testing_utils';
|
||||||
|
|
||||||
|
import {ReasonOption} from './reason_option';
|
||||||
|
|
||||||
|
describe('ReasonOption', () => {
|
||||||
|
const mockProps = {
|
||||||
|
data: {label: 'Test Reason', value: 'test_reason'},
|
||||||
|
innerProps: {},
|
||||||
|
selectProps: {},
|
||||||
|
removeProps: {
|
||||||
|
onClick: jest.fn(),
|
||||||
|
},
|
||||||
|
children: null,
|
||||||
|
className: '',
|
||||||
|
cx: jest.fn(),
|
||||||
|
getStyles: jest.fn(),
|
||||||
|
getValue: jest.fn(),
|
||||||
|
hasValue: false,
|
||||||
|
isMulti: true,
|
||||||
|
isRtl: false,
|
||||||
|
options: [],
|
||||||
|
selectOption: jest.fn(),
|
||||||
|
setValue: jest.fn(),
|
||||||
|
clearValue: jest.fn(),
|
||||||
|
theme: {} as any,
|
||||||
|
} as unknown as MultiValueProps<{label: string; value: string}, true>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render the reason option with correct label', () => {
|
||||||
|
renderWithContext(<ReasonOption {...mockProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Reason')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render with ReasonOption class', () => {
|
||||||
|
const {container} = renderWithContext(<ReasonOption {...mockProps}/>);
|
||||||
|
|
||||||
|
expect(container.querySelector('.ReasonOption')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render remove button with close icon', () => {
|
||||||
|
const {container} = renderWithContext(<ReasonOption {...mockProps}/>);
|
||||||
|
|
||||||
|
const removeButton = container.querySelector('.Remove');
|
||||||
|
expect(removeButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call onClick when remove button is clicked', () => {
|
||||||
|
const {container} = renderWithContext(<ReasonOption {...mockProps}/>);
|
||||||
|
|
||||||
|
const removeButton = container.querySelector('.Remove');
|
||||||
|
fireEvent.click(removeButton!);
|
||||||
|
|
||||||
|
expect(mockProps.removeProps.onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render with different label', () => {
|
||||||
|
const propsWithDifferentLabel = {
|
||||||
|
...mockProps,
|
||||||
|
data: {label: 'Another Reason', value: 'another_reason'},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ReasonOption {...propsWithDifferentLabel}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Another Reason')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Test Reason')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render custom children in remove button when provided', () => {
|
||||||
|
const customChildren = <span>{'Custom Remove'}</span>;
|
||||||
|
const mockRemoveProps = {
|
||||||
|
...mockProps.removeProps,
|
||||||
|
children: customChildren,
|
||||||
|
};
|
||||||
|
|
||||||
|
const propsWithCustomChildren = {
|
||||||
|
...mockProps,
|
||||||
|
removeProps: mockRemoveProps,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ReasonOption {...propsWithCustomChildren}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Custom Remove')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty label gracefully', () => {
|
||||||
|
const propsWithEmptyLabel = {
|
||||||
|
...mockProps,
|
||||||
|
data: {label: '', value: 'empty_reason'},
|
||||||
|
};
|
||||||
|
|
||||||
|
const {container} = renderWithContext(<ReasonOption {...propsWithEmptyLabel}/>);
|
||||||
|
|
||||||
|
expect(container.querySelector('.ReasonOption')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type {MultiValueProps} from 'react-select/dist/declarations/src/components/MultiValue';
|
||||||
|
|
||||||
|
import CloseCircleSolidIcon from 'components/widgets/icons/close_circle_solid_icon';
|
||||||
|
|
||||||
|
import './reason_option.scss';
|
||||||
|
|
||||||
|
function Remove(props: any) {
|
||||||
|
const {innerProps, children} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='Remove'
|
||||||
|
{...innerProps}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
{children || <CloseCircleSolidIcon/>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReasonOption(props: MultiValueProps<{label: string; value: string}, true>) {
|
||||||
|
const {data, innerProps, selectProps, removeProps} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='ReasonOption'
|
||||||
|
{...innerProps}
|
||||||
|
>
|
||||||
|
{data.label}
|
||||||
|
|
||||||
|
<Remove
|
||||||
|
data={data}
|
||||||
|
innerProps={innerProps}
|
||||||
|
selectProps={selectProps}
|
||||||
|
{...removeProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
.content:has(.content-flagging-section-setting-wrapper) {
|
||||||
|
padding: 28px 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-flagging-section-setting-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-flagging-section-title {
|
||||||
|
margin: unset;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-flagging-section-description, .helpText {
|
||||||
|
color: var(--center-channel-color-72);
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-flagging-section-setting {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
.setting-title {
|
||||||
|
min-width: 290px;
|
||||||
|
max-width: 290px;
|
||||||
|
color: var(--center-channel-color);
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.helpText {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
.teamSpecificReviewerSection {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.teamSearchWrapper {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 16px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
#teamSearchSelect {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectAllTeamsButton {
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {screen, fireEvent} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type {ContentFlaggingReviewerSetting, TeamReviewerSetting} from '@mattermost/types/config';
|
||||||
|
|
||||||
|
import type {SystemConsoleCustomSettingsComponentProps} from 'components/admin_console/schema_admin_settings';
|
||||||
|
|
||||||
|
import {renderWithContext} from 'tests/react_testing_utils';
|
||||||
|
|
||||||
|
import ContentFlaggingContentReviewers from './content_reviewers';
|
||||||
|
|
||||||
|
// Mock the UserMultiSelector component
|
||||||
|
jest.mock('../../content_flagging/user_multiselector/user_multiselector', () => ({
|
||||||
|
UserMultiSelector: ({id, initialValue, onChange}: {id: string; initialValue: string[]; onChange: (userIds: string[]) => void}) => (
|
||||||
|
<div data-testid={`user-multi-selector-${id}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(['user1', 'user2'])}
|
||||||
|
data-testid={`${id}-change-users`}
|
||||||
|
>
|
||||||
|
{'Change Users'}
|
||||||
|
</button>
|
||||||
|
<span data-testid={`${id}-initial-value`}>{initialValue.join(',')}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the TeamReviewers component
|
||||||
|
jest.mock('./team_reviewers_section/team_reviewers_section', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({teamReviewersSetting, onChange}: {teamReviewersSetting: Record<string, TeamReviewerSetting>; onChange: (settings: Record<string, TeamReviewerSetting>) => void}) => (
|
||||||
|
<div data-testid='team-reviewers'>
|
||||||
|
<button
|
||||||
|
onClick={() => onChange({team1: {Enabled: true, ReviewerIds: ['user3']}})}
|
||||||
|
data-testid='team-reviewers-change'
|
||||||
|
>
|
||||||
|
{'Change Team Reviewers'}
|
||||||
|
</button>
|
||||||
|
<span data-testid='team-reviewers-setting'>{JSON.stringify(teamReviewersSetting)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ContentFlaggingContentReviewers', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
id: 'content_reviewers',
|
||||||
|
value: {
|
||||||
|
CommonReviewers: true,
|
||||||
|
CommonReviewerIds: ['user1'],
|
||||||
|
SystemAdminsAsReviewers: false,
|
||||||
|
TeamAdminsAsReviewers: false,
|
||||||
|
TeamReviewersSetting: {},
|
||||||
|
} as ContentFlaggingReviewerSetting,
|
||||||
|
onChange: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
setByEnv: false,
|
||||||
|
} as unknown as SystemConsoleCustomSettingsComponentProps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the component with correct title and description', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Content Reviewers')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Define who should review content in your environment')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders radio buttons for same reviewers for all teams setting', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Same reviewers for all teams:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('sameReviewersForAllTeams_true')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('sameReviewersForAllTeams_false')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows common reviewers section when CommonReviewers is true', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Reviewers:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('user-multi-selector-content_reviewers_common_reviewers')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('team-reviewers')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows team-specific reviewers section when CommonReviewers is false', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
CommonReviewers: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...props}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Configure content flagging per team')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('team-reviewers')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Reviewers:')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders additional reviewers checkboxes', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Additional reviewers')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('System Administrators')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Administrators')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles same reviewers for all teams radio button change to true', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
CommonReviewers: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...props}/>);
|
||||||
|
|
||||||
|
const trueRadio = screen.getByTestId('sameReviewersForAllTeams_true');
|
||||||
|
fireEvent.click(trueRadio);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('content_reviewers', {
|
||||||
|
...props.value,
|
||||||
|
CommonReviewers: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles same reviewers for all teams radio button change to false', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
const falseRadio = screen.getByTestId('sameReviewersForAllTeams_false');
|
||||||
|
fireEvent.click(falseRadio);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('content_reviewers', {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
CommonReviewers: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles system admin reviewer checkbox change', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
const systemAdminCheckbox = screen.getByRole('checkbox', {name: /system administrators/i});
|
||||||
|
fireEvent.click(systemAdminCheckbox);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('content_reviewers', {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
SystemAdminsAsReviewers: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles team admin reviewer checkbox change', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
const teamAdminCheckbox = screen.getByRole('checkbox', {name: /team administrators/i});
|
||||||
|
fireEvent.click(teamAdminCheckbox);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('content_reviewers', {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
TeamAdminsAsReviewers: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles common reviewers change', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
const changeUsersButton = screen.getByTestId('content_reviewers_common_reviewers-change-users');
|
||||||
|
fireEvent.click(changeUsersButton);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('content_reviewers', {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
CommonReviewerIds: ['user1', 'user2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles team reviewer settings change', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
CommonReviewers: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...props}/>);
|
||||||
|
|
||||||
|
const changeTeamReviewersButton = screen.getByTestId('team-reviewers-change');
|
||||||
|
fireEvent.click(changeTeamReviewersButton);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('content_reviewers', {
|
||||||
|
...props.value,
|
||||||
|
TeamReviewersSetting: {team1: {Enabled: true, ReviewerIds: ['user3']}},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes correct initial values to UserMultiSelector', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
CommonReviewerIds: ['user1', 'user2', 'user3'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...props}/>);
|
||||||
|
|
||||||
|
const initialValueSpan = screen.getByTestId('content_reviewers_common_reviewers-initial-value');
|
||||||
|
expect(initialValueSpan).toHaveTextContent('user1,user2,user3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes correct team reviewer settings to TeamReviewers component', () => {
|
||||||
|
const teamReviewersSetting = {
|
||||||
|
team1: {ReviewerIds: ['user1']},
|
||||||
|
team2: {ReviewerIds: ['user2']},
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
CommonReviewers: false,
|
||||||
|
TeamReviewersSetting: teamReviewersSetting,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...props}/>);
|
||||||
|
|
||||||
|
const teamReviewersSettingSpan = screen.getByTestId('team-reviewers-setting');
|
||||||
|
expect(teamReviewersSettingSpan).toHaveTextContent(JSON.stringify(teamReviewersSetting));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders help text for additional reviewers', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText(/If enabled, system administrators will be sent flagged posts/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly sets radio button checked states', () => {
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...defaultProps}/>);
|
||||||
|
|
||||||
|
const trueRadio = screen.getByTestId('sameReviewersForAllTeams_true') as HTMLInputElement;
|
||||||
|
const falseRadio = screen.getByTestId('sameReviewersForAllTeams_false') as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(trueRadio.checked).toBe(true);
|
||||||
|
expect(falseRadio.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly sets radio button checked states when CommonReviewers is false', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
...(defaultProps.value as ContentFlaggingReviewerSetting),
|
||||||
|
CommonReviewers: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<ContentFlaggingContentReviewers {...props}/>);
|
||||||
|
|
||||||
|
const trueRadio = screen.getByTestId('sameReviewersForAllTeams_true') as HTMLInputElement;
|
||||||
|
const falseRadio = screen.getByTestId('sameReviewersForAllTeams_false') as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(trueRadio.checked).toBe(false);
|
||||||
|
expect(falseRadio.checked).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useCallback, useState} from 'react';
|
||||||
|
import {FormattedMessage} from 'react-intl';
|
||||||
|
|
||||||
|
import type {ContentFlaggingReviewerSetting, TeamReviewerSetting} from '@mattermost/types/config';
|
||||||
|
|
||||||
|
import {Label} from 'components/admin_console/boolean_setting';
|
||||||
|
import CheckboxSetting from 'components/admin_console/checkbox_setting';
|
||||||
|
import TeamReviewers
|
||||||
|
from 'components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section';
|
||||||
|
import {
|
||||||
|
AdminSection,
|
||||||
|
SectionContent,
|
||||||
|
SectionHeader,
|
||||||
|
} from 'components/admin_console/system_properties/controls';
|
||||||
|
|
||||||
|
import {UserMultiSelector} from '../../content_flagging/user_multiselector/user_multiselector';
|
||||||
|
import type {SystemConsoleCustomSettingsComponentProps} from '../../schema_admin_settings';
|
||||||
|
|
||||||
|
import './content_reviewers.scss';
|
||||||
|
|
||||||
|
export default function ContentFlaggingContentReviewers(props: SystemConsoleCustomSettingsComponentProps) {
|
||||||
|
const [reviewerSetting, setReviewerSetting] = useState<ContentFlaggingReviewerSetting>(props.value as ContentFlaggingReviewerSetting);
|
||||||
|
|
||||||
|
const handleSameReviewersForAllTeamsChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const updatedSetting: ContentFlaggingReviewerSetting = {
|
||||||
|
...reviewerSetting,
|
||||||
|
CommonReviewers: event.target.value === 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
setReviewerSetting(updatedSetting);
|
||||||
|
props.onChange(props.id, updatedSetting);
|
||||||
|
}, [props, reviewerSetting]);
|
||||||
|
|
||||||
|
const handleSystemAdminReviewerChange = useCallback((_: string, value: boolean) => {
|
||||||
|
const updatedSetting: ContentFlaggingReviewerSetting = {
|
||||||
|
...reviewerSetting,
|
||||||
|
SystemAdminsAsReviewers: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
setReviewerSetting(updatedSetting);
|
||||||
|
props.onChange(props.id, updatedSetting);
|
||||||
|
}, [props, reviewerSetting]);
|
||||||
|
|
||||||
|
const handleTeamAdminReviewerChange = useCallback((_: string, value: boolean) => {
|
||||||
|
const updatedSetting: ContentFlaggingReviewerSetting = {
|
||||||
|
...reviewerSetting,
|
||||||
|
TeamAdminsAsReviewers: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
setReviewerSetting(updatedSetting);
|
||||||
|
props.onChange(props.id, updatedSetting);
|
||||||
|
}, [props, reviewerSetting]);
|
||||||
|
|
||||||
|
const handleCommonReviewersChange = useCallback((selectedUserIds: string[]) => {
|
||||||
|
const updatedSetting: ContentFlaggingReviewerSetting = {
|
||||||
|
...reviewerSetting,
|
||||||
|
CommonReviewerIds: selectedUserIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
setReviewerSetting(updatedSetting);
|
||||||
|
props.onChange(props.id, updatedSetting);
|
||||||
|
}, [props, reviewerSetting]);
|
||||||
|
|
||||||
|
const handleTeamReviewerSettingsChange = useCallback((updatedTeamSettings: Record<string, TeamReviewerSetting>) => {
|
||||||
|
const updatedSetting: ContentFlaggingReviewerSetting = {
|
||||||
|
...reviewerSetting,
|
||||||
|
TeamReviewersSetting: updatedTeamSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
setReviewerSetting(updatedSetting);
|
||||||
|
props.onChange(props.id, updatedSetting);
|
||||||
|
}, [props, reviewerSetting]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSection>
|
||||||
|
<SectionHeader>
|
||||||
|
<hgroup>
|
||||||
|
<h1 className='content-flagging-section-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.reviewerSettings.title'
|
||||||
|
defaultMessage='Content Reviewers'
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<h5 className='content-flagging-section-description'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.reviewerSettings.description'
|
||||||
|
defaultMessage='Define who should review content in your environment'
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
</hgroup>
|
||||||
|
</SectionHeader>
|
||||||
|
|
||||||
|
<SectionContent>
|
||||||
|
<div className='content-flagging-section-setting-wrapper'>
|
||||||
|
{/* Same reviewers for all teams */}
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.reviewerSettings.sameReviewersForAllTeams'
|
||||||
|
defaultMessage='Same reviewers for all teams:'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<Label isDisabled={false}>
|
||||||
|
<input
|
||||||
|
data-testid='sameReviewersForAllTeams_true'
|
||||||
|
id='sameReviewersForAllTeams_true'
|
||||||
|
type='radio'
|
||||||
|
value='true'
|
||||||
|
checked={reviewerSetting.CommonReviewers}
|
||||||
|
onChange={handleSameReviewersForAllTeamsChange}
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.true'
|
||||||
|
defaultMessage='True'
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label isDisabled={false}>
|
||||||
|
<input
|
||||||
|
data-testid='sameReviewersForAllTeams_false'
|
||||||
|
id='sameReviewersForAllTeams_false'
|
||||||
|
type='radio'
|
||||||
|
value='false'
|
||||||
|
checked={!reviewerSetting.CommonReviewers}
|
||||||
|
onChange={handleSameReviewersForAllTeamsChange}
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.false'
|
||||||
|
defaultMessage='False'
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
reviewerSetting.CommonReviewers &&
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.reviewerSettings.commonReviewers'
|
||||||
|
defaultMessage='Reviewers:'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<UserMultiSelector
|
||||||
|
id='content_reviewers_common_reviewers'
|
||||||
|
initialValue={reviewerSetting.CommonReviewerIds}
|
||||||
|
onChange={handleCommonReviewersChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!reviewerSetting.CommonReviewers &&
|
||||||
|
<div className='content-flagging-section-setting teamSpecificReviewerSection'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.reviewerSettings.perTeamReviewers.title'
|
||||||
|
defaultMessage='Configure content flagging per team'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TeamReviewers
|
||||||
|
teamReviewersSetting={reviewerSetting.TeamReviewersSetting}
|
||||||
|
onChange={handleTeamReviewerSettingsChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.reviewerSettings.additionalReviewers'
|
||||||
|
defaultMessage='Additional reviewers'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content-wrapper'>
|
||||||
|
<div className='setting-content'>
|
||||||
|
<CheckboxSetting
|
||||||
|
id='notifyOnDismissal_reviewers'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.reviewerSettings.additionalReviewers.systemAdmins'
|
||||||
|
defaultMessage='System Administrators'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={reviewerSetting.SystemAdminsAsReviewers}
|
||||||
|
onChange={handleSystemAdminReviewerChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxSetting
|
||||||
|
id='notifyOnDismissal_author'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.reviewerSettings.additionalReviewers.teamAdmins'
|
||||||
|
defaultMessage='Team Administrators'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={reviewerSetting.TeamAdminsAsReviewers}
|
||||||
|
onChange={handleTeamAdminReviewerChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='helpText'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.reviewerSettings.additionalReviewers.helpText'
|
||||||
|
defaultMessage='If enabled, system administrators will be sent flagged posts for review from every team that they are a part of. Team administrators will only be sent flagged posts for review from their respective teams.'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionContent>
|
||||||
|
</AdminSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
.TeamOptionComponent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.TeamIcon {
|
||||||
|
background: var(--center-channel-color-08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type {OptionProps} from 'react-select';
|
||||||
|
|
||||||
|
import type {Team} from '@mattermost/types/teams';
|
||||||
|
|
||||||
|
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||||
|
import * as Utils from 'utils/utils';
|
||||||
|
|
||||||
|
import {TeamOptionComponent} from './team_option';
|
||||||
|
|
||||||
|
import type {AutocompleteOptionType} from '../user_multiselector/user_multiselector';
|
||||||
|
|
||||||
|
// Mock the utils module
|
||||||
|
jest.mock('utils/utils', () => ({
|
||||||
|
imageURLForTeam: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the TeamIcon component
|
||||||
|
jest.mock('components/widgets/team_icon/team_icon', () => ({
|
||||||
|
TeamIcon: ({content, size, url}: {content: string; size: string; url: string}) => (
|
||||||
|
<div
|
||||||
|
data-testid='team-icon'
|
||||||
|
data-size={size}
|
||||||
|
data-url={url}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TeamOptionComponent', () => {
|
||||||
|
const mockTeam: Team = {
|
||||||
|
id: 'team-id-1',
|
||||||
|
create_at: 1234567890,
|
||||||
|
update_at: 1234567890,
|
||||||
|
delete_at: 0,
|
||||||
|
display_name: 'Test Team',
|
||||||
|
name: 'test-team',
|
||||||
|
description: 'A test team',
|
||||||
|
email: 'test@example.com',
|
||||||
|
type: 'O',
|
||||||
|
company_name: '',
|
||||||
|
allowed_domains: '',
|
||||||
|
invite_id: '',
|
||||||
|
allow_open_invite: true,
|
||||||
|
scheme_id: '',
|
||||||
|
group_constrained: false,
|
||||||
|
policy_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProps: OptionProps<AutocompleteOptionType<Team>, true> = {
|
||||||
|
data: {
|
||||||
|
raw: mockTeam,
|
||||||
|
label: mockTeam.display_name,
|
||||||
|
value: mockTeam.id,
|
||||||
|
},
|
||||||
|
innerProps: {
|
||||||
|
onClick: jest.fn(),
|
||||||
|
onMouseMove: jest.fn(),
|
||||||
|
onMouseOver: jest.fn(),
|
||||||
|
},
|
||||||
|
isDisabled: false,
|
||||||
|
isFocused: false,
|
||||||
|
isSelected: false,
|
||||||
|
innerRef: jest.fn(),
|
||||||
|
selectProps: {} as any,
|
||||||
|
getValue: jest.fn(),
|
||||||
|
hasValue: false,
|
||||||
|
getStyles: jest.fn(),
|
||||||
|
selectOption: jest.fn(),
|
||||||
|
setValue: jest.fn(),
|
||||||
|
clearValue: jest.fn(),
|
||||||
|
cx: jest.fn(),
|
||||||
|
getClassNames: jest.fn(),
|
||||||
|
theme: {} as any,
|
||||||
|
isMulti: true,
|
||||||
|
options: [],
|
||||||
|
} as unknown as OptionProps<AutocompleteOptionType<Team>, true>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(Utils.imageURLForTeam as jest.Mock).mockReturnValue('http://example.com/team-icon.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render team option with team icon and display name', () => {
|
||||||
|
renderWithContext(<TeamOptionComponent {...mockProps}/>);
|
||||||
|
|
||||||
|
expect(screen.queryAllByText('Test Team')).toHaveLength(2);
|
||||||
|
expect(screen.getByTestId('team-icon')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('team-icon')).toHaveAttribute('data-size', 'xsm');
|
||||||
|
expect(screen.getByTestId('team-icon')).toHaveAttribute('data-url', 'http://example.com/team-icon.png');
|
||||||
|
expect(screen.getByTestId('team-icon')).toHaveTextContent('Test Team');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call imageURLForTeam with correct team data', () => {
|
||||||
|
renderWithContext(<TeamOptionComponent {...mockProps}/>);
|
||||||
|
|
||||||
|
expect(Utils.imageURLForTeam).toHaveBeenCalledWith(mockTeam);
|
||||||
|
expect(Utils.imageURLForTeam).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply CSS class and inner props correctly', () => {
|
||||||
|
renderWithContext(<TeamOptionComponent {...mockProps}/>);
|
||||||
|
|
||||||
|
const container = screen.queryAllByText('Test Team')[0].closest('.TeamOptionComponent');
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
expect(container).toHaveClass('TeamOptionComponent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when data is null', () => {
|
||||||
|
const propsWithNullData = {
|
||||||
|
...mockProps,
|
||||||
|
data: null,
|
||||||
|
} as unknown as OptionProps<AutocompleteOptionType<Team>, true>;
|
||||||
|
|
||||||
|
const {container} = renderWithContext(<TeamOptionComponent {...propsWithNullData}/>);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when data is undefined', () => {
|
||||||
|
const propsWithUndefinedData = {
|
||||||
|
...mockProps,
|
||||||
|
data: undefined,
|
||||||
|
} as unknown as OptionProps<AutocompleteOptionType<Team>, true>;
|
||||||
|
|
||||||
|
const {container} = renderWithContext(<TeamOptionComponent {...propsWithUndefinedData}/>);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when data.raw is null', () => {
|
||||||
|
const propsWithNullRaw = {
|
||||||
|
...mockProps,
|
||||||
|
data: {
|
||||||
|
raw: null,
|
||||||
|
label: 'Test',
|
||||||
|
value: 'test',
|
||||||
|
},
|
||||||
|
} as unknown as OptionProps<AutocompleteOptionType<Team>, true>;
|
||||||
|
|
||||||
|
const {container} = renderWithContext(<TeamOptionComponent {...propsWithNullRaw}/>);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when data.raw is undefined', () => {
|
||||||
|
const propsWithUndefinedRaw = {
|
||||||
|
...mockProps,
|
||||||
|
data: {
|
||||||
|
raw: undefined,
|
||||||
|
label: 'Test',
|
||||||
|
value: 'test',
|
||||||
|
},
|
||||||
|
} as unknown as OptionProps<AutocompleteOptionType<Team>, true>;
|
||||||
|
|
||||||
|
const {container} = renderWithContext(<TeamOptionComponent {...propsWithUndefinedRaw}/>);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different team types', () => {
|
||||||
|
const privateTeam = {
|
||||||
|
...mockTeam,
|
||||||
|
type: 'P' as const,
|
||||||
|
display_name: 'Private Team',
|
||||||
|
};
|
||||||
|
|
||||||
|
const propsWithPrivateTeam = {
|
||||||
|
...mockProps,
|
||||||
|
data: {
|
||||||
|
...mockProps.data!,
|
||||||
|
raw: privateTeam,
|
||||||
|
},
|
||||||
|
} as unknown as OptionProps<AutocompleteOptionType<Team>, true>;
|
||||||
|
|
||||||
|
renderWithContext(<TeamOptionComponent {...propsWithPrivateTeam}/>);
|
||||||
|
|
||||||
|
expect(screen.queryAllByText('Private Team')[0]).toBeInTheDocument();
|
||||||
|
expect(Utils.imageURLForTeam).toHaveBeenCalledWith(privateTeam);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import type {OptionProps} from 'react-select';
|
||||||
|
|
||||||
|
import type {Team} from '@mattermost/types/teams';
|
||||||
|
|
||||||
|
import {TeamIcon} from 'components/widgets/team_icon/team_icon';
|
||||||
|
|
||||||
|
import * as Utils from 'utils/utils';
|
||||||
|
|
||||||
|
import type {AutocompleteOptionType} from '../user_multiselector/user_multiselector';
|
||||||
|
|
||||||
|
import './team_option.scss';
|
||||||
|
|
||||||
|
export function TeamOptionComponent(props: OptionProps<AutocompleteOptionType<Team>, true>) {
|
||||||
|
const {data, innerProps} = props;
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
if (!data || !data.raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = data.raw;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='TeamOptionComponent'
|
||||||
|
{...innerProps}
|
||||||
|
>
|
||||||
|
<TeamIcon
|
||||||
|
size='xsm'
|
||||||
|
url={Utils.imageURLForTeam(team)}
|
||||||
|
content={team.display_name}
|
||||||
|
intl={intl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{team.display_name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
.TeamReviewers {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--center-channel-color-16);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
> .DataGrid {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
.DataGrid_search {
|
||||||
|
height: unset;
|
||||||
|
padding: 16px;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
.DataGrid_searchBar {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.TeamReviewers__disable-all, .TeamReviewers__disable-all > button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DataGrid_rows .DataGrid_row:nth-child(odd) {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DataGrid_row {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 16px;
|
||||||
|
gap: 32px;
|
||||||
|
|
||||||
|
.TeamReviewers__team {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
.TeamReviewers__team-name {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.TeamIcon {
|
||||||
|
background: var(--center-channel-color-08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.DataGrid_cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center !important;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.user-multiselector__control {
|
||||||
|
margin-block: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(.UserMultiSelector) {
|
||||||
|
overflow: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DataGrid_header {
|
||||||
|
background: var(--center-channel-color-04);
|
||||||
|
gap: 32px;
|
||||||
|
padding-inline: 18px;
|
||||||
|
|
||||||
|
> .DataGrid_cell {
|
||||||
|
padding-left: 0;
|
||||||
|
color: var(--center-channel-color-72);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DataGrid_cell {
|
||||||
|
&:nth-child(1) {
|
||||||
|
max-width: 230px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
max-width: 446px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
max-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DataGrid_footer {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
> .DataGrid_cell {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {screen, fireEvent, waitFor} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type {TeamReviewerSetting} from '@mattermost/types/config';
|
||||||
|
import type {Team} from '@mattermost/types/teams';
|
||||||
|
|
||||||
|
import {searchTeams} from 'mattermost-redux/actions/teams';
|
||||||
|
|
||||||
|
import {renderWithContext} from 'tests/react_testing_utils';
|
||||||
|
import {TestHelper} from 'utils/test_helper';
|
||||||
|
|
||||||
|
import TeamReviewersSection from './team_reviewers_section';
|
||||||
|
|
||||||
|
jest.mock('mattermost-redux/actions/teams', () => ({
|
||||||
|
searchTeams: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../user_multiselector/user_multiselector', () => ({
|
||||||
|
UserMultiSelector: ({id, initialValue, onChange}: {id: string; initialValue: string[]; onChange: (ids: string[]) => void}) => (
|
||||||
|
<div data-testid={`user-multi-selector-${id}`}>
|
||||||
|
<span>{`Selected: {${initialValue.join(', ')}`}</span>
|
||||||
|
<button onClick={() => onChange(['user1', 'user2'])}>
|
||||||
|
{'Change Reviewers'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('components/widgets/team_icon/team_icon', () => ({
|
||||||
|
TeamIcon: ({content}: {content: string}) => <div data-testid='team-icon'>{content}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSearchTeams = jest.mocked(searchTeams);
|
||||||
|
|
||||||
|
describe('TeamReviewersSection', () => {
|
||||||
|
const mockTeams: Team[] = [
|
||||||
|
TestHelper.getTeamMock({
|
||||||
|
id: 'team1',
|
||||||
|
display_name: 'Team One',
|
||||||
|
name: 'team-one',
|
||||||
|
}),
|
||||||
|
TestHelper.getTeamMock({
|
||||||
|
id: 'team2',
|
||||||
|
display_name: 'Team Two',
|
||||||
|
name: 'team-two',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
teamReviewersSetting: {} as Record<string, TeamReviewerSetting>,
|
||||||
|
onChange: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// mockSearchTeams.mockResolvedValue({
|
||||||
|
// data: {
|
||||||
|
// teams: mockTeams,
|
||||||
|
// total_count: 2,
|
||||||
|
// },
|
||||||
|
// } as never);
|
||||||
|
|
||||||
|
mockSearchTeams.mockReturnValue(async () => ({
|
||||||
|
data: {
|
||||||
|
teams: mockTeams,
|
||||||
|
total_count: 2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render component with teams data', async () => {
|
||||||
|
renderWithContext(<TeamReviewersSection {...defaultProps}/>, {}, {useMockedStore: true});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const teamNameCells = screen.getAllByTestId('teamName');
|
||||||
|
expect(teamNameCells).toHaveLength(2);
|
||||||
|
expect(teamNameCells[0]).toBeVisible();
|
||||||
|
expect(teamNameCells[0]).toHaveTextContent('Team One');
|
||||||
|
|
||||||
|
expect(teamNameCells[1]).toBeVisible();
|
||||||
|
expect(teamNameCells[1]).toHaveTextContent('Team Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Team')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Reviewers')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Enabled')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call searchTeams on component mount', async () => {
|
||||||
|
renderWithContext(<TeamReviewersSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle search functionality', async () => {
|
||||||
|
renderWithContext(<TeamReviewersSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(searchInput, {target: {value: 'search term'}});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('search term', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle pagination - next page', async () => {
|
||||||
|
mockSearchTeams.mockReturnValue(async () => ({
|
||||||
|
data: {
|
||||||
|
teams: mockTeams,
|
||||||
|
total_count: 20,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderWithContext(<TeamReviewersSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const teamNameCells = screen.getAllByTestId('teamName');
|
||||||
|
expect(teamNameCells).toHaveLength(2);
|
||||||
|
expect(teamNameCells[0]).toBeVisible();
|
||||||
|
expect(teamNameCells[0]).toHaveTextContent('Team One');
|
||||||
|
|
||||||
|
expect(teamNameCells[1]).toBeVisible();
|
||||||
|
expect(teamNameCells[1]).toHaveTextContent('Team Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextButton = screen.getByRole('button', {name: /next/i});
|
||||||
|
fireEvent.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 1, per_page: 10});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle pagination - previous page', async () => {
|
||||||
|
mockSearchTeams.mockReturnValue(async () => ({
|
||||||
|
data: {
|
||||||
|
teams: mockTeams,
|
||||||
|
total_count: 20,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderWithContext(<TeamReviewersSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
// First go to next page
|
||||||
|
await waitFor(() => {
|
||||||
|
const teamNameCells = screen.getAllByTestId('teamName');
|
||||||
|
expect(teamNameCells).toHaveLength(2);
|
||||||
|
expect(teamNameCells[0]).toBeVisible();
|
||||||
|
expect(teamNameCells[0]).toHaveTextContent('Team One');
|
||||||
|
|
||||||
|
expect(teamNameCells[1]).toBeVisible();
|
||||||
|
expect(teamNameCells[1]).toHaveTextContent('Team Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextButton = screen.getByRole('button', {name: /Next page/i});
|
||||||
|
fireEvent.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 1, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then go back to previous page
|
||||||
|
const prevButton = screen.getByRole('button', {name: /Previous page/i});
|
||||||
|
fireEvent.click(prevButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle toggle functionality for enabling team reviewers', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderWithContext(
|
||||||
|
<TeamReviewersSection
|
||||||
|
{...defaultProps}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const teamNameCells = screen.getAllByTestId('teamName');
|
||||||
|
expect(teamNameCells).toHaveLength(2);
|
||||||
|
expect(teamNameCells[0]).toBeVisible();
|
||||||
|
expect(teamNameCells[0]).toHaveTextContent('Team One');
|
||||||
|
|
||||||
|
expect(teamNameCells[1]).toBeVisible();
|
||||||
|
expect(teamNameCells[1]).toHaveTextContent('Team Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = screen.getAllByRole('button', {name: /enable or disable content reviewers for this team/i})[0];
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
|
team1: {
|
||||||
|
Enabled: true,
|
||||||
|
ReviewerIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle toggle functionality with existing settings', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const teamReviewersSetting = {
|
||||||
|
team1: {
|
||||||
|
Enabled: true,
|
||||||
|
ReviewerIds: ['user1'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(
|
||||||
|
<TeamReviewersSection
|
||||||
|
teamReviewersSetting={teamReviewersSetting}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const teamNameCells = screen.getAllByTestId('teamName');
|
||||||
|
expect(teamNameCells).toHaveLength(2);
|
||||||
|
expect(teamNameCells[0]).toBeVisible();
|
||||||
|
expect(teamNameCells[0]).toHaveTextContent('Team One');
|
||||||
|
|
||||||
|
expect(teamNameCells[1]).toBeVisible();
|
||||||
|
expect(teamNameCells[1]).toHaveTextContent('Team Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = screen.getAllByRole('button', {name: /enable or disable content reviewers for this team/i})[0];
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
|
team1: {
|
||||||
|
Enabled: false,
|
||||||
|
ReviewerIds: ['user1'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle reviewer selection changes', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderWithContext(
|
||||||
|
<TeamReviewersSection
|
||||||
|
{...defaultProps}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const teamNameCells = screen.getAllByTestId('teamName');
|
||||||
|
expect(teamNameCells).toHaveLength(2);
|
||||||
|
expect(teamNameCells[0]).toBeVisible();
|
||||||
|
expect(teamNameCells[0]).toHaveTextContent('Team One');
|
||||||
|
|
||||||
|
expect(teamNameCells[1]).toBeVisible();
|
||||||
|
expect(teamNameCells[1]).toHaveTextContent('Team Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeReviewersButton = screen.getAllByText('Change Reviewers')[0];
|
||||||
|
fireEvent.click(changeReviewersButton);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
|
team1: {
|
||||||
|
Enabled: false,
|
||||||
|
ReviewerIds: ['user1', 'user2'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display existing reviewer settings', async () => {
|
||||||
|
const teamReviewersSetting = {
|
||||||
|
team1: {
|
||||||
|
Enabled: true,
|
||||||
|
ReviewerIds: ['existing-user1', 'existing-user2'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(
|
||||||
|
<TeamReviewersSection
|
||||||
|
teamReviewersSetting={teamReviewersSetting}
|
||||||
|
onChange={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render disable all button', async () => {
|
||||||
|
renderWithContext(<TeamReviewersSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Disable for all teams')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const disableAllButton = screen.getByRole('button', {name: /disable for all teams/i});
|
||||||
|
expect(disableAllButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display correct pagination counts', async () => {
|
||||||
|
mockSearchTeams.mockReturnValue(async () => ({
|
||||||
|
data: {
|
||||||
|
teams: mockTeams,
|
||||||
|
total_count: 25,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderWithContext(<TeamReviewersSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/1.*10.*25/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reset page to 0 when searching', async () => {
|
||||||
|
// mockSearchTeams.
|
||||||
|
// mockReturnValueOnce({
|
||||||
|
// data: {teams: mockTeams, total_count: 20},
|
||||||
|
// } as never).
|
||||||
|
// mockResolvedValueOnce({
|
||||||
|
// data: {teams: mockTeams, total_count: 20},
|
||||||
|
// } as never).
|
||||||
|
// mockResolvedValueOnce({
|
||||||
|
// data: {teams: mockTeams, total_count: 5},
|
||||||
|
// } as never);
|
||||||
|
|
||||||
|
mockSearchTeams.mockReturnValueOnce(async () => ({
|
||||||
|
data: {teams: mockTeams, total_count: 20},
|
||||||
|
})).
|
||||||
|
mockReturnValueOnce(async () => ({
|
||||||
|
data: {teams: mockTeams, total_count: 20},
|
||||||
|
})).
|
||||||
|
mockReturnValueOnce(async () => ({
|
||||||
|
data: {teams: mockTeams, total_count: 5},
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderWithContext(<TeamReviewersSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
// Wait for initial load
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const teamNameCells = screen.getAllByTestId('teamName');
|
||||||
|
expect(teamNameCells).toHaveLength(2);
|
||||||
|
expect(teamNameCells[0]).toBeVisible();
|
||||||
|
expect(teamNameCells[0]).toHaveTextContent('Team One');
|
||||||
|
|
||||||
|
expect(teamNameCells[1]).toBeVisible();
|
||||||
|
expect(teamNameCells[1]).toHaveTextContent('Team Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Go to next page
|
||||||
|
const nextButton = screen.getByRole('button', {name: /next/i});
|
||||||
|
fireEvent.click(nextButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 1, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search - should reset to page 0
|
||||||
|
const searchInput = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(searchInput, {target: {value: 'search'}});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('search', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty teams response', async () => {
|
||||||
|
mockSearchTeams.mockReturnValue(async () => ({
|
||||||
|
data: {
|
||||||
|
teams: mockTeams,
|
||||||
|
total_count: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderWithContext(<TeamReviewersSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not crash and should still render headers
|
||||||
|
expect(screen.getByText('Team')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Reviewers')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Enabled')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle multiple toggle clicks correctly', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderWithContext(
|
||||||
|
<TeamReviewersSection
|
||||||
|
{...defaultProps}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const teamNameCells = screen.getAllByTestId('teamName');
|
||||||
|
expect(teamNameCells).toHaveLength(2);
|
||||||
|
expect(teamNameCells[0]).toBeVisible();
|
||||||
|
expect(teamNameCells[0]).toHaveTextContent('Team One');
|
||||||
|
|
||||||
|
expect(teamNameCells[1]).toBeVisible();
|
||||||
|
expect(teamNameCells[1]).toHaveTextContent('Team Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = screen.getAllByRole('button', {name: /enable or disable content reviewers for this team/i})[0];
|
||||||
|
|
||||||
|
// First click - enable
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
|
team1: {
|
||||||
|
Enabled: true,
|
||||||
|
ReviewerIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update props to simulate state change
|
||||||
|
const updatedProps = {
|
||||||
|
teamReviewersSetting: {
|
||||||
|
team1: {
|
||||||
|
Enabled: true,
|
||||||
|
ReviewerIds: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onChange,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithContext(<TeamReviewersSection {...updatedProps}/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText('Team One')).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedToggle = screen.getAllByRole('button', {name: /enable or disable content reviewers for this team/i})[0];
|
||||||
|
|
||||||
|
// Second click - disable
|
||||||
|
fireEvent.click(updatedToggle);
|
||||||
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
|
team1: {
|
||||||
|
Enabled: true,
|
||||||
|
ReviewerIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import {useDispatch} from 'react-redux';
|
||||||
|
|
||||||
|
import type {TeamReviewerSetting} from '@mattermost/types/config';
|
||||||
|
import type {Team, TeamSearchOpts} from '@mattermost/types/teams';
|
||||||
|
|
||||||
|
import {searchTeams} from 'mattermost-redux/actions/teams';
|
||||||
|
|
||||||
|
import DataGrid from 'components/admin_console/data_grid/data_grid';
|
||||||
|
import Toggle from 'components/toggle';
|
||||||
|
import {TeamIcon} from 'components/widgets/team_icon/team_icon';
|
||||||
|
|
||||||
|
import * as Utils from 'utils/utils';
|
||||||
|
|
||||||
|
import {UserMultiSelector} from '../../user_multiselector/user_multiselector';
|
||||||
|
|
||||||
|
import './team_reviewers_section.scss';
|
||||||
|
|
||||||
|
const GET_TEAMS_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
teamReviewersSetting: Record<string, TeamReviewerSetting>;
|
||||||
|
onChange: (updatedTeamSettings: Record<string, TeamReviewerSetting>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamReviewers({teamReviewersSetting, onChange}: Props): JSX.Element {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [startCount, setStartCount] = useState(1);
|
||||||
|
const [endCount, setEndCount] = useState(100);
|
||||||
|
const [teamSearchTerm, setTeamSearchTerm] = useState<string>('');
|
||||||
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
|
|
||||||
|
const setPaginationValues = useCallback((page: number, total: number) => {
|
||||||
|
const startCount = (page * GET_TEAMS_PAGE_SIZE) + 1;
|
||||||
|
const endCount = Math.min((page + 1) * GET_TEAMS_PAGE_SIZE, total);
|
||||||
|
|
||||||
|
setStartCount(startCount);
|
||||||
|
setEndCount(endCount);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTeams = async (term: string) => {
|
||||||
|
try {
|
||||||
|
const teamsResponse = await dispatch(searchTeams(term || '', {page, per_page: GET_TEAMS_PAGE_SIZE} as TeamSearchOpts));
|
||||||
|
|
||||||
|
if (teamsResponse && teamsResponse.data) {
|
||||||
|
setTotal(teamsResponse.data.total_count);
|
||||||
|
|
||||||
|
if (teamsResponse.data.teams.length > 0) {
|
||||||
|
setTeams(teamsResponse.data.teams);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaginationValues(page, teamsResponse.data.total_count);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTeams(teamSearchTerm);
|
||||||
|
}, [dispatch, page, setPaginationValues, teamSearchTerm]);
|
||||||
|
|
||||||
|
const getHandleToggle = useCallback((teamId: string) => {
|
||||||
|
return () => {
|
||||||
|
const updatedTeamSettings = {...teamReviewersSetting};
|
||||||
|
if (!updatedTeamSettings[teamId]) {
|
||||||
|
updatedTeamSettings[teamId] = {Enabled: false, ReviewerIds: []};
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedTeamSettings[teamId] = {
|
||||||
|
...updatedTeamSettings[teamId],
|
||||||
|
Enabled: updatedTeamSettings[teamId].Enabled ? !updatedTeamSettings[teamId].Enabled : true,
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange(updatedTeamSettings);
|
||||||
|
};
|
||||||
|
}, [onChange, teamReviewersSetting]);
|
||||||
|
|
||||||
|
const getHandleReviewersChange = useCallback((teamId: string) => {
|
||||||
|
return (reviewerIDs: string[]) => {
|
||||||
|
const updatedTeamSettings = {...teamReviewersSetting};
|
||||||
|
if (!updatedTeamSettings[teamId]) {
|
||||||
|
updatedTeamSettings[teamId] = {Enabled: false, ReviewerIds: []};
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedTeamSettings[teamId] = {
|
||||||
|
...updatedTeamSettings[teamId],
|
||||||
|
ReviewerIds: reviewerIDs,
|
||||||
|
};
|
||||||
|
onChange(updatedTeamSettings);
|
||||||
|
};
|
||||||
|
}, [onChange, teamReviewersSetting]);
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: intl.formatMessage({id: 'admin.contentFlagging.reviewerSettings.header.team', defaultMessage: 'Team'}),
|
||||||
|
field: 'team',
|
||||||
|
fixed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: intl.formatMessage({id: 'admin.contentFlagging.reviewerSettings.header.reviewers', defaultMessage: 'Reviewers'}),
|
||||||
|
field: 'reviewers',
|
||||||
|
fixed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: intl.formatMessage({id: 'admin.contentFlagging.reviewerSettings.header.enabled', defaultMessage: 'Enabled'}),
|
||||||
|
field: 'enabled',
|
||||||
|
fixed: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [intl]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
return teams.map((team) => ({
|
||||||
|
cells: {
|
||||||
|
id: team.id,
|
||||||
|
team: (
|
||||||
|
<div className='TeamReviewers__team'>
|
||||||
|
<TeamIcon
|
||||||
|
size='xxs'
|
||||||
|
url={Utils.imageURLForTeam(team)}
|
||||||
|
content={team.display_name}
|
||||||
|
intl={intl}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
data-testid='teamName'
|
||||||
|
className='TeamReviewers__team-name'
|
||||||
|
>
|
||||||
|
{team.display_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
reviewers: (
|
||||||
|
<UserMultiSelector
|
||||||
|
id={`team_content_reviewer_${team.id}`}
|
||||||
|
initialValue={teamReviewersSetting[team.id]?.ReviewerIds || []}
|
||||||
|
onChange={getHandleReviewersChange(team.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enabled: (
|
||||||
|
<Toggle
|
||||||
|
id={`team_content_reviewer_toggle_${team.id}`}
|
||||||
|
ariaLabel={intl.formatMessage({id: 'admin.contentFlagging.reviewerSettings.toggle', defaultMessage: 'Enable or disable content reviewers for this team'})}
|
||||||
|
size='btn-md'
|
||||||
|
onToggle={getHandleToggle(team.id)}
|
||||||
|
toggled={teamReviewersSetting[team.id]?.Enabled || false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [getHandleReviewersChange, getHandleToggle, intl, teamReviewersSetting, teams]);
|
||||||
|
|
||||||
|
const nextPage = useCallback(() => {
|
||||||
|
if ((page * GET_TEAMS_PAGE_SIZE) + GET_TEAMS_PAGE_SIZE < total) {
|
||||||
|
setPage((prevPage) => prevPage + 1);
|
||||||
|
}
|
||||||
|
}, [page, total]);
|
||||||
|
|
||||||
|
const previousPage = useCallback(() => {
|
||||||
|
if (page > 0) {
|
||||||
|
setPage((prevPage) => prevPage - 1);
|
||||||
|
}
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const setSearchTerm = useCallback((term: string) => {
|
||||||
|
setTeamSearchTerm(term);
|
||||||
|
setPage(0); // Reset to first page on new search
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disableAllBtn = useMemo(() => (
|
||||||
|
<div className='TeamReviewers__disable-all'>
|
||||||
|
<button
|
||||||
|
data-testid='copyText'
|
||||||
|
className='btn btn-link icon-close'
|
||||||
|
aria-label={intl.formatMessage({id: 'admin.contentFlagging.reviewerSettings.disableAll', defaultMessage: 'Disable for all teams'})}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({id: 'admin.contentFlagging.reviewerSettings.disableAll', defaultMessage: 'Disable for all teams'})}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
), [intl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='TeamReviewers'>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
page={page}
|
||||||
|
startCount={startCount}
|
||||||
|
endCount={endCount}
|
||||||
|
loading={false}
|
||||||
|
nextPage={nextPage}
|
||||||
|
previousPage={previousPage}
|
||||||
|
total={total}
|
||||||
|
onSearch={setSearchTerm}
|
||||||
|
extraComponent={disableAllBtn}
|
||||||
|
term={teamSearchTerm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {render, screen, fireEvent} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import {IntlProvider} from 'react-intl';
|
||||||
|
|
||||||
|
import type {ContentFlaggingNotificationSettings} from '@mattermost/types/config';
|
||||||
|
|
||||||
|
import type {SystemConsoleCustomSettingsComponentProps} from 'components/admin_console/schema_admin_settings';
|
||||||
|
|
||||||
|
import ContentFlaggingNotificationSettingsSection from './notification_settings';
|
||||||
|
|
||||||
|
const renderWithIntl = (component: React.ReactElement) => {
|
||||||
|
return render(
|
||||||
|
<IntlProvider locale='en'>
|
||||||
|
{component}
|
||||||
|
</IntlProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ContentFlaggingNotificationSettingsSection', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
id: 'test-id',
|
||||||
|
value: {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers'],
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
removed: ['reviewers', 'author'],
|
||||||
|
dismissed: ['reviewers'],
|
||||||
|
},
|
||||||
|
} as ContentFlaggingNotificationSettings,
|
||||||
|
onChange: jest.fn(),
|
||||||
|
} as unknown as SystemConsoleCustomSettingsComponentProps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render section title and description', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Notification Settings')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Choose who receives notifications from the System bot when content is flagged and reviewed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render all notification setting sections', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Notify when content is flagged')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Notify when a reviewer is assigned')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Notify when content is removed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Notify when flag is dismissed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render all checkbox labels', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
// Should have multiple instances of these labels across different sections
|
||||||
|
expect(screen.getAllByText('Reviewer(s)')).toHaveLength(4);
|
||||||
|
expect(screen.getAllByText('Author')).toHaveLength(3);
|
||||||
|
expect(screen.getAllByText('Reporter')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set correct default checked values for checkboxes', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('flagged_reviewers')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('flagged_authors')).not.toBeChecked();
|
||||||
|
|
||||||
|
// Assigned section
|
||||||
|
expect(screen.getByTestId('assigned_reviewers')).toBeChecked();
|
||||||
|
|
||||||
|
// Removed section
|
||||||
|
expect(screen.getByTestId('removed_reviewers')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('removed_author')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('removed_reporter')).not.toBeChecked();
|
||||||
|
|
||||||
|
// Dismissed section
|
||||||
|
expect(screen.getByTestId('dismissed_reviewers')).toBeChecked();
|
||||||
|
expect(screen.getByTestId('dismissed_author')).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId('dismissed_reporter')).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle checkbox change when adding a target', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
const flaggedAuthorsCheckbox = screen.getByTestId('flagged_authors');
|
||||||
|
fireEvent.click(flaggedAuthorsCheckbox);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('test-id', {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers', 'authors'],
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
removed: ['reviewers', 'author'],
|
||||||
|
dismissed: ['reviewers'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle checkbox change when removing a target', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
const removedAuthorCheckbox = screen.getByTestId('removed_author');
|
||||||
|
fireEvent.click(removedAuthorCheckbox);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('test-id', {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers'],
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
removed: ['reviewers'],
|
||||||
|
dismissed: ['reviewers'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should disable flagged_reviewers checkbox', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
const flaggedReviewersCheckbox = screen.getByTestId('flagged_reviewers');
|
||||||
|
expect(flaggedReviewersCheckbox).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not disable other checkboxes', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('flagged_authors')).not.toBeDisabled();
|
||||||
|
expect(screen.getByTestId('assigned_reviewers')).not.toBeDisabled();
|
||||||
|
expect(screen.getByTestId('removed_reviewers')).not.toBeDisabled();
|
||||||
|
expect(screen.getByTestId('removed_author')).not.toBeDisabled();
|
||||||
|
expect(screen.getByTestId('removed_reporter')).not.toBeDisabled();
|
||||||
|
expect(screen.getByTestId('dismissed_reviewers')).not.toBeDisabled();
|
||||||
|
expect(screen.getByTestId('dismissed_author')).not.toBeDisabled();
|
||||||
|
expect(screen.getByTestId('dismissed_reporter')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should initialize EventTargetMapping if not present', () => {
|
||||||
|
const propsWithoutMapping = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {} as ContentFlaggingNotificationSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...propsWithoutMapping}/>);
|
||||||
|
|
||||||
|
const flaggedReviewersCheckbox = screen.getByTestId('flagged_reviewers');
|
||||||
|
fireEvent.click(flaggedReviewersCheckbox);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('test-id', {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers'],
|
||||||
|
assigned: [],
|
||||||
|
removed: [],
|
||||||
|
dismissed: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should initialize action array if not present', () => {
|
||||||
|
const propsWithPartialMapping = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers'],
|
||||||
|
|
||||||
|
// missing assigned, removed, dismissed
|
||||||
|
},
|
||||||
|
} as ContentFlaggingNotificationSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...propsWithPartialMapping}/>);
|
||||||
|
|
||||||
|
const assignedReviewersCheckbox = screen.getByTestId('assigned_reviewers');
|
||||||
|
fireEvent.click(assignedReviewersCheckbox);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('test-id', {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers'],
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not add duplicate targets', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
// Try to add 'reviewers' to flagged again (it's already there)
|
||||||
|
const flaggedReviewersCheckbox = screen.getByTestId('flagged_reviewers');
|
||||||
|
fireEvent.click(flaggedReviewersCheckbox);
|
||||||
|
fireEvent.click(flaggedReviewersCheckbox);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledWith('test-id', {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers'], // Should remain the same, no duplicate
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
removed: ['reviewers', 'author'],
|
||||||
|
dismissed: ['reviewers'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle multiple checkbox changes correctly', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
// First change: add author to flagged
|
||||||
|
const flaggedAuthorsCheckbox = screen.getByTestId('flagged_authors');
|
||||||
|
fireEvent.click(flaggedAuthorsCheckbox);
|
||||||
|
|
||||||
|
// Second change: add reporter to removed
|
||||||
|
const removedReporterCheckbox = screen.getByTestId('removed_reporter');
|
||||||
|
fireEvent.click(removedReporterCheckbox);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenNthCalledWith(1, 'test-id', {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers', 'authors'],
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
removed: ['reviewers', 'author'],
|
||||||
|
dismissed: ['reviewers'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenNthCalledWith(2, 'test-id', {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers', 'authors'],
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
removed: ['reviewers', 'author', 'reporter'],
|
||||||
|
dismissed: ['reviewers'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle unchecking and rechecking the same checkbox', () => {
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...defaultProps}/>);
|
||||||
|
|
||||||
|
const removedAuthorCheckbox = screen.getByTestId('removed_author');
|
||||||
|
|
||||||
|
// First click: uncheck (remove author from removed)
|
||||||
|
fireEvent.click(removedAuthorCheckbox);
|
||||||
|
expect(defaultProps.onChange).toHaveBeenNthCalledWith(1, 'test-id', {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers'],
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
removed: ['reviewers'],
|
||||||
|
dismissed: ['reviewers'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second click: check again (add author back to removed)
|
||||||
|
fireEvent.click(removedAuthorCheckbox);
|
||||||
|
expect(defaultProps.onChange).toHaveBeenNthCalledWith(2, 'test-id', {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: ['reviewers'],
|
||||||
|
assigned: ['reviewers'],
|
||||||
|
removed: ['reviewers', 'author'],
|
||||||
|
dismissed: ['reviewers'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defaultProps.onChange).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty EventTargetMapping arrays', () => {
|
||||||
|
const propsWithEmptyArrays = {
|
||||||
|
...defaultProps,
|
||||||
|
value: {
|
||||||
|
EventTargetMapping: {
|
||||||
|
flagged: [],
|
||||||
|
assigned: [],
|
||||||
|
removed: [],
|
||||||
|
dismissed: [],
|
||||||
|
},
|
||||||
|
} as unknown as ContentFlaggingNotificationSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithIntl(<ContentFlaggingNotificationSettingsSection {...propsWithEmptyArrays}/>);
|
||||||
|
|
||||||
|
// All checkboxes should be unchecked
|
||||||
|
expect(screen.getByTestId('flagged_reviewers')).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId('flagged_authors')).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId('assigned_reviewers')).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId('removed_reviewers')).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId('removed_author')).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId('removed_reporter')).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId('dismissed_reviewers')).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId('dismissed_author')).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId('dismissed_reporter')).not.toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useCallback, useState} from 'react';
|
||||||
|
import {FormattedMessage} from 'react-intl';
|
||||||
|
|
||||||
|
import type {ContentFlaggingNotificationSettings} from '@mattermost/types/config';
|
||||||
|
import type {ContentFlaggingEvent, NotificationTarget} from '@mattermost/types/content_flagging';
|
||||||
|
|
||||||
|
import CheckboxSetting from 'components/admin_console/checkbox_setting';
|
||||||
|
import type {SystemConsoleCustomSettingsComponentProps} from 'components/admin_console/schema_admin_settings';
|
||||||
|
import {
|
||||||
|
AdminSection,
|
||||||
|
SectionContent,
|
||||||
|
SectionHeader,
|
||||||
|
} from 'components/admin_console/system_properties/controls';
|
||||||
|
|
||||||
|
import '../content_flagging_section_base.scss';
|
||||||
|
|
||||||
|
export default function ContentFlaggingNotificationSettingsSection({id, value, onChange}: SystemConsoleCustomSettingsComponentProps) {
|
||||||
|
const [notificationSettings, setNotificationSettings] = useState<ContentFlaggingNotificationSettings>(value as ContentFlaggingNotificationSettings);
|
||||||
|
|
||||||
|
const handleChange = useCallback((inputId: string, value: boolean) => {
|
||||||
|
const [actionRaw, targetRaw] = inputId.split('_');
|
||||||
|
const action = actionRaw as ContentFlaggingEvent;
|
||||||
|
const target = targetRaw as NotificationTarget;
|
||||||
|
if (!action || !target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSettings = {...notificationSettings};
|
||||||
|
if (!updatedSettings.EventTargetMapping) {
|
||||||
|
updatedSettings.EventTargetMapping = {
|
||||||
|
flagged: [],
|
||||||
|
assigned: [],
|
||||||
|
removed: [],
|
||||||
|
dismissed: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatedSettings.EventTargetMapping[action]) {
|
||||||
|
updatedSettings.EventTargetMapping[action] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
// Add target to the action's list if not already present
|
||||||
|
if (!updatedSettings.EventTargetMapping[action].includes(target)) {
|
||||||
|
updatedSettings.EventTargetMapping = {
|
||||||
|
...updatedSettings.EventTargetMapping,
|
||||||
|
[action]: [...updatedSettings.EventTargetMapping[action], target],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove target from the action's list if present
|
||||||
|
updatedSettings.EventTargetMapping = {
|
||||||
|
...updatedSettings.EventTargetMapping,
|
||||||
|
[action]: updatedSettings.EventTargetMapping[action].filter((t: NotificationTarget) => t !== target),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotificationSettings(updatedSettings);
|
||||||
|
|
||||||
|
onChange(id, updatedSettings);
|
||||||
|
}, [id, notificationSettings, onChange]);
|
||||||
|
|
||||||
|
const getValue = useCallback((event: ContentFlaggingEvent, target: NotificationTarget): boolean => {
|
||||||
|
if (!notificationSettings || !notificationSettings.EventTargetMapping) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return notificationSettings.EventTargetMapping[event]?.includes(target) || false;
|
||||||
|
}, [notificationSettings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSection>
|
||||||
|
<SectionHeader>
|
||||||
|
<hgroup>
|
||||||
|
<h1 className='content-flagging-section-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.title'
|
||||||
|
defaultMessage='Notification Settings'
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<h5 className='content-flagging-section-description'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.description'
|
||||||
|
defaultMessage='Choose who receives notifications from the System bot when content is flagged and reviewed'
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
</hgroup>
|
||||||
|
</SectionHeader>
|
||||||
|
<SectionContent>
|
||||||
|
<div className='content-flagging-section-setting-wrapper'>
|
||||||
|
|
||||||
|
{/*Notify on flagging*/}
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.notifyOnFlag'
|
||||||
|
defaultMessage='Notify when content is flagged'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<CheckboxSetting
|
||||||
|
id='flagged_reviewers'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.reviewers'
|
||||||
|
defaultMessage='Reviewer(s)'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={getValue('flagged', 'reviewers')}
|
||||||
|
onChange={handleChange}
|
||||||
|
setByEnv={false}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxSetting
|
||||||
|
id='flagged_authors'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.author'
|
||||||
|
defaultMessage='Author'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={getValue('flagged', 'author')}
|
||||||
|
onChange={handleChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*Notify on reviewer assigned*/}
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.notifyOnReviewerAssigned'
|
||||||
|
defaultMessage='Notify when a reviewer is assigned'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<CheckboxSetting
|
||||||
|
id='assigned_reviewers'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.reviewers'
|
||||||
|
defaultMessage='Reviewer(s)'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={getValue('assigned', 'reviewers')}
|
||||||
|
onChange={handleChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*Notify on removal*/}
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.notifyOnRemoval'
|
||||||
|
defaultMessage='Notify when content is removed'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<CheckboxSetting
|
||||||
|
id='removed_reviewers'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.reviewers'
|
||||||
|
defaultMessage='Reviewer(s)'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={getValue('removed', 'reviewers')}
|
||||||
|
onChange={handleChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxSetting
|
||||||
|
id='removed_author'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.author'
|
||||||
|
defaultMessage='Author'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={getValue('removed', 'author')}
|
||||||
|
onChange={handleChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxSetting
|
||||||
|
id='removed_reporter'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.reporter'
|
||||||
|
defaultMessage='Reporter'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={getValue('removed', 'reporter')}
|
||||||
|
onChange={handleChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*Notify on dismiss*/}
|
||||||
|
<div className='content-flagging-section-setting'>
|
||||||
|
<div className='setting-title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.notifyOnDismissal'
|
||||||
|
defaultMessage='Notify when flag is dismissed'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='setting-content'>
|
||||||
|
<CheckboxSetting
|
||||||
|
id='dismissed_reviewers'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.reviewers'
|
||||||
|
defaultMessage='Reviewer(s)'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={getValue('dismissed', 'reviewers')}
|
||||||
|
onChange={handleChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxSetting
|
||||||
|
id='dismissed_author'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.author'
|
||||||
|
defaultMessage='Author'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={getValue('dismissed', 'author')}
|
||||||
|
onChange={handleChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxSetting
|
||||||
|
id='dismissed_reporter'
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.contentFlagging.notificationSettings.reporter'
|
||||||
|
defaultMessage='Reporter'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultChecked={getValue('dismissed', 'reporter')}
|
||||||
|
onChange={handleChange}
|
||||||
|
setByEnv={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionContent>
|
||||||
|
</AdminSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`components/admin_console/content_flagging/user_multiselector/UserProfilePill should render user profile pill with avatar and display name 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="UserProfilePill"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="testuser profile image"
|
||||||
|
class="Avatar Avatar-xxs"
|
||||||
|
loading="lazy"
|
||||||
|
src="/api/v4/users/user-id-1/image?_=0"
|
||||||
|
/>
|
||||||
|
Test User
|
||||||
|
<div
|
||||||
|
class="Remove"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<svg
|
||||||
|
aria-label="Close Icon"
|
||||||
|
height="16px"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width="16px"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m 8,0 c 4.424,0 8,3.576 8,8 0,4.424 -3.576,8 -8,8 C 3.576,16 0,12.424 0,8 0,3.576 3.576,0 8,0 Z M 10.872,4 8,6.872 5.128,4 4,5.128 6.872,8 4,10.872 5.128,12 8,9.128 10.872,12 12,10.872 9.128,8 12,5.128 Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
.UserMultiSelector {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__menu-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, {type ReactElement, useCallback, useEffect, useMemo, useRef} from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import {useDispatch, useSelector} from 'react-redux';
|
||||||
|
import type {MultiValue} from 'react-select';
|
||||||
|
import AsyncSelect from 'react-select/async';
|
||||||
|
|
||||||
|
import type {UserProfile} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import {debounce} from 'mattermost-redux/actions/helpers';
|
||||||
|
import {getMissingProfilesByIds, searchProfiles} from 'mattermost-redux/actions/users';
|
||||||
|
import {getUsersByIDs} from 'mattermost-redux/selectors/entities/users';
|
||||||
|
|
||||||
|
import type {GlobalState} from 'types/store';
|
||||||
|
|
||||||
|
import {UserProfilePill} from './user_profile_pill';
|
||||||
|
|
||||||
|
import {UserOptionComponent} from '../../content_flagging/user_multiselector/user_profile_option';
|
||||||
|
import {LoadingIndicator} from '../../system_users/system_users_filters_popover/system_users_filter_team';
|
||||||
|
|
||||||
|
import './user_multiselect.scss';
|
||||||
|
|
||||||
|
export type AutocompleteOptionType<T> = {
|
||||||
|
label: string | ReactElement;
|
||||||
|
value: string;
|
||||||
|
raw?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
className?: string;
|
||||||
|
onChange: (selectedUserIds: string[]) => void;
|
||||||
|
initialValue?: string[];
|
||||||
|
hasError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMultiSelector({id, className, onChange, initialValue, hasError}: Props) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const {formatMessage} = useIntl();
|
||||||
|
const initialDataLoaded = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInitialData = async () => {
|
||||||
|
await dispatch(getMissingProfilesByIds(initialValue || []));
|
||||||
|
initialDataLoaded.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (initialValue && !initialDataLoaded.current) {
|
||||||
|
fetchInitialData();
|
||||||
|
}
|
||||||
|
}, [dispatch, initialValue]);
|
||||||
|
|
||||||
|
const initialUsers = useSelector((state: GlobalState) => getUsersByIDs(state, initialValue || []));
|
||||||
|
const selectInitialValue = initialUsers.
|
||||||
|
filter((userProfile) => Boolean(userProfile)).
|
||||||
|
map((userProfile: UserProfile) => ({
|
||||||
|
value: userProfile.id,
|
||||||
|
label: userProfile.username,
|
||||||
|
raw: userProfile,
|
||||||
|
} as AutocompleteOptionType<UserProfile>));
|
||||||
|
|
||||||
|
const userLoadingMessage = useCallback(() => formatMessage({id: 'admin.userMultiSelector.loading', defaultMessage: 'Loading users'}), [formatMessage]);
|
||||||
|
const noUsersMessage = useCallback(() => formatMessage({id: 'admin.userMultiSelector.noUsers', defaultMessage: 'No users found'}), [formatMessage]);
|
||||||
|
const placeholder = formatMessage({id: 'admin.userMultiSelector.placeholder', defaultMessage: 'Start typing to search for users...'});
|
||||||
|
|
||||||
|
const searchUsers = useMemo(() => debounce(async (searchTerm: string, callback) => {
|
||||||
|
try {
|
||||||
|
const response = await dispatch(searchProfiles(searchTerm, {page: 0}));
|
||||||
|
if (response && response.data && response.data.length > 0) {
|
||||||
|
const users = response.data.
|
||||||
|
filter((userProfile) => !userProfile.is_bot).
|
||||||
|
map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.username,
|
||||||
|
raw: user,
|
||||||
|
}));
|
||||||
|
|
||||||
|
callback(users);
|
||||||
|
} else {
|
||||||
|
callback([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
|
callback([]);
|
||||||
|
}
|
||||||
|
}, 200), [dispatch]);
|
||||||
|
|
||||||
|
function handleOnChange(value: MultiValue<AutocompleteOptionType<UserProfile>>) {
|
||||||
|
const selectedUserIds = value.map((option) => option.value);
|
||||||
|
onChange?.(selectedUserIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='UserMultiSelector'>
|
||||||
|
<AsyncSelect
|
||||||
|
id={id}
|
||||||
|
inputId={`${id}_input`}
|
||||||
|
classNamePrefix='UserMultiSelector'
|
||||||
|
className={classNames('Input Input__focus', className, {error: hasError})}
|
||||||
|
isMulti={true}
|
||||||
|
isClearable={false}
|
||||||
|
hideSelectedOptions={true}
|
||||||
|
cacheOptions={true}
|
||||||
|
placeholder={placeholder}
|
||||||
|
loadingMessage={userLoadingMessage}
|
||||||
|
noOptionsMessage={noUsersMessage}
|
||||||
|
loadOptions={searchUsers}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
value={selectInitialValue}
|
||||||
|
components={{
|
||||||
|
LoadingIndicator,
|
||||||
|
DropdownIndicator: () => null,
|
||||||
|
IndicatorSeparator: () => null,
|
||||||
|
Option: UserOptionComponent,
|
||||||
|
MultiValue: UserProfilePill,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
.UserOptionComponent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--center-channel-color-08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {useSelector} from 'react-redux';
|
||||||
|
import type {OptionProps} from 'react-select';
|
||||||
|
|
||||||
|
import type {UserProfile} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import Avatar from 'components/widgets/users/avatar';
|
||||||
|
|
||||||
|
import {getDisplayNameByUser, imageURLForUser} from 'utils/utils';
|
||||||
|
|
||||||
|
import type {GlobalState} from 'types/store';
|
||||||
|
|
||||||
|
import type {AutocompleteOptionType} from './user_multiselector';
|
||||||
|
|
||||||
|
import './user_profile_option.scss';
|
||||||
|
|
||||||
|
export function UserOptionComponent(props: OptionProps<AutocompleteOptionType<UserProfile>, true>) {
|
||||||
|
const {data, innerProps} = props;
|
||||||
|
|
||||||
|
const userProfile = data.raw;
|
||||||
|
const userDisplayName = useSelector((state: GlobalState) => getDisplayNameByUser(state, userProfile));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='UserOptionComponent'
|
||||||
|
{...innerProps}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
size='xxs'
|
||||||
|
username={userProfile?.username}
|
||||||
|
url={imageURLForUser(data.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{userDisplayName}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
.UserProfilePill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--center-channel-color-08);
|
||||||
|
color: var(--center-channel-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
gap: 5px;
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Remove {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-top: 4px;
|
||||||
|
fill: var(--center-channel-color-32);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type {MultiValueProps} from 'react-select/dist/declarations/src/components/MultiValue';
|
||||||
|
|
||||||
|
import type {UserProfile} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import {fireEvent, renderWithContext} from 'tests/react_testing_utils';
|
||||||
|
import {TestHelper} from 'utils/test_helper';
|
||||||
|
|
||||||
|
import type {AutocompleteOptionType} from './user_multiselector';
|
||||||
|
import {UserProfilePill} from './user_profile_pill';
|
||||||
|
|
||||||
|
describe('components/admin_console/content_flagging/user_multiselector/UserProfilePill', () => {
|
||||||
|
const baseProps = {
|
||||||
|
data: {
|
||||||
|
value: 'user-id-1',
|
||||||
|
label: 'Test User',
|
||||||
|
raw: TestHelper.getUserMock({
|
||||||
|
id: 'user-id-1',
|
||||||
|
username: 'testuser',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
}),
|
||||||
|
} as AutocompleteOptionType<UserProfile>,
|
||||||
|
innerProps: {},
|
||||||
|
selectProps: {},
|
||||||
|
removeProps: {
|
||||||
|
onClick: jest.fn(),
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
isDisabled: false,
|
||||||
|
isFocused: false,
|
||||||
|
getValue: jest.fn(),
|
||||||
|
hasValue: true,
|
||||||
|
options: [],
|
||||||
|
setValue: jest.fn(),
|
||||||
|
clearValue: jest.fn(),
|
||||||
|
cx: jest.fn(),
|
||||||
|
getStyles: jest.fn(),
|
||||||
|
getClassNames: jest.fn(),
|
||||||
|
isMulti: true,
|
||||||
|
isRtl: false,
|
||||||
|
theme: {} as any,
|
||||||
|
} as unknown as MultiValueProps<AutocompleteOptionType<UserProfile>, true>;
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
entities: {
|
||||||
|
users: {
|
||||||
|
profiles: {
|
||||||
|
'user-id-1': baseProps.data.raw,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
myPreferences: {},
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
config: {
|
||||||
|
TeammateNameDisplay: 'full_name',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render user profile pill with avatar and display name', () => {
|
||||||
|
const {container} = renderWithContext(
|
||||||
|
<UserProfilePill {...baseProps}/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render with correct user display name', () => {
|
||||||
|
const {container} = renderWithContext(
|
||||||
|
<UserProfilePill {...baseProps}/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pill = container.querySelector('.UserProfilePill');
|
||||||
|
expect(pill).toBeInTheDocument();
|
||||||
|
expect(pill).toHaveTextContent('Test User');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render Avatar component with correct props', () => {
|
||||||
|
const {container} = renderWithContext(
|
||||||
|
<UserProfilePill {...baseProps}/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const avatar = container.querySelector('.Avatar');
|
||||||
|
expect(avatar).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render Remove component with close icon', () => {
|
||||||
|
const {container} = renderWithContext(
|
||||||
|
<UserProfilePill {...baseProps}/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeComponent = container.querySelector('.Remove');
|
||||||
|
expect(removeComponent).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call onClick when remove button is clicked', () => {
|
||||||
|
const mockOnClick = jest.fn();
|
||||||
|
const propsWithClick = {
|
||||||
|
...baseProps,
|
||||||
|
removeProps: {
|
||||||
|
onClick: mockOnClick,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const {container} = renderWithContext(
|
||||||
|
<UserProfilePill {...propsWithClick}/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeComponent = container.querySelector('.Remove');
|
||||||
|
expect(removeComponent).toBeInTheDocument();
|
||||||
|
expect(removeComponent).toBeDefined();
|
||||||
|
|
||||||
|
fireEvent.click(removeComponent!);
|
||||||
|
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle user profile without username gracefully', () => {
|
||||||
|
const propsWithoutUsername = {
|
||||||
|
...baseProps,
|
||||||
|
data: {
|
||||||
|
...baseProps.data,
|
||||||
|
raw: {
|
||||||
|
...baseProps.data.raw,
|
||||||
|
username: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as MultiValueProps<AutocompleteOptionType<UserProfile>, true>;
|
||||||
|
|
||||||
|
const {container} = renderWithContext(
|
||||||
|
<UserProfilePill {...propsWithoutUsername}/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pill = container.querySelector('.UserProfilePill');
|
||||||
|
expect(pill).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use different display name based on config', () => {
|
||||||
|
const stateWithUsernameDisplay = {
|
||||||
|
...initialState,
|
||||||
|
entities: {
|
||||||
|
...initialState.entities,
|
||||||
|
general: {
|
||||||
|
config: {
|
||||||
|
TeammateNameDisplay: 'username',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const {container} = renderWithContext(
|
||||||
|
<UserProfilePill {...baseProps}/>,
|
||||||
|
stateWithUsernameDisplay,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pill = container.querySelector('.UserProfilePill');
|
||||||
|
expect(pill).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should apply correct CSS classes', () => {
|
||||||
|
const {container} = renderWithContext(
|
||||||
|
<UserProfilePill {...baseProps}/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pill = container.querySelector('.UserProfilePill');
|
||||||
|
expect(pill).toBeInTheDocument();
|
||||||
|
expect(pill).toHaveClass('UserProfilePill');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {useSelector} from 'react-redux';
|
||||||
|
import type {MultiValueProps} from 'react-select/dist/declarations/src/components/MultiValue';
|
||||||
|
|
||||||
|
import type {UserProfile} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import CloseCircleSolidIcon from 'components/widgets/icons/close_circle_solid_icon';
|
||||||
|
import Avatar from 'components/widgets/users/avatar/avatar';
|
||||||
|
|
||||||
|
import {getDisplayNameByUser, imageURLForUser} from 'utils/utils';
|
||||||
|
|
||||||
|
import type {GlobalState} from 'types/store';
|
||||||
|
|
||||||
|
import type {AutocompleteOptionType} from './user_multiselector';
|
||||||
|
|
||||||
|
import './user_profile_pill.scss';
|
||||||
|
|
||||||
|
function Remove(props: any) {
|
||||||
|
const {innerProps, children} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='Remove'
|
||||||
|
{...innerProps}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
{children || <CloseCircleSolidIcon/>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserProfilePill(props: MultiValueProps<AutocompleteOptionType<UserProfile>, true>) {
|
||||||
|
const {data, innerProps, selectProps, removeProps} = props;
|
||||||
|
|
||||||
|
const userProfile = data.raw;
|
||||||
|
const userDisplayName = useSelector((state: GlobalState) => getDisplayNameByUser(state, userProfile));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='UserProfilePill'
|
||||||
|
{...innerProps}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
size='xxs'
|
||||||
|
username={userProfile?.username}
|
||||||
|
url={imageURLForUser(data.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{userDisplayName}
|
||||||
|
|
||||||
|
<Remove
|
||||||
|
data={data}
|
||||||
|
innerProps={innerProps}
|
||||||
|
selectProps={selectProps}
|
||||||
|
{...removeProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -262,6 +262,7 @@ class DataGrid extends React.PureComponent<Props, State> {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
aria-label='Previous page'
|
||||||
type='button'
|
type='button'
|
||||||
className={'btn btn-quaternary btn-icon btn-sm ml-2 prev ' + (firstPage ? 'disabled' : '')}
|
className={'btn btn-quaternary btn-icon btn-sm ml-2 prev ' + (firstPage ? 'disabled' : '')}
|
||||||
onClick={prevPageFn}
|
onClick={prevPageFn}
|
||||||
@@ -270,6 +271,7 @@ class DataGrid extends React.PureComponent<Props, State> {
|
|||||||
<PreviousIcon/>
|
<PreviousIcon/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-label='Next page'
|
||||||
type='button'
|
type='button'
|
||||||
className={'btn btn-quaternary btn-icon btn-sm next ' + (lastPage ? 'disabled' : '')}
|
className={'btn btn-quaternary btn-icon btn-sm next ' + (lastPage ? 'disabled' : '')}
|
||||||
onClick={nextPageFn}
|
onClick={nextPageFn}
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ import './schema_admin_settings.scss';
|
|||||||
|
|
||||||
const emptyList: string[] = [];
|
const emptyList: string[] = [];
|
||||||
|
|
||||||
|
export type SystemConsoleCustomSettingChangeHandler = (id: string, value: any, confirm?: boolean, doSubmit?: boolean, warning?: boolean) => void;
|
||||||
|
|
||||||
|
export type SystemConsoleCustomSettingsComponentProps = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
helpText: string;
|
||||||
|
value: unknown;
|
||||||
|
disabled: boolean;
|
||||||
|
config: Partial<AdminConfig>;
|
||||||
|
license: ClientLicense;
|
||||||
|
setByEnv: boolean;
|
||||||
|
onChange: SystemConsoleCustomSettingChangeHandler;
|
||||||
|
registerSaveAction: (saveAction: () => Promise<{error?: {message?: string}}>) => void;
|
||||||
|
setSaveNeeded: () => void;
|
||||||
|
unRegisterSaveAction: (saveAction: () => Promise<{error?: {message?: string}}>) => void;
|
||||||
|
cancelSubmit: () => void;
|
||||||
|
showConfirm: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
config: Partial<AdminConfig>;
|
config: Partial<AdminConfig>;
|
||||||
environmentConfig: Partial<EnvironmentConfig>;
|
environmentConfig: Partial<EnvironmentConfig>;
|
||||||
|
|||||||
@@ -480,6 +480,7 @@ exports[`components/admin_console/server_logs/Logs should display the logs corre
|
|||||||
>
|
>
|
||||||
1 - 3 of 3
|
1 - 3 of 3
|
||||||
<button
|
<button
|
||||||
|
aria-label="Previous page"
|
||||||
class="btn btn-quaternary btn-icon btn-sm ml-2 prev disabled"
|
class="btn btn-quaternary btn-icon btn-sm ml-2 prev disabled"
|
||||||
disabled=""
|
disabled=""
|
||||||
type="button"
|
type="button"
|
||||||
@@ -490,6 +491,7 @@ exports[`components/admin_console/server_logs/Logs should display the logs corre
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Next page"
|
||||||
class="btn btn-quaternary btn-icon btn-sm next disabled"
|
class="btn btn-quaternary btn-icon btn-sm next disabled"
|
||||||
disabled=""
|
disabled=""
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ const Option = (props: OptionProps<OptionType, false>) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LoadingIndicator = () => {
|
export const LoadingIndicator = () => {
|
||||||
return (
|
return (
|
||||||
<LoadingSpinner/>
|
<LoadingSpinner/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ function getDefaultStateFromProps(props: Props): State {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = (props: InputProps<MultiInputValue, true>) => {
|
export const CreatableReactSelectInput = (props: InputProps<MultiInputValue, true>) => {
|
||||||
const ariaProps = {
|
const ariaProps = {
|
||||||
'aria-labelledby': 'settingTitle',
|
'aria-labelledby': 'settingTitle',
|
||||||
};
|
};
|
||||||
@@ -609,7 +609,7 @@ class NotificationsTab extends React.PureComponent<Props, State> {
|
|||||||
DropdownIndicator: () => null,
|
DropdownIndicator: () => null,
|
||||||
Menu: () => null,
|
Menu: () => null,
|
||||||
MenuList: () => null,
|
MenuList: () => null,
|
||||||
Input,
|
Input: CreatableReactSelectInput,
|
||||||
}}
|
}}
|
||||||
onChange={this.handleChangeForCustomKeysWithNotificationInput}
|
onChange={this.handleChangeForCustomKeysWithNotificationInput}
|
||||||
value={this.state.customKeysWithNotification}
|
value={this.state.customKeysWithNotification}
|
||||||
|
|||||||
@@ -71,6 +71,16 @@
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.TeamIcon__xsm {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.TeamIcon__xxs {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
&.withImage {
|
&.withImage {
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
@@ -84,6 +94,16 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.TeamIcon__xsm {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.TeamIcon__xxs {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
.TeamIcon__image {
|
.TeamIcon__image {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: box-shadow 200ms;
|
transition: box-shadow 200ms;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type Props = {
|
|||||||
*
|
*
|
||||||
* @default "regular"
|
* @default "regular"
|
||||||
**/
|
**/
|
||||||
size?: 'sm' | 'lg';
|
size?: 'sm' | 'lg' | 'xsm' | 'xxs';
|
||||||
|
|
||||||
/** Whether to add hover effect to the icon */
|
/** Whether to add hover effect to the icon */
|
||||||
withHover?: boolean;
|
withHover?: boolean;
|
||||||
|
|||||||
@@ -761,6 +761,37 @@
|
|||||||
"admin.connectionSecurityTitle": "Connection Security:",
|
"admin.connectionSecurityTitle": "Connection Security:",
|
||||||
"admin.connectionSecurityTls": "TLS",
|
"admin.connectionSecurityTls": "TLS",
|
||||||
"admin.connectionSecurityTlsDescription": "Encrypts the communication between Mattermost and your server.",
|
"admin.connectionSecurityTlsDescription": "Encrypts the communication between Mattermost and your server.",
|
||||||
|
"admin.contentFlagging.additionalSettings.description": "Configure how you want the flagging to behave",
|
||||||
|
"admin.contentFlagging.additionalSettings.hideFlaggedPosts": "Hide message from channel while it is being reviewed",
|
||||||
|
"admin.contentFlagging.additionalSettings.reasonsForFlagging": "Reasons for flagging",
|
||||||
|
"admin.contentFlagging.additionalSettings.requireReporterComment": "Require reporters to add comment",
|
||||||
|
"admin.contentFlagging.additionalSettings.requireReviewerComment": "Require reviewers to add comment",
|
||||||
|
"admin.contentFlagging.additionalSettings.title": "Additional Settings",
|
||||||
|
"admin.contentFlagging.enableTitle": "Enable Content Flagging",
|
||||||
|
"admin.contentFlagging.notificationSettings.author": "Author",
|
||||||
|
"admin.contentFlagging.notificationSettings.description": "Choose who receives notifications from the System bot when content is flagged and reviewed",
|
||||||
|
"admin.contentFlagging.notificationSettings.notifyOnDismissal": "Notify on dismissal:",
|
||||||
|
"admin.contentFlagging.notificationSettings.notifyOnFlag": "Notify when content is flagged:",
|
||||||
|
"admin.contentFlagging.notificationSettings.notifyOnRemoval": "Notify when content is removed:",
|
||||||
|
"admin.contentFlagging.notificationSettings.notifyOnReviewerAssigned": "Notify when a reviewer is assigned:",
|
||||||
|
"admin.contentFlagging.notificationSettings.reporter": "Reporter",
|
||||||
|
"admin.contentFlagging.notificationSettings.reviewers": "Reviewer(s)",
|
||||||
|
"admin.contentFlagging.notificationSettings.title": "Notification Settings",
|
||||||
|
"admin.contentFlagging.reviewerSettings.additionalReviewers": "Additional reviewers",
|
||||||
|
"admin.contentFlagging.reviewerSettings.additionalReviewers.helpText": "If enabled, system administrators will be sent flagged posts for review from every team that they are a part of. Team administrators will only be sent flagged posts for review from their respective teams.",
|
||||||
|
"admin.contentFlagging.reviewerSettings.additionalReviewers.systemAdmins": "System Administrators",
|
||||||
|
"admin.contentFlagging.reviewerSettings.additionalReviewers.teamAdmins": "Team Administrators",
|
||||||
|
"admin.contentFlagging.reviewerSettings.commonReviewers": "Reviewers",
|
||||||
|
"admin.contentFlagging.reviewerSettings.description": "Define who should review content in your environment",
|
||||||
|
"admin.contentFlagging.reviewerSettings.disableAll": "Disable for all teams",
|
||||||
|
"admin.contentFlagging.reviewerSettings.header.enabled": "Enabled",
|
||||||
|
"admin.contentFlagging.reviewerSettings.header.reviewers": "Reviewers",
|
||||||
|
"admin.contentFlagging.reviewerSettings.header.team": "Team",
|
||||||
|
"admin.contentFlagging.reviewerSettings.perTeamReviewers.title": "Configure content flagging per team",
|
||||||
|
"admin.contentFlagging.reviewerSettings.sameReviewersForAllTeams": "Same reviewers for all teams:",
|
||||||
|
"admin.contentFlagging.reviewerSettings.title": "Content Reviewers",
|
||||||
|
"admin.contentFlagging.reviewerSettings.toggle": "Enable or disable content reviewers for this team",
|
||||||
|
"admin.contentFlagging.title": "Content Flagging",
|
||||||
"admin.custom_terms_of_service_feature_discovery.copy": "Create your own terms of service that new users must accept before accessing your Mattermost instance on desktop, web, or mobile.",
|
"admin.custom_terms_of_service_feature_discovery.copy": "Create your own terms of service that new users must accept before accessing your Mattermost instance on desktop, web, or mobile.",
|
||||||
"admin.custom_terms_of_service_feature_discovery.title": "Create custom terms of service with Mattermost Enterprise",
|
"admin.custom_terms_of_service_feature_discovery.title": "Create custom terms of service with Mattermost Enterprise",
|
||||||
"admin.customization.allowSyncedDrafts": "Enable server syncing of message drafts:",
|
"admin.customization.allowSyncedDrafts": "Enable server syncing of message drafts:",
|
||||||
@@ -2655,6 +2686,7 @@
|
|||||||
"admin.sidebar.compliance": "Compliance",
|
"admin.sidebar.compliance": "Compliance",
|
||||||
"admin.sidebar.complianceExport": "Compliance Export",
|
"admin.sidebar.complianceExport": "Compliance Export",
|
||||||
"admin.sidebar.complianceMonitoring": "Compliance Monitoring",
|
"admin.sidebar.complianceMonitoring": "Compliance Monitoring",
|
||||||
|
"admin.sidebar.contentFlagging": "Content Flagging",
|
||||||
"admin.sidebar.cors": "CORS",
|
"admin.sidebar.cors": "CORS",
|
||||||
"admin.sidebar.customization": "Customization",
|
"admin.sidebar.customization": "Customization",
|
||||||
"admin.sidebar.customTermsOfService": "Custom Terms of Service",
|
"admin.sidebar.customTermsOfService": "Custom Terms of Service",
|
||||||
@@ -3128,6 +3160,9 @@
|
|||||||
"admin.userManagement.userDetail.teamsTitle": "Team Membership",
|
"admin.userManagement.userDetail.teamsTitle": "Team Membership",
|
||||||
"admin.userManagement.userDetail.userId": "User ID: {userId}",
|
"admin.userManagement.userDetail.userId": "User ID: {userId}",
|
||||||
"admin.userManagement.userDetail.username": "Username",
|
"admin.userManagement.userDetail.username": "Username",
|
||||||
|
"admin.userMultiSelector.loading": "Loading users",
|
||||||
|
"admin.userMultiSelector.noUsers": "No users found",
|
||||||
|
"admin.userMultiSelector.placeholder": "Start typing to search for users...",
|
||||||
"admin.viewArchivedChannelsHelpText": "When true, allows users to view, share and search for content of channels that have been archived. Users can only view the content in channels of which they were a member before the channel was archived.",
|
"admin.viewArchivedChannelsHelpText": "When true, allows users to view, share and search for content of channels that have been archived. Users can only view the content in channels of which they were a member before the channel was archived.",
|
||||||
"admin.viewArchivedChannelsTitle": "Allow users to view archived channels:",
|
"admin.viewArchivedChannelsTitle": "Allow users to view archived channels:",
|
||||||
"admin.webserverModeDisabled": "Disabled",
|
"admin.webserverModeDisabled": "Disabled",
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export function getUser(state: GlobalState, id: UserProfile['id']): UserProfile
|
|||||||
return state.entities.users.profiles[id];
|
return state.entities.users.profiles[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUsersByIDs(state: GlobalState, ids: Array<UserProfile['id']>): UserProfile[] {
|
||||||
|
return ids.map((userId) => getUser(state, userId));
|
||||||
|
}
|
||||||
|
|
||||||
export const getUsersByUsername: (a: GlobalState) => Record<string, UserProfile> = createSelector(
|
export const getUsersByUsername: (a: GlobalState) => Record<string, UserProfile> = createSelector(
|
||||||
'getUsersByUsername',
|
'getUsersByUsername',
|
||||||
getUsers,
|
getUsers,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import type {ContentFlaggingEvent, NotificationTarget} from './content_flagging';
|
||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
AboutLink: string;
|
AboutLink: string;
|
||||||
AllowBannerDismissal: string;
|
AllowBannerDismissal: string;
|
||||||
@@ -990,6 +992,36 @@ export type AccessControlSettings = {
|
|||||||
EnableChannelScopeAccessControl: boolean;
|
EnableChannelScopeAccessControl: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ContentFlaggingNotificationSettings = {
|
||||||
|
ReviewerSettings: ContentFlaggingReviewerSetting;
|
||||||
|
EventTargetMapping: Record<ContentFlaggingEvent, NotificationTarget[]>;
|
||||||
|
AdditionalSettings: ContentFlaggingAdditionalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TeamReviewerSetting = {
|
||||||
|
Enabled: boolean;
|
||||||
|
ReviewerIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentFlaggingReviewerSetting = {
|
||||||
|
CommonReviewers: boolean;
|
||||||
|
CommonReviewerIds: string[];
|
||||||
|
TeamReviewersSetting: Record<string, TeamReviewerSetting>;
|
||||||
|
SystemAdminsAsReviewers: boolean;
|
||||||
|
TeamAdminsAsReviewers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentFlaggingAdditionalSettings = {
|
||||||
|
Reasons: string[];
|
||||||
|
ReporterCommentRequired: boolean;
|
||||||
|
ReviewerCommentRequired: boolean;
|
||||||
|
HideFlaggedContent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentFlaggingSettings = {
|
||||||
|
NotificationSettings: ContentFlaggingNotificationSettings;
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminConfig = {
|
export type AdminConfig = {
|
||||||
ServiceSettings: ServiceSettings;
|
ServiceSettings: ServiceSettings;
|
||||||
TeamSettings: TeamSettings;
|
TeamSettings: TeamSettings;
|
||||||
@@ -1036,6 +1068,7 @@ export type AdminConfig = {
|
|||||||
WranglerSettings: WranglerSettings;
|
WranglerSettings: WranglerSettings;
|
||||||
ConnectedWorkspacesSettings: ConnectedWorkspacesSettings;
|
ConnectedWorkspacesSettings: ConnectedWorkspacesSettings;
|
||||||
AccessControlSettings: AccessControlSettings;
|
AccessControlSettings: AccessControlSettings;
|
||||||
|
ContentFlaggingSettings: ContentFlaggingSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReplicaLagSetting = {
|
export type ReplicaLagSetting = {
|
||||||
|
|||||||
6
webapp/platform/types/src/content_flagging.ts
Обычный файл
6
webapp/platform/types/src/content_flagging.ts
Обычный файл
@@ -0,0 +1,6 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
export type ContentFlaggingEvent = 'flagged' | 'assigned' | 'removed' | 'dismissed';
|
||||||
|
|
||||||
|
export type NotificationTarget = 'reviewers' | 'author' | 'reporter';
|
||||||
Ссылка в новой задаче
Block a user