mirror of
https://github.com/mattermost/mattermost.git
synced 2026-04-15 22:12:19 -04:00
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
This commit is contained in:
parent
254f641182
commit
d1e5fdea2c
45 changed files with 4093 additions and 5 deletions
|
|
@ -804,4 +804,27 @@ const defaultServerConfig: AdminConfig = {
|
|||
EnableAttributeBasedAccessControl: 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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"translation": "Invalid Outgoing Integrations Request Timeout for service settings. Must be a positive number."
|
||||
|
|
|
|||
|
|
@ -3884,6 +3884,7 @@ type Config struct {
|
|||
WranglerSettings WranglerSettings
|
||||
ConnectedWorkspacesSettings ConnectedWorkspacesSettings
|
||||
AccessControlSettings AccessControlSettings
|
||||
ContentFlaggingSettings ContentFlaggingSettings
|
||||
}
|
||||
|
||||
func (o *Config) Auditable() map[string]any {
|
||||
|
|
@ -4002,6 +4003,7 @@ func (o *Config) SetDefaults() {
|
|||
o.WranglerSettings.SetDefaults()
|
||||
o.ConnectedWorkspacesSettings.SetDefaults(isUpdate, o.ExperimentalSettings)
|
||||
o.AccessControlSettings.SetDefaults()
|
||||
o.ContentFlaggingSettings.SetDefaults()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
229
server/public/model/content_flagging_settings.go
Normal file
229
server/public/model/content_flagging_settings.go
Normal file
|
|
@ -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
Normal file
360
server/public/model/content_flagging_settings_test.go
Normal file
|
|
@ -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
|
||||
|
||||
AttributeBasedAccessControl bool
|
||||
|
||||
ContentFlagging bool
|
||||
}
|
||||
|
||||
func (f *FeatureFlags) SetDefaults() {
|
||||
|
|
@ -96,6 +98,7 @@ func (f *FeatureFlags) SetDefaults() {
|
|||
f.ExperimentalAuditSettingsSystemConsoleUI = true
|
||||
f.CustomProfileAttributes = true
|
||||
f.AttributeBasedAccessControl = true
|
||||
f.ContentFlagging = false
|
||||
}
|
||||
|
||||
// ToMap returns the feature flags as a map[string]string
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ import {
|
|||
} from 'actions/admin_actions';
|
||||
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 CustomProfileAttributes from 'components/admin_console/custom_profile_attributes/custom_profile_attributes';
|
||||
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: {
|
||||
url: 'site_config/wrangler',
|
||||
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';
|
||||
|
||||
const Label = styled.label<{isDisabled: boolean}>`
|
||||
export const Label = styled.label<{isDisabled: boolean}>`
|
||||
display: inline-flex;
|
||||
opacity: ${({isDisabled}) => (isDisabled ? 0.5 : 1)};
|
||||
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
|
||||
aria-label='Previous page'
|
||||
type='button'
|
||||
className={'btn btn-quaternary btn-icon btn-sm ml-2 prev ' + (firstPage ? 'disabled' : '')}
|
||||
onClick={prevPageFn}
|
||||
|
|
@ -270,6 +271,7 @@ class DataGrid extends React.PureComponent<Props, State> {
|
|||
<PreviousIcon/>
|
||||
</button>
|
||||
<button
|
||||
aria-label='Next page'
|
||||
type='button'
|
||||
className={'btn btn-quaternary btn-icon btn-sm next ' + (lastPage ? 'disabled' : '')}
|
||||
onClick={nextPageFn}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,25 @@ import './schema_admin_settings.scss';
|
|||
|
||||
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 = {
|
||||
config: Partial<AdminConfig>;
|
||||
environmentConfig: Partial<EnvironmentConfig>;
|
||||
|
|
|
|||
|
|
@ -480,6 +480,7 @@ exports[`components/admin_console/server_logs/Logs should display the logs corre
|
|||
>
|
||||
1 - 3 of 3
|
||||
<button
|
||||
aria-label="Previous page"
|
||||
class="btn btn-quaternary btn-icon btn-sm ml-2 prev disabled"
|
||||
disabled=""
|
||||
type="button"
|
||||
|
|
@ -490,6 +491,7 @@ exports[`components/admin_console/server_logs/Logs should display the logs corre
|
|||
/>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Next page"
|
||||
class="btn btn-quaternary btn-icon btn-sm next disabled"
|
||||
disabled=""
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ const Option = (props: OptionProps<OptionType, false>) => {
|
|||
);
|
||||
};
|
||||
|
||||
const LoadingIndicator = () => {
|
||||
export const LoadingIndicator = () => {
|
||||
return (
|
||||
<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 = {
|
||||
'aria-labelledby': 'settingTitle',
|
||||
};
|
||||
|
|
@ -609,7 +609,7 @@ class NotificationsTab extends React.PureComponent<Props, State> {
|
|||
DropdownIndicator: () => null,
|
||||
Menu: () => null,
|
||||
MenuList: () => null,
|
||||
Input,
|
||||
Input: CreatableReactSelectInput,
|
||||
}}
|
||||
onChange={this.handleChangeForCustomKeysWithNotificationInput}
|
||||
value={this.state.customKeysWithNotification}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,16 @@
|
|||
height: 34px;
|
||||
}
|
||||
|
||||
&.TeamIcon__xsm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&.TeamIcon__xxs {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&.withImage {
|
||||
border: none;
|
||||
|
||||
|
|
@ -84,6 +94,16 @@
|
|||
height: 40px;
|
||||
}
|
||||
|
||||
&.TeamIcon__xsm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&.TeamIcon__xxs {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.TeamIcon__image {
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 200ms;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ type Props = {
|
|||
*
|
||||
* @default "regular"
|
||||
**/
|
||||
size?: 'sm' | 'lg';
|
||||
size?: 'sm' | 'lg' | 'xsm' | 'xxs';
|
||||
|
||||
/** Whether to add hover effect to the icon */
|
||||
withHover?: boolean;
|
||||
|
|
|
|||
|
|
@ -761,6 +761,37 @@
|
|||
"admin.connectionSecurityTitle": "Connection Security:",
|
||||
"admin.connectionSecurityTls": "TLS",
|
||||
"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.title": "Create custom terms of service with Mattermost Enterprise",
|
||||
"admin.customization.allowSyncedDrafts": "Enable server syncing of message drafts:",
|
||||
|
|
@ -2655,6 +2686,7 @@
|
|||
"admin.sidebar.compliance": "Compliance",
|
||||
"admin.sidebar.complianceExport": "Compliance Export",
|
||||
"admin.sidebar.complianceMonitoring": "Compliance Monitoring",
|
||||
"admin.sidebar.contentFlagging": "Content Flagging",
|
||||
"admin.sidebar.cors": "CORS",
|
||||
"admin.sidebar.customization": "Customization",
|
||||
"admin.sidebar.customTermsOfService": "Custom Terms of Service",
|
||||
|
|
@ -3128,6 +3160,9 @@
|
|||
"admin.userManagement.userDetail.teamsTitle": "Team Membership",
|
||||
"admin.userManagement.userDetail.userId": "User ID: {userId}",
|
||||
"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.viewArchivedChannelsTitle": "Allow users to view archived channels:",
|
||||
"admin.webserverModeDisabled": "Disabled",
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ export function getUser(state: GlobalState, id: UserProfile['id']): UserProfile
|
|||
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(
|
||||
'getUsersByUsername',
|
||||
getUsers,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ContentFlaggingEvent, NotificationTarget} from './content_flagging';
|
||||
|
||||
export type ClientConfig = {
|
||||
AboutLink: string;
|
||||
AllowBannerDismissal: string;
|
||||
|
|
@ -990,6 +992,36 @@ export type AccessControlSettings = {
|
|||
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 = {
|
||||
ServiceSettings: ServiceSettings;
|
||||
TeamSettings: TeamSettings;
|
||||
|
|
@ -1036,6 +1068,7 @@ export type AdminConfig = {
|
|||
WranglerSettings: WranglerSettings;
|
||||
ConnectedWorkspacesSettings: ConnectedWorkspacesSettings;
|
||||
AccessControlSettings: AccessControlSettings;
|
||||
ContentFlaggingSettings: ContentFlaggingSettings;
|
||||
};
|
||||
|
||||
export type ReplicaLagSetting = {
|
||||
|
|
|
|||
6
webapp/platform/types/src/content_flagging.ts
Normal file
6
webapp/platform/types/src/content_flagging.ts
Normal file
|
|
@ -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';
|
||||
Loading…
Reference in a new issue