From 6656a544840558bf5cd1862734667f0dd12e6e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Thu, 21 Nov 2024 09:50:10 +0100 Subject: [PATCH] MM-61830 - hide actions to no permissions channels (#29319) * MM-61830 - hide actions to no permissions channels * add translations * add unit tests * expand functionality to archived channels, update tests * simplify logic to verify membership --- .../scheduled_post_actions.test.tsx | 235 ++++++++++++++++++ .../scheduled_post_actions.tsx | 20 +- .../channels/src/components/drafts/drafts.tsx | 2 +- .../components/drafts/drafts_illustration.tsx | 2 +- .../placeholder_scheduled_posts_title.tsx | 21 +- webapp/channels/src/i18n/en.json | 1 + 6 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.test.tsx diff --git a/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.test.tsx b/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.test.tsx new file mode 100644 index 0000000000..021c9dee81 --- /dev/null +++ b/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.test.tsx @@ -0,0 +1,235 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react'; +import React from 'react'; + +import type {Channel, ChannelType} from '@mattermost/types/channels'; +import type {ScheduledPost} from '@mattermost/types/schedule_post'; + +import * as commonSelectors from 'mattermost-redux/selectors/entities/common'; +import * as usersSelectors from 'mattermost-redux/selectors/entities/users'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import ScheduledPostActions from './scheduled_post_actions'; + +const initialState = { + entities: { + users: { + currentUserId: 'user_id', + profiles: { + user_id: { + roles: 'custom_role', + timezone: { + useAutomaticTimezone: true, + automaticTimezone: '', + manualTimezone: '', + }, + }, + }, + }, + general: { + config: {}, + license: {}, + }, + channels: { + currentChannelId: 'channel_id', + channels: { + channel_id: { + id: 'channel_id', + type: 'O' as ChannelType, + display_name: 'Test Channel', + delete_at: 0, + }, + }, + }, + roles: { + roles: {}, + }, + }, +}; + +const defaultProps = { + scheduledPost: { + id: 'scheduled_post_id', + channel_id: 'channel_id', + scheduled_at: Date.now(), + error_code: null, + create_at: Date.now(), + update_at: Date.now(), + user_id: 'user_id', + root_id: '', + message: 'Test message', + props: {}, + metadata: {}, + } as unknown as ScheduledPost, + channel: { + id: 'channel_id', + type: 'O' as ChannelType, + display_name: 'Test Channel', + delete_at: 0, + } as Channel, + onReschedule: jest.fn(), + onDelete: jest.fn(), + onSend: jest.fn(), + onEdit: jest.fn(), + onCopyText: jest.fn(), +}; + +describe('ScheduledPostActions Component', () => { + let isCurrentUserSystemAdminMock: jest.SpyInstance; + let getMyChannelMembershipsnMock: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + + isCurrentUserSystemAdminMock = jest.spyOn(usersSelectors, 'isCurrentUserSystemAdmin'); + getMyChannelMembershipsnMock = jest.spyOn(commonSelectors, 'getMyChannelMemberships'); + + // Set default return values + isCurrentUserSystemAdminMock.mockReturnValue(false); + getMyChannelMembershipsnMock.mockReturnValue({ + channel_id: { + channel_id: 'channel_id', + user_id: 'user_id', + roles: 'channel_user', + }, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function renderComponent(props = defaultProps, state = initialState) { + return renderWithContext( + , + state, + ); + } + it('should render all action buttons when user is an ADMIN', () => { + isCurrentUserSystemAdminMock.mockReturnValue(true); + + renderComponent(); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(5); + + const buttonIds = buttons.map((button) => button.id); + expect(buttonIds).toContain('draft_icon-trash-can-outline_delete'); + expect(buttonIds).toContain('draft_icon-pencil-outline_edit'); + expect(buttonIds).toContain('draft_icon-content-copy_copy_text'); + expect(buttonIds).toContain('draft_icon-clock-send-outline_reschedule'); + expect(buttonIds).toContain('draft_icon-send-outline_sendNow'); + }); + + it('should render appropriate action buttons when user is NOT an admin but IS member of the channel', () => { + isCurrentUserSystemAdminMock.mockReturnValue(false); + + renderComponent(); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(5); + + const buttonIds = buttons.map((button) => button.id); + expect(buttonIds).toContain('draft_icon-trash-can-outline_delete'); + expect(buttonIds).toContain('draft_icon-pencil-outline_edit'); + expect(buttonIds).toContain('draft_icon-content-copy_copy_text'); + expect(buttonIds).toContain('draft_icon-clock-send-outline_reschedule'); + expect(buttonIds).toContain('draft_icon-send-outline_sendNow'); + }); + + it('should only render delete and copy text button when regular user is NOT member of the channel', () => { + isCurrentUserSystemAdminMock.mockReturnValue(false); + + // Regular User is not a member of the channel + getMyChannelMembershipsnMock.mockReturnValue({}); + + renderComponent(); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + + const buttonIds = buttons.map((button) => button.id); + expect(buttonIds).toContain('draft_icon-trash-can-outline_delete'); + expect(buttonIds).toContain('draft_icon-content-copy_copy_text'); + + // validate action buttons are not present + expect(buttonIds).not.toContain('draft_icon-send-outline_sendNow'); + expect(buttonIds).not.toContain('draft_icon-pencil-outline_edit'); + expect(buttonIds).not.toContain('draft_icon-clock-send-outline_reschedule'); + }); + + it('should render all action buttons when user is not member of the channel but is an admin', () => { + isCurrentUserSystemAdminMock.mockReturnValue(true); + getMyChannelMembershipsnMock.mockReturnValue({}); + + renderComponent(); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(5); + + const buttonIds = buttons.map((button) => button.id); + expect(buttonIds).toContain('draft_icon-trash-can-outline_delete'); + expect(buttonIds).toContain('draft_icon-pencil-outline_edit'); + expect(buttonIds).toContain('draft_icon-content-copy_copy_text'); + expect(buttonIds).toContain('draft_icon-clock-send-outline_reschedule'); + expect(buttonIds).toContain('draft_icon-send-outline_sendNow'); + }); + + it('should only render delete and copy text buttons when the channel is archived and is regular user', () => { + const archivedChannelProps = { + ...defaultProps, + channel: { + ...defaultProps.channel, + delete_at: 1, + } as Channel, + }; + + renderComponent(archivedChannelProps); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + + const buttonIds = buttons.map((button) => button.id); + expect(buttonIds).toContain('draft_icon-trash-can-outline_delete'); + expect(buttonIds).toContain('draft_icon-content-copy_copy_text'); + + // Validate that other action buttons are not present + expect(buttonIds).not.toContain('draft_icon-send-outline_sendNow'); + expect(buttonIds).not.toContain('draft_icon-pencil-outline_edit'); + expect(buttonIds).not.toContain('draft_icon-clock-send-outline_reschedule'); + }); + + it('should render all action buttons when the channel is archived and the user is admin', () => { + const archivedChannelProps = { + ...defaultProps, + channel: { + ...defaultProps.channel, + delete_at: 1, + } as Channel, + }; + + isCurrentUserSystemAdminMock.mockReturnValue(true); + + renderComponent(archivedChannelProps); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(5); + + const buttonIds = buttons.map((button) => button.id); + expect(buttonIds).toContain('draft_icon-trash-can-outline_delete'); + expect(buttonIds).toContain('draft_icon-content-copy_copy_text'); + expect(buttonIds).toContain('draft_icon-send-outline_sendNow'); + expect(buttonIds).toContain('draft_icon-pencil-outline_edit'); + expect(buttonIds).toContain('draft_icon-clock-send-outline_reschedule'); + }); +}); diff --git a/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.tsx b/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.tsx index 5cdf20bc3c..2ec0e48c43 100644 --- a/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.tsx +++ b/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.tsx @@ -11,7 +11,9 @@ import type {ScheduledPost} from '@mattermost/types/schedule_post'; import {fetchMissingChannels} from 'mattermost-redux/actions/channels'; import {isDeactivatedDirectChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getMyChannelMemberships} from 'mattermost-redux/selectors/entities/common'; import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; +import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {openModal} from 'actions/views/modals'; @@ -75,6 +77,8 @@ type Props = { function ScheduledPostActions({scheduledPost, channel, onReschedule, onDelete, onSend, onEdit, onCopyText}: Props) { const dispatch = useDispatch(); const userTimezone = useSelector(getCurrentTimezone); + const myChannelsMemberships = useSelector((state: GlobalState) => getMyChannelMemberships(state)); + const isAdmin = useSelector((state: GlobalState) => isCurrentUserSystemAdmin(state)); useEffect(() => { // this ensures the DM is loaded in redux store and is available @@ -127,13 +131,13 @@ function ScheduledPostActions({scheduledPost, channel, onReschedule, onDelete, o })); }, [channel, dispatch, onSend, scheduledPost.id]); - const showEditOption = !scheduledPost.error_code; - + const userChannelMember = Boolean(channel && myChannelsMemberships[channel.id]); const isChannelArchived = Boolean(channel?.delete_at); - const isDeactivatedDM = useSelector((state: GlobalState) => isDeactivatedDirectChannel(state, scheduledPost.channel_id)); - const showSendNowOption = (!scheduledPost.error_code || scheduledPost.error_code === 'unknown' || scheduledPost.error_code === 'unable_to_send') && channel && !isChannelArchived && !isDeactivatedDM; - const showRescheduleOption = !scheduledPost.error_code || scheduledPost.error_code === 'unknown' || scheduledPost.error_code === 'unable_to_send'; + const showEditOption = !scheduledPost.error_code && userChannelMember && !isChannelArchived; + const isDeactivatedDM = useSelector((state: GlobalState) => isDeactivatedDirectChannel(state, scheduledPost.channel_id)); + const showSendNowOption = (!scheduledPost.error_code || scheduledPost.error_code === 'unknown' || scheduledPost.error_code === 'unable_to_send') && channel && !isChannelArchived && !isDeactivatedDM && userChannelMember; + const showRescheduleOption = (!scheduledPost.error_code || scheduledPost.error_code === 'unknown' || scheduledPost.error_code === 'unable_to_send') && userChannelMember && !isChannelArchived; return (
@@ -146,7 +150,7 @@ function ScheduledPostActions({scheduledPost, channel, onReschedule, onDelete, o /> { - showEditOption && + (isAdmin || showEditOption) && { - showRescheduleOption && + (isAdmin || showRescheduleOption) && makeGetScheduledPostsByTeam(), []); const scheduledPosts = useSelector((state: GlobalState) => getScheduledPostsByTeam(state, currentTeamId, true)); const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled); diff --git a/webapp/channels/src/components/drafts/drafts_illustration.tsx b/webapp/channels/src/components/drafts/drafts_illustration.tsx index 7b97514c5e..b48f249bb8 100644 --- a/webapp/channels/src/components/drafts/drafts_illustration.tsx +++ b/webapp/channels/src/components/drafts/drafts_illustration.tsx @@ -6,7 +6,7 @@ import React from 'react'; export default ( ); + const tooltipText = ( + + ); + if (type === 'thread') { title = ( +
+ {title} +
+ + ); } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 8f222070ec..4595e7a5b6 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4907,6 +4907,7 @@ "scheduled_post.panel.header.time": "Send {isTodayOrTomorrow, select, true {} other {on}} {scheduledDateTime}", "scheduled_posts.row_title_channel.placeholder": "In: {icon} No Destination", "scheduled_posts.row_title_thread.placeholder": "Thread to: {icon} No Destination", + "scheduled_posts.row_title_thread.placeholder_tooltip": "The channel either doesn’t exist or you do not have access to it.", "search_bar.channels": "Channels", "search_bar.clear": "Clear", "search_bar.file_types": "File types",