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:
Harshil Sharma 2025-07-10 17:47:16 +05:30 committed by GitHub
parent 254f641182
commit d1e5fdea2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 4093 additions and 5 deletions

View file

@ -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,
},
},
},
};

View file

@ -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."

View file

@ -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
}

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

View 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)
})
}

View file

@ -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

View file

@ -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)'}),

View file

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

View file

@ -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;
}
}

View file

@ -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,
});
});
});

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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();
});
});

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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;
}
}
}

View file

@ -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);
});
});

View file

@ -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>
);
}

View file

@ -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);
}
}

View file

@ -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);
});
});

View file

@ -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>
);
}

View file

@ -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;
}
}
}
}

View file

@ -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: [],
},
});
});
});

View file

@ -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>
);
}

View file

@ -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();
});
});

View file

@ -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>
);
}

View file

@ -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>
`;

View file

@ -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;
}
}

View file

@ -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>
);
}

View file

@ -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);
}
}

View file

@ -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>
);
}

View file

@ -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);
}
}

View file

@ -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');
});
});

View file

@ -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>
);
}

View file

@ -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}

View file

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

View file

@ -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"

View file

@ -221,7 +221,7 @@ const Option = (props: OptionProps<OptionType, false>) => {
);
};
const LoadingIndicator = () => {
export const LoadingIndicator = () => {
return (
<LoadingSpinner/>
);

View file

@ -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}

View file

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

View file

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

View file

@ -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",

View file

@ -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,

View file

@ -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 = {

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