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
Этот коммит содержится в:
@@ -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(
|
||||||
|
<ScheduledPostActions
|
||||||
|
{...defaultProps}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,9 @@ import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
|||||||
|
|
||||||
import {fetchMissingChannels} from 'mattermost-redux/actions/channels';
|
import {fetchMissingChannels} from 'mattermost-redux/actions/channels';
|
||||||
import {isDeactivatedDirectChannel} from 'mattermost-redux/selectors/entities/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 {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
|
||||||
|
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||||
|
|
||||||
import {openModal} from 'actions/views/modals';
|
import {openModal} from 'actions/views/modals';
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ type Props = {
|
|||||||
function ScheduledPostActions({scheduledPost, channel, onReschedule, onDelete, onSend, onEdit, onCopyText}: Props) {
|
function ScheduledPostActions({scheduledPost, channel, onReschedule, onDelete, onSend, onEdit, onCopyText}: Props) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const userTimezone = useSelector(getCurrentTimezone);
|
const userTimezone = useSelector(getCurrentTimezone);
|
||||||
|
const myChannelsMemberships = useSelector((state: GlobalState) => getMyChannelMemberships(state));
|
||||||
|
const isAdmin = useSelector((state: GlobalState) => isCurrentUserSystemAdmin(state));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// this ensures the DM is loaded in redux store and is available
|
// 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]);
|
}, [channel, dispatch, onSend, scheduledPost.id]);
|
||||||
|
|
||||||
const showEditOption = !scheduledPost.error_code;
|
const userChannelMember = Boolean(channel && myChannelsMemberships[channel.id]);
|
||||||
|
|
||||||
const isChannelArchived = Boolean(channel?.delete_at);
|
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 (
|
return (
|
||||||
<div className='ScheduledPostActions'>
|
<div className='ScheduledPostActions'>
|
||||||
@@ -146,7 +150,7 @@ function ScheduledPostActions({scheduledPost, channel, onReschedule, onDelete, o
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
showEditOption &&
|
(isAdmin || showEditOption) &&
|
||||||
<Action
|
<Action
|
||||||
icon='icon-pencil-outline'
|
icon='icon-pencil-outline'
|
||||||
id='edit'
|
id='edit'
|
||||||
@@ -166,7 +170,7 @@ function ScheduledPostActions({scheduledPost, channel, onReschedule, onDelete, o
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
showRescheduleOption &&
|
(isAdmin || showRescheduleOption) &&
|
||||||
<Action
|
<Action
|
||||||
icon='icon-clock-send-outline'
|
icon='icon-clock-send-outline'
|
||||||
id='reschedule'
|
id='reschedule'
|
||||||
@@ -177,7 +181,7 @@ function ScheduledPostActions({scheduledPost, channel, onReschedule, onDelete, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
showSendNowOption &&
|
(isAdmin || showSendNowOption) &&
|
||||||
<Action
|
<Action
|
||||||
icon='icon-send-outline'
|
icon='icon-send-outline'
|
||||||
id='sendNow'
|
id='sendNow'
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function Drafts({
|
|||||||
const isScheduledPostsTab = useRouteMatch('/:team/' + SCHEDULED_POST_URL_SUFFIX);
|
const isScheduledPostsTab = useRouteMatch('/:team/' + SCHEDULED_POST_URL_SUFFIX);
|
||||||
|
|
||||||
const currentTeamId = useSelector(getCurrentTeamId);
|
const currentTeamId = useSelector(getCurrentTeamId);
|
||||||
const getScheduledPostsByTeam = makeGetScheduledPostsByTeam();
|
const getScheduledPostsByTeam = useMemo(() => makeGetScheduledPostsByTeam(), []);
|
||||||
const scheduledPosts = useSelector((state: GlobalState) => getScheduledPostsByTeam(state, currentTeamId, true));
|
const scheduledPosts = useSelector((state: GlobalState) => getScheduledPostsByTeam(state, currentTeamId, true));
|
||||||
const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled);
|
const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import React from 'react';
|
|||||||
export default (
|
export default (
|
||||||
<svg
|
<svg
|
||||||
width='100%'
|
width='100%'
|
||||||
height='auto'
|
height='100%'
|
||||||
viewBox='0 224 724 290'
|
viewBox='0 224 724 290'
|
||||||
fill='none'
|
fill='none'
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {FormattedMessage} from 'react-intl';
|
import {FormattedMessage} from 'react-intl';
|
||||||
|
|
||||||
|
import WithTooltip from 'components/with_tooltip';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type: 'channel' | 'thread';
|
type: 'channel' | 'thread';
|
||||||
}
|
}
|
||||||
@@ -17,6 +19,13 @@ export default function PlaceholderScheduledPostsTitle({type}: Props) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tooltipText = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='scheduled_posts.row_title_thread.placeholder_tooltip'
|
||||||
|
defaultMessage={'The channel either doesn’t exist or you do not have access to it.'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (type === 'thread') {
|
if (type === 'thread') {
|
||||||
title = (
|
title = (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
@@ -39,5 +48,15 @@ export default function PlaceholderScheduledPostsTitle({type}: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return title;
|
return (
|
||||||
|
<WithTooltip
|
||||||
|
id='scheduled_posts__placeholder'
|
||||||
|
placement={'top'}
|
||||||
|
title={tooltipText}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</WithTooltip>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4907,6 +4907,7 @@
|
|||||||
"scheduled_post.panel.header.time": "Send {isTodayOrTomorrow, select, true {} other {on}} {scheduledDateTime}",
|
"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_channel.placeholder": "In: {icon} No Destination",
|
||||||
"scheduled_posts.row_title_thread.placeholder": "Thread to: {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.channels": "Channels",
|
||||||
"search_bar.clear": "Clear",
|
"search_bar.clear": "Clear",
|
||||||
"search_bar.file_types": "File types",
|
"search_bar.file_types": "File types",
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user