From d1e5fdea2c19a408f87d85c31dad2a6987f47448 Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:47:16 +0530 Subject: [PATCH] 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 --- .../lib/src/server/default_config.ts | 23 + server/i18n/en.json | 24 + server/public/model/config.go | 6 + .../public/model/content_flagging_settings.go | 229 ++++++++ .../model/content_flagging_settings_test.go | 360 +++++++++++++ server/public/model/feature_flags.go | 3 + .../admin_console/admin_definition.tsx | 40 ++ .../admin_console/boolean_setting.tsx | 2 +- .../additional_settings.scss | 11 + .../additional_settings.test.tsx | 176 +++++++ .../additional_settings.tsx | 250 +++++++++ .../additional_settings/reason_option.scss | 13 + .../reason_option.test.tsx | 106 ++++ .../additional_settings/reason_option.tsx | 43 ++ .../content_flagging_section_base.scss | 49 ++ .../content_reviewers/content_reviewers.scss | 23 + .../content_reviewers.test.tsx | 269 ++++++++++ .../content_reviewers/content_reviewers.tsx | 227 ++++++++ .../content_reviewers/team_option.scss | 16 + .../content_reviewers/team_option.test.tsx | 181 +++++++ .../content_reviewers/team_option.tsx | 44 ++ .../team_reviewers_section.scss | 112 ++++ .../team_reviewers_section.test.tsx | 498 ++++++++++++++++++ .../team_reviewers_section.tsx | 210 ++++++++ .../notification_settings.test.tsx | 289 ++++++++++ .../notification_settings.tsx | 264 ++++++++++ .../user_profile_pill.test.tsx.snap | 34 ++ .../user_multiselector/user_multiselect.scss | 16 + .../user_multiselector/user_multiselector.tsx | 124 +++++ .../user_profile_option.scss | 16 + .../user_profile_option.tsx | 40 ++ .../user_multiselector/user_profile_pill.scss | 23 + .../user_profile_pill.test.tsx | 186 +++++++ .../user_multiselector/user_profile_pill.tsx | 62 +++ .../admin_console/data_grid/data_grid.tsx | 2 + .../admin_console/schema_admin_settings.tsx | 19 + .../__snapshots__/logs.test.tsx.snap | 2 + .../system_users_filter_team/index.tsx | 2 +- .../user_settings_notifications.tsx | 4 +- .../widgets/team_icon/team_icon.scss | 20 + .../widgets/team_icon/team_icon.tsx | 2 +- webapp/channels/src/i18n/en.json | 35 ++ .../src/selectors/entities/users.ts | 4 + webapp/platform/types/src/config.ts | 33 ++ webapp/platform/types/src/content_flagging.ts | 6 + 45 files changed, 4093 insertions(+), 5 deletions(-) create mode 100644 server/public/model/content_flagging_settings.go create mode 100644 server/public/model/content_flagging_settings_test.go create mode 100644 webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.scss create mode 100644 webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.test.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.scss create mode 100644 webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.test.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_flagging_section_base.scss create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.scss create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.test.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.scss create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.test.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.scss create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.test.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/notificatin_settings/notification_settings.test.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/notificatin_settings/notification_settings.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/user_multiselector/__snapshots__/user_profile_pill.test.tsx.snap create mode 100644 webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselect.scss create mode 100644 webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselector.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.scss create mode 100644 webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.scss create mode 100644 webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.test.tsx create mode 100644 webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.tsx create mode 100644 webapp/platform/types/src/content_flagging.ts diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 3a436a969c..dc352c3257 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -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, + }, + }, + }, }; diff --git a/server/i18n/en.json b/server/i18n/en.json index 624dc15470..0bbb034d70 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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." diff --git a/server/public/model/config.go b/server/public/model/config.go index 96b54c2a23..85863e898f 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -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 } diff --git a/server/public/model/content_flagging_settings.go b/server/public/model/content_flagging_settings.go new file mode 100644 index 0000000000..c2b7e60ff1 --- /dev/null +++ b/server/public/model/content_flagging_settings.go @@ -0,0 +1,229 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import "net/http" + +type ContentFlaggingEvent string + +const ( + EventFlagged ContentFlaggingEvent = "flagged" + EventAssigned ContentFlaggingEvent = "assigned" + EventContentRemoved ContentFlaggingEvent = "removed" + EventContentDismissed ContentFlaggingEvent = "dismissed" +) + +type NotificationTarget string + +const ( + TargetReviewers NotificationTarget = "reviewers" + TargetAuthor NotificationTarget = "author" + TargetReporter NotificationTarget = "reporter" +) + +type ContentFlaggingNotificationSettings struct { + EventTargetMapping map[ContentFlaggingEvent][]NotificationTarget +} + +func (cfs *ContentFlaggingNotificationSettings) SetDefaults() { + if cfs.EventTargetMapping == nil { + cfs.EventTargetMapping = make(map[ContentFlaggingEvent][]NotificationTarget) + } + + if _, exists := cfs.EventTargetMapping[EventFlagged]; !exists { + cfs.EventTargetMapping[EventFlagged] = []NotificationTarget{TargetReviewers} + } + + if _, exists := cfs.EventTargetMapping[EventAssigned]; !exists { + cfs.EventTargetMapping[EventAssigned] = []NotificationTarget{TargetReviewers} + } + + if _, exists := cfs.EventTargetMapping[EventContentRemoved]; !exists { + cfs.EventTargetMapping[EventContentRemoved] = []NotificationTarget{TargetReviewers, TargetAuthor, TargetReporter} + } + + if _, exists := cfs.EventTargetMapping[EventContentDismissed]; !exists { + cfs.EventTargetMapping[EventContentDismissed] = []NotificationTarget{TargetReviewers, TargetReporter} + } +} + +func (cfs *ContentFlaggingNotificationSettings) IsValid() *AppError { + // Reviewers must be notified when content is flagged + // Disabling this option is not allowed in the UI, so this check is for safety and consistency. + + // Only valid events and targets are allowed + for event, targets := range cfs.EventTargetMapping { + if event != EventFlagged && event != EventAssigned && event != EventContentRemoved && event != EventContentDismissed { + return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.invalid_event", nil, "", http.StatusBadRequest) + } + + for _, target := range targets { + if target != TargetReviewers && target != TargetAuthor && target != TargetReporter { + return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.invalid_target", nil, "", http.StatusBadRequest) + } + } + } + + if cfs.EventTargetMapping[EventFlagged] == nil || len(cfs.EventTargetMapping[EventFlagged]) == 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", nil, "", http.StatusBadRequest) + } + + // Search for the TargetReviewers in the EventFlagged event + reviewerFound := false + for _, target := range cfs.EventTargetMapping[EventFlagged] { + if target == TargetReviewers { + reviewerFound = true + break + } + } + + if !reviewerFound { + return NewAppError("Config.IsValid", "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", nil, "", http.StatusBadRequest) + } + + return nil +} + +type TeamReviewerSetting struct { + Enabled *bool + ReviewerIds *[]string +} + +type ReviewerSettings struct { + CommonReviewers *bool + CommonReviewerIds *[]string + TeamReviewersSetting *map[string]TeamReviewerSetting + SystemAdminsAsReviewers *bool + TeamAdminsAsReviewers *bool +} + +func (rs *ReviewerSettings) SetDefaults() { + if rs.CommonReviewers == nil { + rs.CommonReviewers = NewPointer(true) + } + + if rs.CommonReviewerIds == nil { + rs.CommonReviewerIds = &[]string{} + } + + if rs.TeamReviewersSetting == nil { + rs.TeamReviewersSetting = &map[string]TeamReviewerSetting{} + } + + if rs.SystemAdminsAsReviewers == nil { + rs.SystemAdminsAsReviewers = NewPointer(false) + } + + if rs.TeamAdminsAsReviewers == nil { + rs.TeamAdminsAsReviewers = NewPointer(true) + } +} + +func (rs *ReviewerSettings) IsValid() *AppError { + additionalReviewersEnabled := *rs.SystemAdminsAsReviewers || *rs.TeamAdminsAsReviewers + + // If common reviewers are enabled, there must be at least one specified reviewer, or additional viewers be specified + if *rs.CommonReviewers && (rs.CommonReviewerIds == nil || len(*rs.CommonReviewerIds) == 0) && !additionalReviewersEnabled { + return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.common_reviewers_not_set.app_error", nil, "", http.StatusBadRequest) + } + + // if additional reviewers are specified, no extra validation is needed in team specific settings as + // settings team reviewers keeping team feature disabled is valid, as well as + // enabling team feature and not specified reviews is fine as well (since additional reviewers are set) + if !additionalReviewersEnabled { + for _, setting := range *rs.TeamReviewersSetting { + if *setting.Enabled && (setting.ReviewerIds == nil || len(*setting.ReviewerIds) == 0) { + return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.team_reviewers_not_set.app_error", nil, "", http.StatusBadRequest) + } + } + } + + return nil +} + +type AdditionalContentFlaggingSettings struct { + Reasons *[]string + ReporterCommentRequired *bool + ReviewerCommentRequired *bool + HideFlaggedContent *bool +} + +func (acfs *AdditionalContentFlaggingSettings) SetDefaults() { + if acfs.Reasons == nil { + acfs.Reasons = &[]string{ + "Inappropriate content", + "Sensitive data", + "Security concern", + "Harassment or abuse", + "Spam or phishing", + } + } + + if acfs.ReporterCommentRequired == nil { + acfs.ReporterCommentRequired = NewPointer(true) + } + + if acfs.ReviewerCommentRequired == nil { + acfs.ReviewerCommentRequired = NewPointer(true) + } + + if acfs.HideFlaggedContent == nil { + acfs.HideFlaggedContent = NewPointer(true) + } +} + +func (acfs *AdditionalContentFlaggingSettings) IsValid() *AppError { + if acfs.Reasons == nil || len(*acfs.Reasons) == 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.reasons_not_set.app_error", nil, "", http.StatusBadRequest) + } + + return nil +} + +type ContentFlaggingSettings struct { + EnableContentFlagging *bool + ReviewerSettings *ReviewerSettings + NotificationSettings *ContentFlaggingNotificationSettings + AdditionalSettings *AdditionalContentFlaggingSettings +} + +func (cfs *ContentFlaggingSettings) SetDefaults() { + if cfs.EnableContentFlagging == nil { + cfs.EnableContentFlagging = NewPointer(false) + } + + if cfs.NotificationSettings == nil { + cfs.NotificationSettings = &ContentFlaggingNotificationSettings{ + EventTargetMapping: make(map[ContentFlaggingEvent][]NotificationTarget), + } + } + + if cfs.ReviewerSettings == nil { + cfs.ReviewerSettings = &ReviewerSettings{} + } + + if cfs.AdditionalSettings == nil { + cfs.AdditionalSettings = &AdditionalContentFlaggingSettings{} + } + + cfs.NotificationSettings.SetDefaults() + cfs.ReviewerSettings.SetDefaults() + cfs.AdditionalSettings.SetDefaults() +} + +func (cfs *ContentFlaggingSettings) IsValid() *AppError { + if err := cfs.NotificationSettings.IsValid(); err != nil { + return err + } + + if err := cfs.ReviewerSettings.IsValid(); err != nil { + return err + } + + if err := cfs.AdditionalSettings.IsValid(); err != nil { + return err + } + + return nil +} diff --git a/server/public/model/content_flagging_settings_test.go b/server/public/model/content_flagging_settings_test.go new file mode 100644 index 0000000000..20449192d4 --- /dev/null +++ b/server/public/model/content_flagging_settings_test.go @@ -0,0 +1,360 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestContentFlaggingNotificationSettings_SetDefault(t *testing.T) { + t.Run("should set default event target mappings", func(t *testing.T) { + settings := &ContentFlaggingNotificationSettings{} + settings.SetDefaults() + + require.Nil(t, settings.IsValid()) + + require.NotNil(t, settings.EventTargetMapping) + require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventFlagged]) + require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventAssigned]) + require.Equal(t, []NotificationTarget{TargetReviewers, TargetAuthor, TargetReporter}, settings.EventTargetMapping[EventContentRemoved]) + require.Equal(t, []NotificationTarget{TargetReviewers, TargetReporter}, settings.EventTargetMapping[EventContentDismissed]) + }) + + t.Run("should not override existing mappings", func(t *testing.T) { + settings := &ContentFlaggingNotificationSettings{ + EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{ + EventFlagged: {TargetReviewers}, + }, + } + settings.SetDefaults() + + require.Nil(t, settings.IsValid()) + require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventFlagged]) + require.Equal(t, []NotificationTarget{TargetReviewers}, settings.EventTargetMapping[EventAssigned]) + }) +} + +func TestContentFlaggingNotificationSettings_IsValid(t *testing.T) { + t.Run("should be valid when reviewers are notified for flagged events", func(t *testing.T) { + settings := &ContentFlaggingNotificationSettings{ + EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{ + EventFlagged: {TargetReviewers, TargetAuthor}, + }, + } + + err := settings.IsValid() + require.Nil(t, err) + }) + + t.Run("should be invalid when no targets for flagged events", func(t *testing.T) { + settings := &ContentFlaggingNotificationSettings{ + EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{ + EventFlagged: {}, + }, + } + + err := settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", err.Id) + }) + + t.Run("should be invalid when flagged event mapping is nil", func(t *testing.T) { + settings := &ContentFlaggingNotificationSettings{ + EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{}, + } + + err := settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", err.Id) + }) + + t.Run("should be invalid when reviewers not included in flagged event targets", func(t *testing.T) { + settings := &ContentFlaggingNotificationSettings{ + EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{ + EventFlagged: {TargetAuthor, TargetReporter}, + }, + } + + err := settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", err.Id) + }) + + t.Run("should be invalid when invalid events and targets are specified", func(t *testing.T) { + settings := &ContentFlaggingNotificationSettings{ + EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{ + "invalid_event": {TargetAuthor, TargetReporter}, + }, + } + + err := settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.notification_settings.invalid_event", err.Id) + + settings = &ContentFlaggingNotificationSettings{ + EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{ + EventFlagged: {"invalid_target_1", "invalid_target_2"}, + }, + } + + err = settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.notification_settings.invalid_target", err.Id) + + settings = &ContentFlaggingNotificationSettings{ + EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{ + "invalid_event": {"invalid_target_1", "invalid_target_2"}, + }, + } + + err = settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.notification_settings.invalid_event", err.Id) + }) +} + +func TestReviewerSettings_SetDefault(t *testing.T) { + t.Run("should not override existing values", func(t *testing.T) { + commonReviewers := false + settings := &ReviewerSettings{ + CommonReviewers: &commonReviewers, + } + settings.SetDefaults() + + require.Nil(t, settings.IsValid()) + require.False(t, *settings.CommonReviewers) + }) +} + +func TestReviewerSettings_IsValid(t *testing.T) { + t.Run("should be valid when common reviewers enabled with reviewer IDs", func(t *testing.T) { + settings := &ReviewerSettings{ + CommonReviewers: NewPointer(true), + CommonReviewerIds: &[]string{"user1", "user2"}, + TeamReviewersSetting: &map[string]TeamReviewerSetting{}, + SystemAdminsAsReviewers: NewPointer(false), + TeamAdminsAsReviewers: NewPointer(false), + } + + err := settings.IsValid() + require.Nil(t, err) + }) + + t.Run("should be valid when common reviewers enabled with additional reviewers", func(t *testing.T) { + settings := &ReviewerSettings{ + CommonReviewers: NewPointer(true), + CommonReviewerIds: &[]string{}, + TeamReviewersSetting: &map[string]TeamReviewerSetting{}, + SystemAdminsAsReviewers: NewPointer(true), + TeamAdminsAsReviewers: NewPointer(false), + } + + err := settings.IsValid() + require.Nil(t, err) + }) + + t.Run("should be invalid when common reviewers enabled but no reviewers specified", func(t *testing.T) { + settings := &ReviewerSettings{ + CommonReviewers: NewPointer(true), + CommonReviewerIds: &[]string{}, + TeamReviewersSetting: &map[string]TeamReviewerSetting{}, + SystemAdminsAsReviewers: NewPointer(false), + TeamAdminsAsReviewers: NewPointer(false), + } + + err := settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.content_flagging.common_reviewers_not_set.app_error", err.Id) + }) + + t.Run("should be valid when team reviewers enabled with reviewer IDs", func(t *testing.T) { + settings := &ReviewerSettings{ + CommonReviewers: NewPointer(false), + CommonReviewerIds: &[]string{}, + TeamReviewersSetting: &map[string]TeamReviewerSetting{ + "team1": { + Enabled: NewPointer(true), + ReviewerIds: &[]string{"user1"}, + }, + }, + SystemAdminsAsReviewers: NewPointer(false), + TeamAdminsAsReviewers: NewPointer(false), + } + + err := settings.IsValid() + require.Nil(t, err) + }) + + t.Run("should be invalid when team reviewers enabled but no reviewer IDs", func(t *testing.T) { + settings := &ReviewerSettings{ + CommonReviewers: NewPointer(false), + CommonReviewerIds: &[]string{}, + TeamReviewersSetting: &map[string]TeamReviewerSetting{ + "team1": { + Enabled: NewPointer(true), + ReviewerIds: &[]string{}, + }, + }, + SystemAdminsAsReviewers: NewPointer(false), + TeamAdminsAsReviewers: NewPointer(false), + } + + err := settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.content_flagging.team_reviewers_not_set.app_error", err.Id) + }) + + t.Run("should be valid when team reviewers enabled but no reviewer IDs with additional reviewers", func(t *testing.T) { + settings := &ReviewerSettings{ + CommonReviewers: NewPointer(false), + CommonReviewerIds: &[]string{}, + TeamReviewersSetting: &map[string]TeamReviewerSetting{ + "team1": { + Enabled: NewPointer(true), + ReviewerIds: &[]string{}, + }, + }, + SystemAdminsAsReviewers: NewPointer(true), + TeamAdminsAsReviewers: NewPointer(false), + } + + err := settings.IsValid() + require.Nil(t, err) + }) +} + +func TestAdditionalContentFlaggingSettings_SetDefault(t *testing.T) { + t.Run("should not override existing values", func(t *testing.T) { + customReasons := []string{"Custom reason"} + settings := &AdditionalContentFlaggingSettings{ + Reasons: &customReasons, + } + settings.SetDefaults() + + require.Nil(t, settings.IsValid()) + require.Equal(t, customReasons, *settings.Reasons) + }) +} + +func TestAdditionalContentFlaggingSettings_IsValid(t *testing.T) { + t.Run("should be valid when reasons are provided", func(t *testing.T) { + settings := &AdditionalContentFlaggingSettings{ + Reasons: &[]string{"Reason 1", "Reason 2"}, + } + + err := settings.IsValid() + require.Nil(t, err) + }) + + t.Run("should be invalid when reasons are nil", func(t *testing.T) { + settings := &AdditionalContentFlaggingSettings{ + Reasons: nil, + } + + err := settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.content_flagging.reasons_not_set.app_error", err.Id) + }) + + t.Run("should be invalid when reasons are empty", func(t *testing.T) { + settings := &AdditionalContentFlaggingSettings{ + Reasons: &[]string{}, + } + + err := settings.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.config.is_valid.content_flagging.reasons_not_set.app_error", err.Id) + }) +} + +func TestContentFlaggingSettings_SetDefault(t *testing.T) { + t.Run("should not override existing values", func(t *testing.T) { + enabled := true + settings := &ContentFlaggingSettings{ + EnableContentFlagging: &enabled, + } + settings.SetDefaults() + + require.Nil(t, settings.IsValid()) + require.True(t, *settings.EnableContentFlagging) + }) +} + +func TestContentFlaggingSettings_IsValid(t *testing.T) { + t.Run("should be valid when all nested settings are valid", func(t *testing.T) { + settings := &ContentFlaggingSettings{} + settings.SetDefaults() + + err := settings.IsValid() + require.Nil(t, err) + }) + + t.Run("should be invalid when notification settings are invalid", func(t *testing.T) { + settings := &ContentFlaggingSettings{ + NotificationSettings: &ContentFlaggingNotificationSettings{ + EventTargetMapping: map[ContentFlaggingEvent][]NotificationTarget{ + EventFlagged: {}, + }, + }, + ReviewerSettings: &ReviewerSettings{}, + AdditionalSettings: &AdditionalContentFlaggingSettings{}, + } + settings.ReviewerSettings.SetDefaults() + settings.AdditionalSettings.SetDefaults() + + err := settings.IsValid() + require.NotNil(t, err) + require.Contains(t, err.Id, "notification_settings") + }) + + t.Run("should be invalid when reviewer settings are invalid", func(t *testing.T) { + settings := &ContentFlaggingSettings{ + NotificationSettings: &ContentFlaggingNotificationSettings{}, + ReviewerSettings: &ReviewerSettings{ + CommonReviewers: NewPointer(true), + CommonReviewerIds: &[]string{}, + TeamReviewersSetting: &map[string]TeamReviewerSetting{}, + SystemAdminsAsReviewers: NewPointer(false), + TeamAdminsAsReviewers: NewPointer(false), + }, + AdditionalSettings: &AdditionalContentFlaggingSettings{}, + } + settings.NotificationSettings.SetDefaults() + settings.AdditionalSettings.SetDefaults() + + err := settings.IsValid() + require.NotNil(t, err) + require.Contains(t, err.Id, "common_reviewers_not_set") + }) + + t.Run("should be invalid when additional settings are invalid", func(t *testing.T) { + settings := &ContentFlaggingSettings{ + NotificationSettings: &ContentFlaggingNotificationSettings{}, + ReviewerSettings: &ReviewerSettings{}, + AdditionalSettings: &AdditionalContentFlaggingSettings{ + Reasons: &[]string{}, + }, + } + settings.NotificationSettings.SetDefaults() + settings.ReviewerSettings.SetDefaults() + + err := settings.IsValid() + require.NotNil(t, err) + require.Contains(t, err.Id, "reasons_not_set") + }) +} + +func TestContentFlaggingConstants(t *testing.T) { + t.Run("should have correct event constants", func(t *testing.T) { + require.Equal(t, ContentFlaggingEvent("flagged"), EventFlagged) + require.Equal(t, ContentFlaggingEvent("assigned"), EventAssigned) + require.Equal(t, ContentFlaggingEvent("removed"), EventContentRemoved) + require.Equal(t, ContentFlaggingEvent("dismissed"), EventContentDismissed) + }) + + t.Run("should have correct target constants", func(t *testing.T) { + require.Equal(t, NotificationTarget("reviewers"), TargetReviewers) + require.Equal(t, NotificationTarget("author"), TargetAuthor) + require.Equal(t, NotificationTarget("reporter"), TargetReporter) + }) +} diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index f8acdbed0c..3c8f9d8346 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -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 diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index c3c11c2041..7b5b20144b 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -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)'}), diff --git a/webapp/channels/src/components/admin_console/boolean_setting.tsx b/webapp/channels/src/components/admin_console/boolean_setting.tsx index 62aded9cf1..12900f99df 100644 --- a/webapp/channels/src/components/admin_console/boolean_setting.tsx +++ b/webapp/channels/src/components/admin_console/boolean_setting.tsx @@ -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; diff --git a/webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.scss b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.scss new file mode 100644 index 0000000000..5e305a08ef --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.scss @@ -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; + } +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.test.tsx new file mode 100644 index 0000000000..421c31ecbd --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.test.tsx @@ -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(); + + 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(); + + expect(screen.getByText('Spam')).toBeInTheDocument(); + expect(screen.getByText('Inappropriate')).toBeInTheDocument(); + }); + + test('should have correct initial radio button states', () => { + renderWithContext(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const selectInput = screen.getByRole('combobox'); + expect(selectInput).toBeInTheDocument(); + expect(selectInput).toHaveAttribute('id', 'contentFlaggingReasons'); + }); + + test('should maintain state consistency across multiple changes', () => { + renderWithContext(); + + // 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, + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.tsx b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.tsx new file mode 100644 index 0000000000..557031e58b --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/additional_settings.tsx @@ -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(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) => { + const updatedSettings: ContentFlaggingAdditionalSettings = { + ...additionalSettings, + ReporterCommentRequired: e.target.value === 'true', + }; + setAdditionalSettings(updatedSettings); + onChange(id, updatedSettings); + }, [additionalSettings, id, onChange]); + + const handleRequireReviewerCommentChange = useCallback((e: ChangeEvent) => { + const updatedSettings: ContentFlaggingAdditionalSettings = { + ...additionalSettings, + ReviewerCommentRequired: e.target.value === 'true', + }; + setAdditionalSettings(updatedSettings); + onChange(id, updatedSettings); + }, [additionalSettings, id, onChange]); + + const handleHideFlaggedPosts = useCallback((e: ChangeEvent) => { + 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 ( + + +
+

+ +

+
+ +
+
+
+ +
+ + {/*Reasons for flagging*/} +
+
+ +
+ +
+ null, + Menu: () => null, + MenuList: () => null, + IndicatorSeparator: () => null, + Input: CreatableReactSelectInput, + MultiValue: ReasonOption, + }} + /> +
+
+ +
+
+ +
+ +
+ + + +
+
+ +
+
+ +
+ +
+ + + +
+
+ +
+
+ +
+ +
+ + + +
+
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.scss b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.scss new file mode 100644 index 0000000000..45c623ca31 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.scss @@ -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; +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.test.tsx new file mode 100644 index 0000000000..8556425a9b --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.test.tsx @@ -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(); + + expect(screen.getByText('Test Reason')).toBeInTheDocument(); + }); + + test('should render with ReasonOption class', () => { + const {container} = renderWithContext(); + + expect(container.querySelector('.ReasonOption')).toBeInTheDocument(); + }); + + test('should render remove button with close icon', () => { + const {container} = renderWithContext(); + + const removeButton = container.querySelector('.Remove'); + expect(removeButton).toBeInTheDocument(); + }); + + test('should call onClick when remove button is clicked', () => { + const {container} = renderWithContext(); + + 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(); + + 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 = {'Custom Remove'}; + const mockRemoveProps = { + ...mockProps.removeProps, + children: customChildren, + }; + + const propsWithCustomChildren = { + ...mockProps, + removeProps: mockRemoveProps, + }; + + renderWithContext(); + + expect(screen.getByText('Custom Remove')).toBeInTheDocument(); + }); + + test('should handle empty label gracefully', () => { + const propsWithEmptyLabel = { + ...mockProps, + data: {label: '', value: 'empty_reason'}, + }; + + const {container} = renderWithContext(); + + expect(container.querySelector('.ReasonOption')).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.tsx b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.tsx new file mode 100644 index 0000000000..ae27421555 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/additional_settings/reason_option.tsx @@ -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 ( +
+ {children || } +
+ ); +} + +export function ReasonOption(props: MultiValueProps<{label: string; value: string}, true>) { + const {data, innerProps, selectProps, removeProps} = props; + + return ( +
+ {data.label} + + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_flagging_section_base.scss b/webapp/channels/src/components/admin_console/content_flagging/content_flagging_section_base.scss new file mode 100644 index 0000000000..e9a2e8383a --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_flagging_section_base.scss @@ -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; +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.scss b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.scss new file mode 100644 index 0000000000..b43f22e76a --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.scss @@ -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; + } + } +} + diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.test.tsx new file mode 100644 index 0000000000..8255018fa9 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.test.tsx @@ -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}) => ( +
+ + {initialValue.join(',')} +
+ ), +})); + +// Mock the TeamReviewers component +jest.mock('./team_reviewers_section/team_reviewers_section', () => ({ + __esModule: true, + default: ({teamReviewersSetting, onChange}: {teamReviewersSetting: Record; onChange: (settings: Record) => void}) => ( +
+ + {JSON.stringify(teamReviewersSetting)} +
+ ), +})); + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const teamReviewersSettingSpan = screen.getByTestId('team-reviewers-setting'); + expect(teamReviewersSettingSpan).toHaveTextContent(JSON.stringify(teamReviewersSetting)); + }); + + it('renders help text for additional reviewers', () => { + renderWithContext(); + + expect(screen.getByText(/If enabled, system administrators will be sent flagged posts/)).toBeInTheDocument(); + }); + + it('correctly sets radio button checked states', () => { + renderWithContext(); + + 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(); + + 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); + }); +}); diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.tsx new file mode 100644 index 0000000000..dbd4b4c1eb --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/content_reviewers.tsx @@ -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(props.value as ContentFlaggingReviewerSetting); + + const handleSameReviewersForAllTeamsChange = useCallback((event: React.ChangeEvent) => { + 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) => { + const updatedSetting: ContentFlaggingReviewerSetting = { + ...reviewerSetting, + TeamReviewersSetting: updatedTeamSettings, + }; + + setReviewerSetting(updatedSetting); + props.onChange(props.id, updatedSetting); + }, [props, reviewerSetting]); + + return ( + + +
+

+ +

+
+ +
+
+
+ + +
+ {/* Same reviewers for all teams */} +
+
+ +
+ +
+ + + +
+
+ + { + reviewerSetting.CommonReviewers && +
+
+ +
+ +
+ +
+
+ } + + { + !reviewerSetting.CommonReviewers && +
+
+ +
+ + + +
+ } + +
+
+ +
+ +
+
+ + } + defaultChecked={reviewerSetting.SystemAdminsAsReviewers} + onChange={handleSystemAdminReviewerChange} + setByEnv={false} + /> + + + } + defaultChecked={reviewerSetting.TeamAdminsAsReviewers} + onChange={handleTeamAdminReviewerChange} + setByEnv={false} + /> +
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.scss b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.scss new file mode 100644 index 0000000000..78617fe3c8 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.scss @@ -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); + } +} + + + diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.test.tsx new file mode 100644 index 0000000000..bf0090eebf --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.test.tsx @@ -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}) => ( +
+ {content} +
+ ), +})); + +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, 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, 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(); + + 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(); + + expect(Utils.imageURLForTeam).toHaveBeenCalledWith(mockTeam); + expect(Utils.imageURLForTeam).toHaveBeenCalledTimes(1); + }); + + it('should apply CSS class and inner props correctly', () => { + renderWithContext(); + + 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, true>; + + const {container} = renderWithContext(); + expect(container.firstChild).toBeNull(); + }); + + it('should return null when data is undefined', () => { + const propsWithUndefinedData = { + ...mockProps, + data: undefined, + } as unknown as OptionProps, true>; + + const {container} = renderWithContext(); + 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, true>; + + const {container} = renderWithContext(); + 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, true>; + + const {container} = renderWithContext(); + 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, true>; + + renderWithContext(); + + expect(screen.queryAllByText('Private Team')[0]).toBeInTheDocument(); + expect(Utils.imageURLForTeam).toHaveBeenCalledWith(privateTeam); + }); +}); diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.tsx new file mode 100644 index 0000000000..9b4114c03e --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_option.tsx @@ -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, true>) { + const {data, innerProps} = props; + + const intl = useIntl(); + + if (!data || !data.raw) { + return null; + } + + const team = data.raw; + + return ( +
+ + + {team.display_name} +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.scss b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.scss new file mode 100644 index 0000000000..97d2f6d913 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.scss @@ -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; + } + } + } +} + + diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.test.tsx new file mode 100644 index 0000000000..e8526879e1 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.test.tsx @@ -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}) => ( +
+ {`Selected: {${initialValue.join(', ')}`} + +
+ ), +})); + +jest.mock('components/widgets/team_icon/team_icon', () => ({ + TeamIcon: ({content}: {content: string}) =>
{content}
, +})); + +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, + 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(, {}, {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(); + + await waitFor(() => { + expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10}); + }); + }); + + test('should handle search functionality', async () => { + renderWithContext(); + + 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(); + + 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(); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + await waitFor(() => { + expect(mockSearchTeams).toHaveBeenCalledWith('', {page: 0, per_page: 10}); + }); + }); + + test('should render disable all button', async () => { + renderWithContext(); + + 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(); + + 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(); + + // 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(); + + 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( + , + ); + + 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(); + + 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: [], + }, + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx new file mode 100644 index 0000000000..57fe019a2d --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx @@ -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; + onChange: (updatedTeamSettings: Record) => 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(''); + const [teams, setTeams] = useState([]); + + 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: ( +
+ + + {team.display_name} + +
+ ), + reviewers: ( + + ), + enabled: ( + + ), + }, + })); + }, [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(() => ( +
+ +
+ ), [intl]); + + return ( +
+ +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/notificatin_settings/notification_settings.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/notificatin_settings/notification_settings.test.tsx new file mode 100644 index 0000000000..1ff7477705 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/notificatin_settings/notification_settings.test.tsx @@ -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( + + {component} + , + ); +}; + +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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + const flaggedReviewersCheckbox = screen.getByTestId('flagged_reviewers'); + expect(flaggedReviewersCheckbox).toBeDisabled(); + }); + + test('should not disable other checkboxes', () => { + renderWithIntl(); + + 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + 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(); + + // 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(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/content_flagging/notificatin_settings/notification_settings.tsx b/webapp/channels/src/components/admin_console/content_flagging/notificatin_settings/notification_settings.tsx new file mode 100644 index 0000000000..6fe16ae157 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/notificatin_settings/notification_settings.tsx @@ -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(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 ( + + +
+

+ +

+
+ +
+
+
+ +
+ + {/*Notify on flagging*/} +
+
+ +
+ +
+ + } + defaultChecked={getValue('flagged', 'reviewers')} + onChange={handleChange} + setByEnv={false} + disabled={true} + /> + + + } + defaultChecked={getValue('flagged', 'author')} + onChange={handleChange} + setByEnv={false} + /> +
+
+ + {/*Notify on reviewer assigned*/} +
+
+ +
+ +
+ + } + defaultChecked={getValue('assigned', 'reviewers')} + onChange={handleChange} + setByEnv={false} + /> +
+
+ + {/*Notify on removal*/} +
+
+ +
+ +
+ + } + defaultChecked={getValue('removed', 'reviewers')} + onChange={handleChange} + setByEnv={false} + /> + + + } + defaultChecked={getValue('removed', 'author')} + onChange={handleChange} + setByEnv={false} + /> + + + } + defaultChecked={getValue('removed', 'reporter')} + onChange={handleChange} + setByEnv={false} + /> +
+
+ + {/*Notify on dismiss*/} +
+
+ +
+ +
+ + } + defaultChecked={getValue('dismissed', 'reviewers')} + onChange={handleChange} + setByEnv={false} + /> + + + } + defaultChecked={getValue('dismissed', 'author')} + onChange={handleChange} + setByEnv={false} + /> + + + } + defaultChecked={getValue('dismissed', 'reporter')} + onChange={handleChange} + setByEnv={false} + /> +
+
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/__snapshots__/user_profile_pill.test.tsx.snap b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/__snapshots__/user_profile_pill.test.tsx.snap new file mode 100644 index 0000000000..d94756beda --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/__snapshots__/user_profile_pill.test.tsx.snap @@ -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`] = ` +
+
+ testuser profile image + Test User +
+ + + + + +
+
+
+`; diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselect.scss b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselect.scss new file mode 100644 index 0000000000..5b4ac977d0 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselect.scss @@ -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; + } +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselector.tsx b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselector.tsx new file mode 100644 index 0000000000..9ec451d7a4 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_multiselector.tsx @@ -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 = { + 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(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)); + + 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>) { + const selectedUserIds = value.map((option) => option.value); + onChange?.(selectedUserIds); + } + + return ( +
+ null, + IndicatorSeparator: () => null, + Option: UserOptionComponent, + MultiValue: UserProfilePill, + }} + /> +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.scss b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.scss new file mode 100644 index 0000000000..5d19e17fb7 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.scss @@ -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); + } +} + + diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.tsx b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.tsx new file mode 100644 index 0000000000..83124747da --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_option.tsx @@ -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, true>) { + const {data, innerProps} = props; + + const userProfile = data.raw; + const userDisplayName = useSelector((state: GlobalState) => getDisplayNameByUser(state, userProfile)); + + return ( +
+ + + {userDisplayName} +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.scss b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.scss new file mode 100644 index 0000000000..077db315cd --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.scss @@ -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); + } +} diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.test.tsx b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.test.tsx new file mode 100644 index 0000000000..505888dceb --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.test.tsx @@ -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, + 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, 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( + , + initialState, + ); + + expect(container).toMatchSnapshot(); + }); + + test('should render with correct user display name', () => { + const {container} = renderWithContext( + , + 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( + , + initialState, + ); + + const avatar = container.querySelector('.Avatar'); + expect(avatar).toBeInTheDocument(); + }); + + test('should render Remove component with close icon', () => { + const {container} = renderWithContext( + , + 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( + , + 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, true>; + + const {container} = renderWithContext( + , + 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( + , + stateWithUsernameDisplay, + ); + + const pill = container.querySelector('.UserProfilePill'); + expect(pill).toBeInTheDocument(); + }); + + test('should apply correct CSS classes', () => { + const {container} = renderWithContext( + , + initialState, + ); + + const pill = container.querySelector('.UserProfilePill'); + expect(pill).toBeInTheDocument(); + expect(pill).toHaveClass('UserProfilePill'); + }); +}); diff --git a/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.tsx b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.tsx new file mode 100644 index 0000000000..b0e3f5e696 --- /dev/null +++ b/webapp/channels/src/components/admin_console/content_flagging/user_multiselector/user_profile_pill.tsx @@ -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 ( +
+ {children || } +
+ ); +} + +export function UserProfilePill(props: MultiValueProps, true>) { + const {data, innerProps, selectProps, removeProps} = props; + + const userProfile = data.raw; + const userDisplayName = useSelector((state: GlobalState) => getDisplayNameByUser(state, userProfile)); + + return ( +
+ + + {userDisplayName} + + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/data_grid/data_grid.tsx b/webapp/channels/src/components/admin_console/data_grid/data_grid.tsx index 09cc5d1553..be46c704b5 100644 --- a/webapp/channels/src/components/admin_console/data_grid/data_grid.tsx +++ b/webapp/channels/src/components/admin_console/data_grid/data_grid.tsx @@ -262,6 +262,7 @@ class DataGrid extends React.PureComponent { }} />