MM61173 - settings modal base creation (#30338)
* MM-61173 - channel settings modal: base modal, initial commit, file creation and base component * new enhancements to the base modal creation * revert changes on textbox_links and edit channel header * fix types and add back unintentioned deleted value * add the preview textbox component * extract logic for info tab into its own component * add the purpose input to the window * move other component logic to its own component and code clean up * ability to update channel type * more advances on the archive channel tab * fix unit test in textbox * fix translations * do not show the archive modal in default channel * fix issue with url editor not being resetted on undo action * adjust text and styling for the header and purpose inputs * remove textboxlinks and use button eye icon * adjust test and preview button style * add unit test to channel patch * move logic from parent modal to info tab component * fix border issues and focus back to preview textareas * prevent saving changes when pressing enter when selecting an icon * enhance input component to cover limits validations and enhances tests * set default error message for save changes panel * add props to provide custom value to the buttons * remove channel input errors on reset button click * create new component settings textbox * rename component to advanced textbox and add unit tests * styling of the info tab and add error state to advanced textbox * add logic to prevent tab switch with unsaved changes * adjust url error logic and code clean up * code clean up and enhance comments * add char min length to advanced textbox logic * add the channel settings modal to the new menu * add new test files and fix reset error * remove unused error variables * adjust translations and remove unncesary import * enhance permissions for archive channels and manage channel settings * Adjust permission tree so channel admins can convert from private to public * enhance the test suit around channel conversion type * fix some e2e tests and solve channel input name issue * fix unit test by interacting first with the input element * adjust e2e tests to channel settings modal changes * remove commented tests and implement pr feedback * adjust more pr feedback to the code * more pr feedback enhancements * further enhancements to tab navigation, and adjust more e2e tests * remove unused components and fix e2e tests * revert unnecessary permissions changes * Add name label to textboxes * adjust e2e and unit tests * revert min lenght change value and adjust tests and snapshots * Channel banner settings (#30721) * Added channel banner setting header * Updated section styling * handled animation * handled min and max lengths * cleanup * color change fix * general improvements * Fixed API test * removed unused param className * added e2e tests * test: add channel settings configuration tab test file * Based on the context, here's a concise commit message for this change: feat: Add comprehensive tests for ChannelSettingsConfigurationTab * added some more tests * CI * reverted package-lock.json changes in Playwright * remove extra border from advaced textbox * adjust styling for name label in advance texbox and restart preview state on modal close * sync package.lock in playwright --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com> Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
Этот коммит содержится в:
@@ -32,6 +32,7 @@ import {
|
||||
isManuallyUnread,
|
||||
getCurrentChannelId,
|
||||
} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getMostRecentPostIdInChannel, getPost} from 'mattermost-redux/selectors/entities/posts';
|
||||
import {
|
||||
getCurrentRelativeTeamUrl,
|
||||
@@ -49,7 +50,7 @@ import {openDirectChannelToUserId} from 'actions/channel_actions';
|
||||
import {loadCustomStatusEmojisForPostList} from 'actions/emoji_actions';
|
||||
import {closeRightHandSide} from 'actions/views/rhs';
|
||||
import {markThreadAsRead} from 'actions/views/threads';
|
||||
import {getLastViewedChannelName} from 'selectors/local_storage';
|
||||
import {getLastViewedChannelName, getPenultimateViewedChannelName} from 'selectors/local_storage';
|
||||
import {getSelectedPost, getSelectedPostId} from 'selectors/rhs';
|
||||
import {getLastPostsApiTimeForChannel} from 'selectors/views/channel';
|
||||
import {getSelectedThreadIdInCurrentTeam} from 'selectors/views/threads';
|
||||
@@ -59,6 +60,7 @@ import LocalStorageStore from 'stores/local_storage_store';
|
||||
import {getHistory} from 'utils/browser_history';
|
||||
import {isArchivedChannel} from 'utils/channel_utils';
|
||||
import {Constants, ActionTypes, EventTypes, PostRequestTypes} from 'utils/constants';
|
||||
import {stopTryNotificationRing} from 'utils/notification_sounds';
|
||||
|
||||
import type {ActionFuncAsync, ThunkActionFunc} from 'types/store';
|
||||
|
||||
@@ -524,18 +526,42 @@ export function updateToastStatus(status: boolean) {
|
||||
|
||||
export function deleteChannel(channelId: string): ActionFuncAsync<boolean> {
|
||||
return async (dispatch, getState) => {
|
||||
// Get state before deletion
|
||||
const state = getState();
|
||||
const channel = getChannel(state, channelId);
|
||||
|
||||
// Validate channel ID
|
||||
if (!channel || channel.id.length !== Constants.CHANNEL_ID_LENGTH) {
|
||||
return {data: false};
|
||||
}
|
||||
|
||||
const canViewArchivedChannels = getConfig(state).ExperimentalViewArchivedChannels === 'true';
|
||||
const currentTeamDetails = getCurrentTeam(state);
|
||||
const penultimateViewedChannelName = getPenultimateViewedChannelName(state) ||
|
||||
getRedirectChannelNameForTeam(state, getCurrentTeamId(state));
|
||||
|
||||
// Handle redirection before deletion if needed
|
||||
if (!canViewArchivedChannels && penultimateViewedChannelName && currentTeamDetails) {
|
||||
getHistory().push('/' + currentTeamDetails.name + '/channels/' + penultimateViewedChannelName);
|
||||
}
|
||||
|
||||
// Call the delete channel action
|
||||
const res = await dispatch(deleteChannelRedux(channelId));
|
||||
if (res.error) {
|
||||
return {data: false};
|
||||
}
|
||||
const state = getState();
|
||||
|
||||
const selectedPost = getSelectedPost(state);
|
||||
const selectedPostId = getSelectedPostId(state);
|
||||
// Handle RHS state
|
||||
const updatedState = getState();
|
||||
const selectedPost = getSelectedPost(updatedState);
|
||||
const selectedPostId = getSelectedPostId(updatedState);
|
||||
if (selectedPostId && !selectedPost.exists) {
|
||||
dispatch(closeRightHandSide());
|
||||
}
|
||||
|
||||
// Stop notification sounds
|
||||
stopTryNotificationRing();
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,3 +23,17 @@ export function setShowPreviewOnEditChannelHeaderModal(showPreview) {
|
||||
showPreview,
|
||||
};
|
||||
}
|
||||
|
||||
export function setShowPreviewOnChannelSettingsHeaderModal(showPreview) {
|
||||
return {
|
||||
type: ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_HEADER_MODAL,
|
||||
showPreview,
|
||||
};
|
||||
}
|
||||
|
||||
export function setShowPreviewOnChannelSettingsPurposeModal(showPreview) {
|
||||
return {
|
||||
type: ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_PURPOSE_MODAL,
|
||||
showPreview,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {FileUpload} from 'components/file_upload/file_upload';
|
||||
import type Textbox from 'components/textbox/textbox';
|
||||
|
||||
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
|
||||
import {renderWithContext, userEvent, screen} from 'tests/react_testing_utils';
|
||||
import {renderWithContext, userEvent, screen, act} from 'tests/react_testing_utils';
|
||||
import Constants, {Locations, StoragePrefixes} from 'utils/constants';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
@@ -184,7 +184,7 @@ const baseProps = {
|
||||
|
||||
describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
describe('keyDown behavior', () => {
|
||||
it('ESC should blur the input', () => {
|
||||
it('ESC should blur the input', async () => {
|
||||
renderWithContext(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
@@ -200,12 +200,16 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
}),
|
||||
);
|
||||
const textbox = screen.getByTestId('post_textbox');
|
||||
userEvent.type(textbox, 'something{esc}');
|
||||
|
||||
await act(async () => {
|
||||
userEvent.type(textbox, 'something{esc}');
|
||||
});
|
||||
|
||||
expect(textbox).not.toHaveFocus();
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ESC should blur the input and reset draft when in editing mode', () => {
|
||||
it('ESC should blur the input and reset draft when in editing mode', async () => {
|
||||
jest.useFakeTimers();
|
||||
const props = {
|
||||
...baseProps,
|
||||
@@ -226,7 +230,9 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
}),
|
||||
);
|
||||
const textbox = screen.getByTestId('edit_textbox');
|
||||
userEvent.type(textbox, 'something{esc}');
|
||||
await act(async () => {
|
||||
userEvent.type(textbox, 'something{esc}');
|
||||
});
|
||||
expect(textbox).not.toHaveFocus();
|
||||
|
||||
// save is called with a short delayed after pressing escape key
|
||||
@@ -236,7 +242,7 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the textbox value to an existing draft on mount and when changing channels', () => {
|
||||
it('should set the textbox value to an existing draft on mount and when changing channels', async () => {
|
||||
const {rerender} = renderWithContext(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
@@ -261,16 +267,18 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
|
||||
expect(screen.getByPlaceholderText('Write to Test Channel')).toHaveValue('original draft');
|
||||
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(screen.getByPlaceholderText('Write to Other Channel')).toHaveValue('a different draft');
|
||||
});
|
||||
|
||||
it('should save a new draft when changing channels', () => {
|
||||
it('should save a new draft when changing channels', async () => {
|
||||
const {rerender} = renderWithContext(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
@@ -278,16 +286,20 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
initialState,
|
||||
);
|
||||
|
||||
userEvent.type(screen.getByPlaceholderText('Write to Test Channel'), 'some text');
|
||||
await act(async () => {
|
||||
userEvent.type(screen.getByPlaceholderText('Write to Test Channel'), 'some text');
|
||||
});
|
||||
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedUpdateDraft).toHaveBeenCalled();
|
||||
expect(mockedUpdateDraft.mock.calls[0][1]).toMatchObject({
|
||||
@@ -296,7 +308,7 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-60541 should not save an unmodified draft when changing channels', () => {
|
||||
it('MM-60541 should not save an unmodified draft when changing channels', async () => {
|
||||
const {rerender} = renderWithContext(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
@@ -316,17 +328,19 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save an updated draft when changing channels', () => {
|
||||
it('should save an updated draft when changing channels', async () => {
|
||||
const {rerender} = renderWithContext(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
@@ -344,16 +358,20 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
userEvent.type(screen.getByPlaceholderText('Write to Test Channel'), ' plus some new text');
|
||||
await act(async () => {
|
||||
userEvent.type(screen.getByPlaceholderText('Write to Test Channel'), ' plus some new text');
|
||||
});
|
||||
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedUpdateDraft).toHaveBeenCalled();
|
||||
expect(mockedUpdateDraft.mock.calls[0][1]).toMatchObject({
|
||||
@@ -362,7 +380,7 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should deleted a deleted draft when changing channels', () => {
|
||||
it('should deleted a deleted draft when changing channels', async () => {
|
||||
const {rerender} = renderWithContext(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
@@ -380,23 +398,27 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
userEvent.clear(screen.getByPlaceholderText('Write to Test Channel'));
|
||||
await act(async () => {
|
||||
userEvent.clear(screen.getByPlaceholderText('Write to Test Channel'));
|
||||
});
|
||||
|
||||
expect(mockedRemoveDraft).not.toHaveBeenCalled();
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedRemoveDraft).toHaveBeenCalled();
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MM-60541 should not attempt to delete a non-existent draft when changing channels', () => {
|
||||
it('MM-60541 should not attempt to delete a non-existent draft when changing channels', async () => {
|
||||
const {rerender} = renderWithContext(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
@@ -407,12 +429,14 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
expect(mockedRemoveDraft).not.toHaveBeenCalled();
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<AdvancedTextEditor
|
||||
{...baseProps}
|
||||
channelId={otherChannelId}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedRemoveDraft).not.toHaveBeenCalled();
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
|
||||
@@ -10,7 +10,7 @@ import {renderWithContext} from 'tests/react_testing_utils';
|
||||
import {LicenseSkus, Constants} from 'utils/constants';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import ChannelBanner from './index';
|
||||
import ChannelBanner from './channel_banner';
|
||||
|
||||
describe('components/channel_banner', () => {
|
||||
const channel1 = TestHelper.getChannelMock({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import React, {useEffect, useMemo, useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
@@ -30,6 +30,19 @@ type Props = {
|
||||
export default function ChannelBanner({channelId}: Props) {
|
||||
const channelBannerInfo = useSelector((state: GlobalState) => getChannelBanner(state, channelId));
|
||||
const showChannelBanner = useSelector((state: GlobalState) => selectShowChannelBanner(state, channelId));
|
||||
const textContainerRef = useRef<HTMLSpanElement>(null);
|
||||
const [tooltipNeeded, setTooltipNeeded] = React.useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOverflowingHorizontally = textContainerRef.current.offsetWidth < textContainerRef.current.scrollWidth;
|
||||
const isOverflowingVertically = textContainerRef.current.offsetHeight < textContainerRef.current.scrollHeight;
|
||||
|
||||
setTooltipNeeded(isOverflowingHorizontally || isOverflowingVertically);
|
||||
}, [channelBannerInfo?.text]);
|
||||
|
||||
const intl = useIntl();
|
||||
const channelBannerTextAriaLabel = intl.formatMessage({id: 'channel_banner.aria_label', defaultMessage: 'Channel banner text'});
|
||||
@@ -75,6 +88,7 @@ export default function ChannelBanner({channelId}: Props) {
|
||||
className='channelBannerTooltip'
|
||||
delayClose={true}
|
||||
forcedPlacement='bottom'
|
||||
disabled={!tooltipNeeded}
|
||||
>
|
||||
<div
|
||||
className='channel_banner'
|
||||
@@ -86,6 +100,7 @@ export default function ChannelBanner({channelId}: Props) {
|
||||
className='channel_banner_text'
|
||||
aria-label={channelBannerTextAriaLabel}
|
||||
style={channelBannerTextStyle}
|
||||
ref={textContainerRef}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
@@ -5,7 +5,6 @@
|
||||
width: 100%;
|
||||
min-height: variables.$announcement-bar-height;
|
||||
max-height: variables.$announcement-bar-height;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 6px;
|
||||
padding-inline: 24px;
|
||||
@@ -18,18 +17,38 @@
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:has(p:first-child) {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--channel-banner-text-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown__heading {
|
||||
overflow: hidden;
|
||||
margin: 2px;
|
||||
font-size: 18px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.markdown__table {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.channelBannerTooltip {
|
||||
top: -2px !important;
|
||||
overflow: auto;
|
||||
min-width: 400px;
|
||||
max-width: min(1000px, 70vw);
|
||||
max-height: 80vh;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {Constants} from 'utils/constants';
|
||||
|
||||
import MenuItemArchiveChannel from '../menu_items/archive_channel';
|
||||
import MenuItemChannelBookmarks from '../menu_items/channel_bookmarks_submenu';
|
||||
import MenuItemChannelSettings from '../menu_items/channel_settings_submenu';
|
||||
import MenuItemChannelSettings from '../menu_items/channel_settings_menu';
|
||||
import MenuItemCloseChannel from '../menu_items/close_channel';
|
||||
import MenuItemGroupsMenuItems from '../menu_items/groups';
|
||||
import MenuItemLeaveChannel from '../menu_items/leave_channel';
|
||||
@@ -44,7 +44,7 @@ interface Props extends Menu.FirstMenuItemProps {
|
||||
pluginItems: ReactNode[];
|
||||
}
|
||||
|
||||
const ChannelHeaderPublicMenu = ({channel, user, isMuted, isReadonly, isDefault, isMobile, isFavorite, isLicensedForLDAPGroups, pluginItems, ...rest}: Props) => {
|
||||
const ChannelHeaderPublicMenu = ({channel, user, isMuted, isDefault, isMobile, isFavorite, isLicensedForLDAPGroups, pluginItems, ...rest}: Props) => {
|
||||
const isGroupConstrained = channel?.group_constrained === true;
|
||||
const isArchived = channel.delete_at !== 0;
|
||||
const isPrivate = channel?.type === Constants.PRIVATE_CHANNEL;
|
||||
@@ -71,8 +71,6 @@ const ChannelHeaderPublicMenu = ({channel, user, isMuted, isReadonly, isDefault,
|
||||
channel={channel}
|
||||
/>
|
||||
<MenuItemChannelSettings
|
||||
isReadonly={isReadonly}
|
||||
isDefault={isDefault}
|
||||
channel={channel}
|
||||
/>
|
||||
<MenuItemChannelBookmarks
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {
|
||||
CogOutlineIcon,
|
||||
} from '@mattermost/compass-icons/components';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import ChannelSettingsModal from 'components/channel_settings_modal/channel_settings_modal';
|
||||
import * as Menu from 'components/menu';
|
||||
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
type Props = {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
const ChannelSettingsMenu = ({channel}: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleOpenChannelSettings = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.CHANNEL_SETTINGS,
|
||||
dialogType: ChannelSettingsModal,
|
||||
dialogProps: {
|
||||
channelId: channel.id,
|
||||
focusOriginElement: 'channelHeaderDropdownButton',
|
||||
isOpen: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
id={'channelSettings'}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channel_header.channel_settings'
|
||||
defaultMessage='Channel Settings'
|
||||
/>
|
||||
}
|
||||
onClick={handleOpenChannelSettings}
|
||||
leadingElement={<CogOutlineIcon size={18}/>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ChannelSettingsMenu);
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CogOutlineIcon,
|
||||
} from '@mattermost/compass-icons/components';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {Permissions} from 'mattermost-redux/constants';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import ConvertChannelModal from 'components/convert_channel_modal';
|
||||
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
|
||||
import EditChannelPurposeModal from 'components/edit_channel_purpose_modal';
|
||||
import * as Menu from 'components/menu';
|
||||
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
|
||||
import RenameChannelModal from 'components/rename_channel_modal';
|
||||
|
||||
import {Constants, ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
type Props = {
|
||||
channel: Channel;
|
||||
isReadonly: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const ChannelSettingsSubmenu = ({channel, isReadonly, isDefault}: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const {formatMessage} = useIntl();
|
||||
const channelPropertiesPermission = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
|
||||
const handleRenameChannel = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.RENAME_CHANNEL,
|
||||
dialogType: RenameChannelModal,
|
||||
dialogProps: {channel},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditHeader = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
|
||||
dialogType: EditChannelHeaderModal,
|
||||
dialogProps: {channel},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditPurpose = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
|
||||
dialogType: EditChannelPurposeModal,
|
||||
dialogProps: {channel},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleConvertToPrivate = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.CONVERT_CHANNEL,
|
||||
dialogType: ConvertChannelModal,
|
||||
dialogProps: {
|
||||
channelId: channel.id,
|
||||
channelDisplayName: channel.display_name,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu.SubMenu
|
||||
id={'channelSettings'}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channelSettings'
|
||||
defaultMessage='Channel Settings'
|
||||
/>
|
||||
}
|
||||
leadingElement={<CogOutlineIcon size={18}/>}
|
||||
trailingElements={<ChevronRightIcon size={16}/>}
|
||||
menuId={'channelSettings-menu'}
|
||||
menuAriaLabel={formatMessage({id: 'channelSettings', defaultMessage: 'Channel Settings'})}
|
||||
>
|
||||
{!isReadonly && (
|
||||
<Menu.Item
|
||||
id='channelRename'
|
||||
onClick={handleRenameChannel}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channel_header.rename'
|
||||
defaultMessage='Rename Channel'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isReadonly && (
|
||||
<ChannelPermissionGate
|
||||
channelId={channel.id}
|
||||
teamId={channel.team_id}
|
||||
permissions={[channelPropertiesPermission]}
|
||||
>
|
||||
<Menu.Item
|
||||
id='channelEditHeader'
|
||||
onClick={handleEditHeader}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channel_header.setHeader'
|
||||
defaultMessage='Edit Channel Header'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ChannelPermissionGate>
|
||||
)}
|
||||
|
||||
{!isReadonly && (
|
||||
<ChannelPermissionGate
|
||||
channelId={channel.id}
|
||||
teamId={channel.team_id}
|
||||
permissions={[channelPropertiesPermission]}
|
||||
>
|
||||
<Menu.Item
|
||||
id='channelEditPurpose'
|
||||
onClick={handleEditPurpose}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channel_header.setPurpose'
|
||||
defaultMessage='Edit Channel Purpose'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ChannelPermissionGate>
|
||||
)}
|
||||
|
||||
{!isDefault && channel.type === Constants.OPEN_CHANNEL && (
|
||||
<ChannelPermissionGate
|
||||
channelId={channel.id}
|
||||
teamId={channel.team_id}
|
||||
permissions={[Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE]}
|
||||
>
|
||||
<Menu.Item
|
||||
id='channelConvertToPrivate'
|
||||
onClick={handleConvertToPrivate}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channel_header.convert'
|
||||
defaultMessage='Convert to Private Channel'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ChannelPermissionGate>
|
||||
)}
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ChannelSettingsSubmenu);
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import * as modalActions from 'actions/views/modals';
|
||||
|
||||
import ConvertChannelModal from 'components/convert_channel_modal';
|
||||
import {WithTestMenuContext} from 'components/menu/menu_context_test';
|
||||
|
||||
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import ConvertPublictoPrivate from './convert_public_to_private';
|
||||
|
||||
describe('components/ChannelHeaderMenu/MenuItems/ConvertPublicToPrivate', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(modalActions, 'openModal');
|
||||
|
||||
// Mock useDispatch to return our custom dispatch function
|
||||
jest.spyOn(require('react-redux'), 'useDispatch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
const channel = TestHelper.getChannelMock();
|
||||
|
||||
test('renders the component correctly, handle click event', () => {
|
||||
renderWithContext(
|
||||
<WithTestMenuContext>
|
||||
<ConvertPublictoPrivate channel={channel}/>
|
||||
</WithTestMenuContext>, {},
|
||||
);
|
||||
|
||||
const menuItem = screen.getByText('Convert to Private Channel');
|
||||
expect(menuItem).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(menuItem); // Simulate click on the menu item
|
||||
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
|
||||
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
|
||||
expect(modalActions.openModal).toHaveBeenCalledWith({
|
||||
modalId: ModalIdentifiers.CONVERT_CHANNEL,
|
||||
dialogType: ConvertChannelModal,
|
||||
dialogProps: {
|
||||
channelId: channel.id,
|
||||
channelDisplayName: channel.display_name,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import ConvertChannelModal from 'components/convert_channel_modal';
|
||||
import * as Menu from 'components/menu';
|
||||
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
type Props = {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
const ConvertPublictoPrivate = ({channel}: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const handleConvertToPrivate = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.CONVERT_CHANNEL,
|
||||
dialogType: ConvertChannelModal,
|
||||
dialogProps: {
|
||||
channelId: channel.id,
|
||||
channelDisplayName: channel.display_name,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
id='channelConvertToPrivate'
|
||||
onClick={handleConvertToPrivate}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channel_header.convert'
|
||||
defaultMessage='Convert to Private Channel'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ConvertPublictoPrivate);
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import * as modalActions from 'actions/views/modals';
|
||||
|
||||
import {WithTestMenuContext} from 'components/menu/menu_context_test';
|
||||
import RenameChannelModal from 'components/rename_channel_modal';
|
||||
|
||||
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import EditChannelSettings from './edit_channel_settings';
|
||||
|
||||
describe('components/ChannelHeaderMenu/MenuItems/EditChannelSettings', () => {
|
||||
const channel = TestHelper.getChannelMock();
|
||||
beforeEach(() => {
|
||||
jest.spyOn(modalActions, 'openModal');
|
||||
jest.spyOn(require('react-redux'), 'useDispatch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders the component correctly, handle click event', () => {
|
||||
renderWithContext(
|
||||
<WithTestMenuContext>
|
||||
<EditChannelSettings
|
||||
channel={channel}
|
||||
isReadonly={false}
|
||||
isDefault={false}
|
||||
/>
|
||||
</WithTestMenuContext>, {},
|
||||
);
|
||||
|
||||
const menuItem = screen.getByText('Rename Channel');
|
||||
expect(menuItem).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(menuItem); // Simulate click on the menu item
|
||||
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
|
||||
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
|
||||
expect(modalActions.openModal).toHaveBeenCalledWith({
|
||||
modalId: ModalIdentifiers.RENAME_CHANNEL,
|
||||
dialogType: RenameChannelModal,
|
||||
dialogProps: {channel},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {Permissions} from 'mattermost-redux/constants';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
|
||||
import EditChannelPurposeModal from 'components/edit_channel_purpose_modal';
|
||||
import * as Menu from 'components/menu';
|
||||
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
|
||||
import RenameChannelModal from 'components/rename_channel_modal';
|
||||
|
||||
import {Constants, ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
import MenuItemConvertToPrivate from './convert_public_to_private';
|
||||
|
||||
type Props = {
|
||||
channel: Channel;
|
||||
isReadonly: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const EditChannelSettings = ({channel, isReadonly, isDefault}: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const channelPropertiesPermission = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
|
||||
|
||||
const handleRenameChannel = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.RENAME_CHANNEL,
|
||||
dialogType: RenameChannelModal,
|
||||
dialogProps: {channel},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditHeader = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
|
||||
dialogType: EditChannelHeaderModal,
|
||||
dialogProps: {channel},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditPurpose = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
|
||||
dialogType: EditChannelPurposeModal,
|
||||
dialogProps: {channel},
|
||||
}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{!isReadonly && (
|
||||
<>
|
||||
<Menu.Item
|
||||
id='channelRename'
|
||||
onClick={handleRenameChannel}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channel_header.rename'
|
||||
defaultMessage='Rename Channel'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChannelPermissionGate
|
||||
channelId={channel.id}
|
||||
teamId={channel.team_id}
|
||||
permissions={[channelPropertiesPermission]}
|
||||
>
|
||||
<Menu.Item
|
||||
id='channelEditHeader'
|
||||
onClick={handleEditHeader}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channel_header.setHeader'
|
||||
defaultMessage='Edit Channel Header'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Menu.Item
|
||||
id='channelEditPurpose'
|
||||
onClick={handleEditPurpose}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='channel_header.setPurpose'
|
||||
defaultMessage='Edit Channel Purpose'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ChannelPermissionGate>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isDefault && channel.type === Constants.OPEN_CHANNEL && (
|
||||
<ChannelPermissionGate
|
||||
channelId={channel.id}
|
||||
teamId={channel.team_id}
|
||||
permissions={[Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE]}
|
||||
>
|
||||
<MenuItemConvertToPrivate
|
||||
channel={channel}
|
||||
/>
|
||||
</ChannelPermissionGate>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(EditChannelSettings);
|
||||
@@ -24,10 +24,12 @@ export type Props = {
|
||||
placeholder: string;
|
||||
onDisplayNameChange: (name: string) => void;
|
||||
onURLChange: (url: string) => void;
|
||||
currentUrl?: string;
|
||||
autoFocus?: boolean;
|
||||
onErrorStateChange?: (isError: boolean) => void;
|
||||
onErrorStateChange?: (isError: boolean, errorMessage?: string) => void;
|
||||
team?: Team;
|
||||
urlError?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
import './channel_name_form_field.scss';
|
||||
@@ -54,14 +56,20 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
|
||||
const intl = useIntl();
|
||||
const {formatMessage} = intl;
|
||||
|
||||
const displayNameModified = useRef<boolean>(false);
|
||||
// Track if the field has been interacted with
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
const [displayNameError, setDisplayNameError] = useState<string>('');
|
||||
const displayName = useRef<string>('');
|
||||
const urlModified = useRef<boolean>(false);
|
||||
const [url, setURL] = useState<string>('');
|
||||
const [url, setURL] = useState<string>(props.currentUrl || '');
|
||||
const [urlError, setURLError] = useState<string>('');
|
||||
const [inputCustomMessage, setInputCustomMessage] = useState<CustomMessageInputType | null>(null);
|
||||
|
||||
// Initialize displayName.current with props.value when component mounts
|
||||
useEffect(() => {
|
||||
displayName.current = props.value;
|
||||
}, [props.value]);
|
||||
|
||||
const currentTeamName = useSelector(getCurrentTeam)?.name;
|
||||
const teamName = props.team ? props.team.name : currentTeamName;
|
||||
|
||||
@@ -69,10 +77,27 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
|
||||
e.preventDefault();
|
||||
const {target: {value: updatedDisplayName}} = e;
|
||||
|
||||
const displayNameErrors = validateDisplayName(intl, updatedDisplayName);
|
||||
// Mark as interacted when user types
|
||||
if (!hasInteracted && updatedDisplayName.trim() !== '') {
|
||||
setHasInteracted(true);
|
||||
}
|
||||
|
||||
// Only validate if the user has interacted with the field
|
||||
if (hasInteracted) {
|
||||
const displayNameErrors = validateDisplayName(intl, updatedDisplayName);
|
||||
|
||||
if (displayNameErrors.length) {
|
||||
setDisplayNameError(displayNameErrors[displayNameErrors.length - 1]);
|
||||
setInputCustomMessage({
|
||||
type: 'error',
|
||||
value: displayNameErrors[displayNameErrors.length - 1],
|
||||
});
|
||||
} else {
|
||||
setDisplayNameError('');
|
||||
setInputCustomMessage(null);
|
||||
}
|
||||
}
|
||||
|
||||
// set error if any, else clear it
|
||||
setDisplayNameError(displayNameErrors.length ? displayNameErrors[displayNameErrors.length - 1] : '');
|
||||
displayName.current = updatedDisplayName;
|
||||
props.onDisplayNameChange(updatedDisplayName);
|
||||
|
||||
@@ -83,19 +108,32 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
|
||||
setURLError('');
|
||||
props.onURLChange(cleanURL);
|
||||
}
|
||||
}, [props.onDisplayNameChange, props.onURLChange]);
|
||||
}, [props.onDisplayNameChange, props.onURLChange, hasInteracted, intl]);
|
||||
|
||||
const handleOnDisplayNameBlur = useCallback(() => {
|
||||
// Always mark as interacted on blur
|
||||
setHasInteracted(true);
|
||||
|
||||
// Validate on blur - always show errors on blur regardless of interaction state
|
||||
const displayNameErrors = validateDisplayName(intl, displayName.current);
|
||||
setDisplayNameError(displayNameErrors.length ? displayNameErrors[displayNameErrors.length - 1] : '');
|
||||
|
||||
if (displayNameErrors.length) {
|
||||
setInputCustomMessage({
|
||||
type: 'error',
|
||||
value: displayNameErrors[displayNameErrors.length - 1],
|
||||
});
|
||||
} else {
|
||||
setInputCustomMessage(null);
|
||||
}
|
||||
|
||||
// Handle URL generation if needed
|
||||
if (displayName.current && !url) {
|
||||
const url = generateSlug();
|
||||
setURL(url);
|
||||
props.onURLChange(url);
|
||||
}
|
||||
if (!displayNameModified.current) {
|
||||
displayNameModified.current = true;
|
||||
setInputCustomMessage(null);
|
||||
}
|
||||
}, [props.onURLChange, displayName.current, url, displayNameModified]);
|
||||
}, [props.onURLChange, displayName.current, url, intl]);
|
||||
|
||||
const handleOnURLChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -110,12 +148,41 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
|
||||
props.onURLChange(cleanURL);
|
||||
}, [props.onURLChange]);
|
||||
|
||||
// Add a URL blur handler to validate the URL when the user moves away from the field
|
||||
const handleOnURLBlur = useCallback(() => {
|
||||
// Only validate if the URL has been modified
|
||||
if (urlModified.current) {
|
||||
const urlErrors = validateChannelUrl(url, intl);
|
||||
let lastError = '';
|
||||
if (urlErrors.length && typeof urlErrors[urlErrors.length - 1] === 'string') {
|
||||
// Safe to assert as string because we're always providing intl to validateChannelUrl and have extra type safe check
|
||||
lastError = urlErrors[urlErrors.length - 1] as string;
|
||||
}
|
||||
setURLError(lastError);
|
||||
}
|
||||
}, [url, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only report URL errors if the URL has been explicitly modified and validated
|
||||
// This prevents showing errors during typing
|
||||
if (props.onErrorStateChange) {
|
||||
props.onErrorStateChange(Boolean(displayNameError) || Boolean(urlError));
|
||||
if (displayNameError) {
|
||||
props.onErrorStateChange(true, displayNameError);
|
||||
} else if (urlModified.current && urlError) {
|
||||
props.onErrorStateChange(true, urlError);
|
||||
} else {
|
||||
props.onErrorStateChange(false, '');
|
||||
}
|
||||
}
|
||||
}, [displayNameError, urlError]);
|
||||
|
||||
// Effect to set URL from props if it's modified (used in modals for reset button event which sets the value outside the onChange event)
|
||||
useEffect(() => {
|
||||
if (props.currentUrl) {
|
||||
setURL(props.currentUrl);
|
||||
}
|
||||
}, [props.currentUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
@@ -129,10 +196,14 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
|
||||
label={formatMessage({id: 'channel_modal.name.label', defaultMessage: 'Channel name'})}
|
||||
placeholder={props.placeholder}
|
||||
limit={Constants.MAX_CHANNELNAME_LENGTH}
|
||||
|
||||
// Only pass minLength after the user has interacted with the field
|
||||
minLength={hasInteracted ? Constants.MIN_CHANNELNAME_LENGTH : undefined}
|
||||
value={props.value}
|
||||
customMessage={inputCustomMessage}
|
||||
onChange={handleOnDisplayNameChange}
|
||||
onBlur={handleOnDisplayNameBlur}
|
||||
disabled={props.readOnly}
|
||||
/>
|
||||
<URLInput
|
||||
className='new-channel-modal__url'
|
||||
@@ -143,6 +214,7 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
|
||||
shortenLength={Constants.DEFAULT_CHANNELURL_SHORTEN_LENGTH}
|
||||
error={urlError || props.urlError}
|
||||
onChange={handleOnURLChange}
|
||||
onBlur={handleOnURLBlur}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,9 @@ export type Props = PropsFromRedux & {
|
||||
*/
|
||||
currentUser: UserProfile;
|
||||
|
||||
/**
|
||||
* Id of the element that triggered the modal opening
|
||||
*/
|
||||
focusOriginElement?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import type {ClientConfig} from '@mattermost/types/config';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
|
||||
import * as teams from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import * as channelActions from 'actions/views/channel';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import ChannelSettingsArchiveTab from './channel_settings_archive_tab';
|
||||
|
||||
// Mock the redux actions and selectors
|
||||
jest.mock('actions/views/channel', () => ({
|
||||
deleteChannel: jest.fn().mockReturnValue({type: 'MOCK_DELETE_ACTION'}),
|
||||
}));
|
||||
|
||||
jest.mock('utils/browser_history', () => ({
|
||||
getHistory: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('mattermost-redux/selectors/entities/general', () => ({
|
||||
...jest.requireActual('mattermost-redux/selectors/entities/general') as typeof import('mattermost-redux/selectors/entities/general'),
|
||||
getConfig: () => mockConfig,
|
||||
}));
|
||||
|
||||
// Mock the roles selector which is a dependency for other selectors
|
||||
jest.mock('mattermost-redux/selectors/entities/roles', () => ({
|
||||
haveITeamPermission: jest.fn().mockReturnValue(true),
|
||||
haveIChannelPermission: jest.fn().mockReturnValue(true),
|
||||
getRoles: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Create a mock channel for testing
|
||||
const mockChannel = TestHelper.getChannelMock({
|
||||
id: 'using-26-letter-channel-id', // so channel id validation length works
|
||||
team_id: 'team1',
|
||||
display_name: 'Test Channel',
|
||||
name: 'test-channel',
|
||||
type: 'O',
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
channel: mockChannel,
|
||||
onHide: jest.fn(),
|
||||
};
|
||||
|
||||
let mockConfig: Partial<ClientConfig>;
|
||||
|
||||
describe('ChannelSettingsArchiveTab', () => {
|
||||
const {getHistory} = require('utils/browser_history');
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockConfig = {
|
||||
ExperimentalViewArchivedChannels: 'false',
|
||||
};
|
||||
|
||||
jest.spyOn(teams, 'getCurrentTeam').mockReturnValue({
|
||||
id: 'team1',
|
||||
name: 'team-name',
|
||||
} as Team);
|
||||
|
||||
const historyPush = jest.fn();
|
||||
(getHistory as jest.Mock).mockReturnValue({push: historyPush});
|
||||
});
|
||||
|
||||
it('should render the archive button', () => {
|
||||
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
|
||||
|
||||
// Check that the archive button is rendered
|
||||
const archiveButton = screen.getByText('Archive this channel');
|
||||
expect(archiveButton).toBeInTheDocument();
|
||||
expect(archiveButton).toHaveAttribute('aria-label', 'Archive channel Test Channel');
|
||||
});
|
||||
|
||||
it('should show confirmation modal when archive button is clicked', async () => {
|
||||
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
|
||||
|
||||
// Click the archive button
|
||||
await userEvent.click(screen.getByText('Archive this channel'));
|
||||
|
||||
// Check that the confirmation modal is shown
|
||||
expect(screen.getByTestId('archiveChannelConfirmModal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Archive channel?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call deleteChannel and onHide when confirmed', async () => {
|
||||
const onHide = jest.fn();
|
||||
|
||||
renderWithContext(<ChannelSettingsArchiveTab {...{...baseProps, onHide}}/>);
|
||||
|
||||
// Click the archive button
|
||||
await userEvent.click(screen.getByText('Archive this channel'));
|
||||
|
||||
// Click the confirm button in the modal
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
|
||||
|
||||
// Check that deleteChannel was called with the channel ID
|
||||
expect(channelActions.deleteChannel).toHaveBeenCalledWith(mockChannel.id);
|
||||
|
||||
// Check that onHide was called
|
||||
expect(onHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close the confirmation modal when canceled', async () => {
|
||||
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
|
||||
|
||||
// Click the archive button
|
||||
await userEvent.click(screen.getByText('Archive this channel'));
|
||||
|
||||
// Check that the confirmation modal is shown
|
||||
expect(screen.getByTestId('archiveChannelConfirmModal')).toBeInTheDocument();
|
||||
|
||||
// Click the cancel button in the modal
|
||||
await userEvent.click(screen.getByTestId('cancel-button'));
|
||||
|
||||
// Check that the confirmation modal is hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('archiveChannelConfirmModal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call deleteChannel which handles redirection when archived channel cannot be viewed', async () => {
|
||||
// Mock the deleteChannel implementation to simulate the action's behavior
|
||||
const {deleteChannel} = require('actions/views/channel');
|
||||
deleteChannel.mockImplementationOnce(() => {
|
||||
return () => {
|
||||
// The action would handle redirection internally
|
||||
return {data: true};
|
||||
};
|
||||
});
|
||||
|
||||
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
|
||||
|
||||
// Click the archive button
|
||||
await userEvent.click(screen.getByText('Archive this channel'));
|
||||
|
||||
// Check that the confirmation modal is shown
|
||||
expect(screen.getByTestId('archiveChannelConfirmModal')).toBeInTheDocument();
|
||||
|
||||
// Click the confirm button in the modal
|
||||
await userEvent.click(screen.getByText('Confirm'));
|
||||
|
||||
// Check that deleteChannel was called with the channel ID
|
||||
expect(channelActions.deleteChannel).toHaveBeenCalledWith(mockChannel.id);
|
||||
});
|
||||
|
||||
it('should show correct message when archived channels cannot be viewed', async () => {
|
||||
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
|
||||
|
||||
// Click the archive button
|
||||
await userEvent.click(screen.getByText('Archive this channel'));
|
||||
|
||||
// Check that the confirmation modal message mentions that archived channels cannot be viewed
|
||||
const modalMessage = screen.getByTestId('archiveChannelConfirmModal');
|
||||
expect(modalMessage).toBeInTheDocument();
|
||||
|
||||
// Check for the specific text content within the modal
|
||||
// Use the within function to scope the query to just the modal content
|
||||
const modalBody = screen.getByTestId('archiveChannelConfirmModal').querySelector('#confirmModalBody');
|
||||
expect(modalBody).toBeInTheDocument();
|
||||
expect(modalBody).toHaveTextContent(/Archiving a channel removes it from the user interface/);
|
||||
});
|
||||
|
||||
it('should call deleteChannel which handles channel ID validation', async () => {
|
||||
// Create a channel with an invalid ID
|
||||
const invalidChannel = {
|
||||
...mockChannel,
|
||||
id: 'invalid', // Too short to be a valid channel ID
|
||||
};
|
||||
|
||||
// Mock the deleteChannel implementation to simulate the action's behavior
|
||||
const {deleteChannel} = require('actions/views/channel');
|
||||
deleteChannel.mockImplementationOnce(() => {
|
||||
return () => {
|
||||
// The action would handle validation internally and return false for invalid IDs
|
||||
return {data: false};
|
||||
};
|
||||
});
|
||||
|
||||
renderWithContext(<ChannelSettingsArchiveTab {...{...baseProps, channel: invalidChannel}}/>);
|
||||
|
||||
// Click the archive button
|
||||
await userEvent.click(screen.getByText('Archive this channel'));
|
||||
|
||||
// Click the confirm button in the modal
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
|
||||
|
||||
// Check that deleteChannel was called with the invalid channel ID
|
||||
expect(channelActions.deleteChannel).toHaveBeenCalledWith(invalidChannel.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState, useCallback} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import {deleteChannel} from 'actions/views/channel';
|
||||
|
||||
import ConfirmationModal from 'components/confirm_modal';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
type ChannelSettingsArchiveTabProps = {
|
||||
channel: Channel;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
function ChannelSettingsArchiveTab({
|
||||
channel,
|
||||
onHide,
|
||||
}: ChannelSettingsArchiveTabProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Redux selector
|
||||
const canViewArchivedChannels = useSelector((state: GlobalState) => getConfig(state).ExperimentalViewArchivedChannels === 'true');
|
||||
|
||||
const [showArchiveConfirmModal, setShowArchiveConfirmModal] = useState(false);
|
||||
|
||||
const handleArchiveChannel = useCallback(() => {
|
||||
setShowArchiveConfirmModal(true);
|
||||
}, []);
|
||||
|
||||
const doArchiveChannel = async () => {
|
||||
// Call the delete channel action which handles validation, redirection, and notification sounds
|
||||
await dispatch(deleteChannel(channel.id));
|
||||
|
||||
// Close the modal
|
||||
onHide();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='ChannelSettingsModal__archiveTab'>
|
||||
<FormattedMessage
|
||||
id='channel_settings.archive.warning'
|
||||
defaultMessage="Archiving a channel removes it from the user interface, but doesn't permanently delete the channel. New messages can't be posted to archived channels."
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-danger'
|
||||
onClick={handleArchiveChannel}
|
||||
id='channelSettingsArchiveChannelButton'
|
||||
aria-label={`Archive channel ${channel.display_name}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='channel_settings.archive.button'
|
||||
defaultMessage='Archive this channel'
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showArchiveConfirmModal && (
|
||||
<ConfirmationModal
|
||||
id='archiveChannelConfirmModal'
|
||||
show={true}
|
||||
title={formatMessage({id: 'channel_settings.modal.archiveTitle', defaultMessage: 'Archive channel?'})}
|
||||
message={
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id={canViewArchivedChannels ?
|
||||
'deleteChannelModal.canViewArchivedChannelsWarning' :
|
||||
'deleteChannelModal.cannotViewArchivedChannelsWarning'
|
||||
}
|
||||
defaultMessage="Archiving a channel removes it from the user interface, but doesn't permanently delete the channel. New messages can't be posted to archived channels."
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='deleteChannelModal.confirmArchive'
|
||||
defaultMessage='Are you sure you wish to archive the <strong>{display_name}</strong> channel?'
|
||||
values={{
|
||||
display_name: channel.display_name,
|
||||
strong: (chunks: string) => <strong>{chunks}</strong>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
confirmButtonText={formatMessage({id: 'channel_settings.modal.confirmArchive', defaultMessage: 'Confirm'})}
|
||||
onConfirm={doArchiveChannel}
|
||||
onCancel={() => setShowArchiveConfirmModal(false)}
|
||||
confirmButtonClass='btn btn-danger'
|
||||
modalClass='archiveChannelConfirmModal'
|
||||
focusOriginElement='channelSettingsArchiveChannelButton'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelSettingsArchiveTab;
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
@import "utils/_animations";
|
||||
|
||||
.ChannelSettingsModal__configurationTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.channel_banner_header {
|
||||
display: flex;
|
||||
|
||||
.channel_banner_header__text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
&.heading {
|
||||
color: rgb(var(--center-channel-color-rgb));
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.subheading {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.channel_banner_section_body {
|
||||
@include fade-in;
|
||||
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 550px;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.AdvancedTextbox {
|
||||
width: 390px;
|
||||
|
||||
#PreviewInputTextButton {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
#channel_banner_banner_text_textbox {
|
||||
min-height: 40px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.textbox-preview-area {
|
||||
height: max-content !important;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
.markdown__heading {
|
||||
overflow: hidden;
|
||||
margin: 2px;
|
||||
font-size: 18px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.setting_section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
|
||||
.color-input.input-group {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
span.setting_title {
|
||||
flex: 1;
|
||||
padding-top: 12px;
|
||||
color: rgb(var(--center-channel-color-rgb));
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting_body {
|
||||
flex: 4;
|
||||
}
|
||||
|
||||
.AdvancedTextbox {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act, screen} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import ChannelSettingsConfigurationTab from './channel_settings_configuration_tab';
|
||||
|
||||
// Mock the redux actions and selectors
|
||||
jest.mock('mattermost-redux/actions/channels', () => ({
|
||||
patchChannel: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the ShowFormat component to make it easier to test
|
||||
jest.mock('components/advanced_text_editor/show_formatting/show_formatting', () => (
|
||||
jest.fn().mockImplementation((props) => (
|
||||
<button
|
||||
data-testid='mock-show-format'
|
||||
onClick={props.onClick}
|
||||
className={props.active ? 'active' : ''}
|
||||
>
|
||||
{'Toggle Preview'}
|
||||
</button>
|
||||
))
|
||||
));
|
||||
|
||||
// Create a mock channel for testing
|
||||
const mockChannel = TestHelper.getChannelMock({
|
||||
id: 'channel1',
|
||||
team_id: 'team1',
|
||||
display_name: 'Test Channel',
|
||||
name: 'test-channel',
|
||||
purpose: 'Testing purpose',
|
||||
header: 'Initial header',
|
||||
type: 'O',
|
||||
banner_info: {
|
||||
enabled: false,
|
||||
text: '',
|
||||
background_color: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a mock channel with banner enabled
|
||||
const mockChannelWithBanner = TestHelper.getChannelMock({
|
||||
id: 'channel1',
|
||||
team_id: 'team1',
|
||||
display_name: 'Test Channel',
|
||||
name: 'test-channel',
|
||||
purpose: 'Testing purpose',
|
||||
header: 'Initial header',
|
||||
type: 'O',
|
||||
banner_info: {
|
||||
enabled: true,
|
||||
text: 'Test banner text',
|
||||
background_color: '#ff0000',
|
||||
},
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
channel: mockChannel,
|
||||
setAreThereUnsavedChanges: jest.fn(),
|
||||
};
|
||||
|
||||
describe('ChannelSettingsConfigurationTab', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with the correct initial values when banner is disabled', () => {
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
|
||||
|
||||
// Check that the toggle is not enabled
|
||||
const toggle = screen.getByTestId('channelBannerToggle-button');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).not.toHaveClass('active');
|
||||
|
||||
// Banner text and color inputs should not be visible when banner is disabled
|
||||
expect(screen.queryByTestId('channel_banner_banner_text_textbox')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('channel_banner_banner_background_color_picker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with the correct initial values when banner is enabled', () => {
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...{...baseProps, channel: mockChannelWithBanner}}/>);
|
||||
|
||||
// Check that the toggle is enabled
|
||||
const toggle = screen.getByTestId('channelBannerToggle-button');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveClass('active');
|
||||
|
||||
// Banner text and color inputs should be visible when banner is enabled
|
||||
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toHaveValue('Test banner text');
|
||||
|
||||
// Check that the color picker has the correct value
|
||||
expect(screen.getByTestId('color-inputColorValue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show banner settings when toggle is clicked', async () => {
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
|
||||
|
||||
// Initially, banner settings should not be visible
|
||||
expect(screen.queryByTestId('channel_banner_banner_text_textbox')).not.toBeInTheDocument();
|
||||
|
||||
// Click the toggle to enable the banner
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
|
||||
});
|
||||
|
||||
// Banner settings should now be visible
|
||||
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('color-inputColorValue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show SaveChangesPanel when changes are made', async () => {
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
|
||||
|
||||
// Initially, SaveChangesPanel should not be visible
|
||||
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
|
||||
|
||||
// Enable the banner
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// SaveChangesPanel should now be visible
|
||||
expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call patchChannel with updated values when Save is clicked', async () => {
|
||||
const {patchChannel} = require('mattermost-redux/actions/channels');
|
||||
patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
|
||||
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
|
||||
|
||||
// Enable the banner
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
|
||||
});
|
||||
|
||||
// Enter banner text
|
||||
await act(async () => {
|
||||
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
|
||||
await userEvent.clear(textInput);
|
||||
await userEvent.type(textInput, 'New banner text');
|
||||
});
|
||||
|
||||
// Set banner color
|
||||
const colorInput = screen.getByTestId('color-inputColorValue');
|
||||
await act(async () => {
|
||||
await userEvent.clear(colorInput);
|
||||
await userEvent.type(colorInput, '#AA00AA');
|
||||
});
|
||||
|
||||
// Click the Save button
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
|
||||
});
|
||||
|
||||
// Verify patchChannel was called with the updated values
|
||||
expect(patchChannel).toHaveBeenCalledWith('channel1', {
|
||||
...mockChannel,
|
||||
banner_info: {
|
||||
enabled: true,
|
||||
text: 'New banner text',
|
||||
background_color: expect.any(String), // The exact color might be hard to test due to the mock
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset form when Reset button is clicked', async () => {
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...{...baseProps, channel: mockChannelWithBanner}}/>);
|
||||
|
||||
// Change the banner text
|
||||
await act(async () => {
|
||||
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
|
||||
await userEvent.clear(textInput);
|
||||
await userEvent.type(textInput, 'Changed banner text');
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// SaveChangesPanel should now be visible
|
||||
expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
|
||||
|
||||
// Click the Reset button
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Reset'}));
|
||||
});
|
||||
|
||||
// Form should be reset to original values
|
||||
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toHaveValue('Test banner text');
|
||||
|
||||
// SaveChangesPanel should be hidden after reset
|
||||
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error when banner text is empty but banner is enabled', async () => {
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
|
||||
|
||||
// Enable the banner
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
|
||||
});
|
||||
|
||||
// Leave banner text empty
|
||||
await act(async () => {
|
||||
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
|
||||
await userEvent.clear(textInput);
|
||||
});
|
||||
|
||||
// Click the Save button
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
|
||||
});
|
||||
|
||||
// SaveChangesPanel should show error state
|
||||
const errorMessage = screen.getByText(/Banner text is required/);
|
||||
const errorPanel = errorMessage.closest('.SaveChangesPanel');
|
||||
expect(errorPanel).toHaveClass('error');
|
||||
});
|
||||
|
||||
it('should show error when banner text exceeds character limit', async () => {
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
|
||||
|
||||
// Enable the banner
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
|
||||
});
|
||||
|
||||
// Create a string that exceeds the allowed character limit
|
||||
const longText = 'a'.repeat(1025);
|
||||
|
||||
// Enter long banner text
|
||||
await act(async () => {
|
||||
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
|
||||
await userEvent.clear(textInput);
|
||||
await userEvent.type(textInput, longText);
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// SaveChangesPanel should show error state
|
||||
const errorMessage = screen.getByText(/There are errors in the form above/);
|
||||
const errorPanel = errorMessage.closest('.SaveChangesPanel');
|
||||
expect(errorPanel).toHaveClass('error');
|
||||
});
|
||||
|
||||
it('should toggle preview when preview button is clicked', async () => {
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...{...baseProps, channel: mockChannelWithBanner}}/>);
|
||||
|
||||
// Initially, preview should not be active
|
||||
const previewButton = screen.getByTestId('mock-show-format');
|
||||
expect(previewButton).not.toHaveClass('active');
|
||||
|
||||
// Click the preview button
|
||||
await act(async () => {
|
||||
await userEvent.click(previewButton);
|
||||
});
|
||||
|
||||
// Preview should now be active
|
||||
expect(previewButton).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should disable banner when toggle is clicked while banner is enabled', async () => {
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...{...baseProps, channel: mockChannelWithBanner}}/>);
|
||||
|
||||
// Initially, banner settings should be visible
|
||||
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toBeInTheDocument();
|
||||
|
||||
// Click the toggle to disable the banner
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
|
||||
});
|
||||
|
||||
// Banner settings should now be hidden
|
||||
expect(screen.queryByTestId('channel_banner_banner_text_textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error when banner color is empty but banner is enabled', async () => {
|
||||
const {patchChannel} = require('mattermost-redux/actions/channels');
|
||||
patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
|
||||
|
||||
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
|
||||
|
||||
// Enable the banner
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
|
||||
});
|
||||
|
||||
// Enter banner text but leave color empty
|
||||
await act(async () => {
|
||||
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
|
||||
await userEvent.clear(textInput);
|
||||
await userEvent.type(textInput, 'New banner text');
|
||||
});
|
||||
|
||||
// Click the Save button
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
|
||||
});
|
||||
|
||||
// SaveChangesPanel should show error state
|
||||
const errorMessage = screen.getByText(/Banner color is required/);
|
||||
const errorPanel = errorMessage.closest('.SaveChangesPanel');
|
||||
expect(errorPanel).toHaveClass('error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
|
||||
import {patchChannel} from 'mattermost-redux/actions/channels';
|
||||
|
||||
import ColorInput from 'components/color_input';
|
||||
import type {TextboxElement} from 'components/textbox';
|
||||
import Toggle from 'components/toggle';
|
||||
import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox';
|
||||
import type {SaveChangesPanelState} from 'components/widgets/modals/components/save_changes_panel';
|
||||
import SaveChangesPanel from 'components/widgets/modals/components/save_changes_panel';
|
||||
|
||||
import './channel_settings_configuration_tab.scss';
|
||||
|
||||
const CHANNEL_BANNER_MAX_CHARACTER_LIMIT = 1024;
|
||||
const CHANNEL_BANNER_MIN_CHARACTER_LIMIT = 0;
|
||||
|
||||
const DEFAULT_CHANNEL_BANNER = {
|
||||
enabled: false,
|
||||
background_color: '',
|
||||
text: '',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
channel: Channel;
|
||||
setAreThereUnsavedChanges?: (unsaved: boolean) => void;
|
||||
showTabSwitchError?: boolean;
|
||||
}
|
||||
|
||||
function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, showTabSwitchError}: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const heading = formatMessage({id: 'channel_banner.label.name', defaultMessage: 'Channel Banner'});
|
||||
const subHeading = formatMessage({id: 'channel_banner.label.subtext', defaultMessage: 'When enabled, a customized banner will display at the top of the channel.'});
|
||||
const bannerTextSettingTitle = formatMessage({id: 'channel_banner.banner_text.label', defaultMessage: 'Banner text'});
|
||||
const bannerColorSettingTitle = formatMessage({id: 'channel_banner.banner_color.label', defaultMessage: 'Banner color'});
|
||||
const bannerTextPlaceholder = formatMessage({id: 'channel_banner.banner_text.placeholder', defaultMessage: 'Channel banner text'});
|
||||
|
||||
const initialBannerInfo = channel.banner_info || DEFAULT_CHANNEL_BANNER;
|
||||
|
||||
const [formError, setFormError] = useState('');
|
||||
const [showBannerTextPreview, setShowBannerTextPreview] = useState(false);
|
||||
const [updatedChannelBanner, setUpdatedChannelBanner] = useState(initialBannerInfo);
|
||||
|
||||
const [requireConfirm, setRequireConfirm] = useState(false);
|
||||
const [characterLimitExceeded, setCharacterLimitExceeded] = useState(false);
|
||||
const [saveChangesPanelState, setSaveChangesPanelState] = useState<SaveChangesPanelState>();
|
||||
|
||||
// Change handlers
|
||||
const handleToggle = useCallback(() => {
|
||||
const newValue = !updatedChannelBanner.enabled;
|
||||
const toUpdate = {
|
||||
...updatedChannelBanner,
|
||||
enabled: newValue,
|
||||
};
|
||||
if (!newValue) {
|
||||
toUpdate.text = initialBannerInfo.text;
|
||||
toUpdate.background_color = initialBannerInfo.background_color;
|
||||
}
|
||||
|
||||
setUpdatedChannelBanner(toUpdate);
|
||||
}, [initialBannerInfo, updatedChannelBanner]);
|
||||
|
||||
const handleTextChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
|
||||
setUpdatedChannelBanner({
|
||||
...updatedChannelBanner,
|
||||
text: e.target.value,
|
||||
});
|
||||
|
||||
if (e.target.value.trim().length > CHANNEL_BANNER_MAX_CHARACTER_LIMIT) {
|
||||
setFormError(formatMessage({
|
||||
id: 'channel_settings.save_changes_panel.standard_error',
|
||||
defaultMessage: 'There are errors in the form above',
|
||||
}));
|
||||
setCharacterLimitExceeded(true);
|
||||
} else if (e.target.value.trim().length <= CHANNEL_BANNER_MIN_CHARACTER_LIMIT) {
|
||||
setFormError(formatMessage({
|
||||
id: 'channel_settings.save_changes_panel.banner_text.required_error',
|
||||
defaultMessage: 'Channel banner text cannot be empty when enabled',
|
||||
}));
|
||||
setCharacterLimitExceeded(true);
|
||||
} else {
|
||||
setFormError('');
|
||||
setCharacterLimitExceeded(false);
|
||||
}
|
||||
}, [formatMessage, updatedChannelBanner]);
|
||||
|
||||
const handleColorChange = useCallback((color: string) => {
|
||||
setUpdatedChannelBanner({
|
||||
...updatedChannelBanner,
|
||||
background_color: color,
|
||||
});
|
||||
}, [updatedChannelBanner]);
|
||||
|
||||
const toggleTextPreview = useCallback(() => setShowBannerTextPreview((show) => !show), []);
|
||||
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
return updatedChannelBanner.text !== initialBannerInfo?.text ||
|
||||
updatedChannelBanner.background_color !== initialBannerInfo?.background_color ||
|
||||
updatedChannelBanner.enabled !== initialBannerInfo?.enabled;
|
||||
}, [initialBannerInfo, updatedChannelBanner]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsavedChanges = hasUnsavedChanges();
|
||||
setRequireConfirm(unsavedChanges);
|
||||
setAreThereUnsavedChanges?.(unsavedChanges);
|
||||
}, [hasUnsavedChanges, setAreThereUnsavedChanges]);
|
||||
|
||||
const handleServerError = useCallback((err: ServerError) => {
|
||||
const errorMsg = err.message || formatMessage({id: 'channel_settings.unknown_error', defaultMessage: 'Something went wrong.'});
|
||||
setFormError(errorMsg);
|
||||
}, [formatMessage]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<boolean> => {
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (updatedChannelBanner.enabled && !updatedChannelBanner.text?.trim()) {
|
||||
setFormError(formatMessage({
|
||||
id: 'channel_settings.error_banner_text_required',
|
||||
defaultMessage: 'Banner text is required',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (updatedChannelBanner.enabled && !updatedChannelBanner.background_color?.trim()) {
|
||||
setFormError(formatMessage({
|
||||
id: 'channel_settings.error_banner_color_required',
|
||||
defaultMessage: 'Banner color is required',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated: Channel = {
|
||||
...channel,
|
||||
};
|
||||
|
||||
updated.banner_info = {
|
||||
text: updatedChannelBanner.text,
|
||||
background_color: updatedChannelBanner.background_color,
|
||||
enabled: updatedChannelBanner.enabled,
|
||||
};
|
||||
|
||||
const {error} = await dispatch(patchChannel(channel.id, updated));
|
||||
if (error) {
|
||||
handleServerError(error as ServerError);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [channel, dispatch, formatMessage, handleServerError, updatedChannelBanner]);
|
||||
|
||||
const handleSaveChanges = useCallback(async () => {
|
||||
const success = await handleSave();
|
||||
if (!success) {
|
||||
setSaveChangesPanelState('error');
|
||||
return;
|
||||
}
|
||||
setSaveChangesPanelState('saved');
|
||||
}, [handleSave]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setRequireConfirm(false);
|
||||
setSaveChangesPanelState(undefined);
|
||||
setShowBannerTextPreview(false);
|
||||
|
||||
setUpdatedChannelBanner(initialBannerInfo);
|
||||
setFormError('');
|
||||
setCharacterLimitExceeded(false);
|
||||
}, [initialBannerInfo]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSaveChangesPanelState(undefined);
|
||||
setRequireConfirm(false);
|
||||
}, []);
|
||||
|
||||
const hasErrors = Boolean(formError) ||
|
||||
characterLimitExceeded ||
|
||||
showTabSwitchError;
|
||||
|
||||
return (
|
||||
<div className='ChannelSettingsModal__configurationTab'>
|
||||
<div className='channel_banner_header'>
|
||||
<div className='channel_banner_header__text'>
|
||||
<label
|
||||
className='Input_legend'
|
||||
aria-label={heading}
|
||||
>
|
||||
{heading}
|
||||
</label>
|
||||
<label
|
||||
className='Input_subheading'
|
||||
aria-label={heading}
|
||||
>
|
||||
{subHeading}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='channel_banner_header__toggle'>
|
||||
<Toggle
|
||||
id='channelBannerToggle'
|
||||
ariaLabel={heading}
|
||||
size='btn-md'
|
||||
disabled={false}
|
||||
onToggle={handleToggle}
|
||||
toggled={updatedChannelBanner.enabled}
|
||||
tabIndex={0}
|
||||
toggleClassName='btn-toggle-primary'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
updatedChannelBanner.enabled &&
|
||||
<div className='channel_banner_section_body'>
|
||||
{/*Banner text section*/}
|
||||
<div className='setting_section'>
|
||||
<span
|
||||
className='setting_title'
|
||||
aria-label={bannerTextSettingTitle}
|
||||
>
|
||||
{bannerTextSettingTitle}
|
||||
</span>
|
||||
|
||||
<div className='setting_body'>
|
||||
<AdvancedTextbox
|
||||
id='channel_banner_banner_text_textbox'
|
||||
value={updatedChannelBanner.text!}
|
||||
channelId={channel.id}
|
||||
onKeyPress={() => {}}
|
||||
showCharacterCount={true}
|
||||
useChannelMentions={false}
|
||||
onChange={handleTextChange}
|
||||
preview={showBannerTextPreview}
|
||||
togglePreview={toggleTextPreview}
|
||||
hasError={characterLimitExceeded}
|
||||
createMessage={bannerTextPlaceholder}
|
||||
maxLength={CHANNEL_BANNER_MAX_CHARACTER_LIMIT}
|
||||
minLength={CHANNEL_BANNER_MIN_CHARACTER_LIMIT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*Banner background color section*/}
|
||||
<div className='setting_section'>
|
||||
<span
|
||||
className='setting_title'
|
||||
aria-label={bannerColorSettingTitle}
|
||||
>
|
||||
{bannerColorSettingTitle}
|
||||
</span>
|
||||
|
||||
<div className='setting_body'>
|
||||
<ColorInput
|
||||
id='channel_banner_banner_background_color_picker'
|
||||
onChange={handleColorChange}
|
||||
value={updatedChannelBanner.background_color || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{requireConfirm && (
|
||||
<SaveChangesPanel
|
||||
handleSubmit={handleSaveChanges}
|
||||
handleCancel={handleCancel}
|
||||
handleClose={handleClose}
|
||||
tabChangeError={hasErrors}
|
||||
state={hasErrors ? 'error' : saveChangesPanelState}
|
||||
customErrorMessage={formError}
|
||||
cancelButtonText={formatMessage({
|
||||
id: 'channel_settings.save_changes_panel.reset',
|
||||
defaultMessage: 'Reset',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelSettingsConfigurationTab;
|
||||
@@ -0,0 +1,534 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act, screen} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import type {ChannelType} from '@mattermost/types/channels';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import ChannelSettingsInfoTab from './channel_settings_info_tab';
|
||||
|
||||
// Mock the redux actions and selectors
|
||||
jest.mock('mattermost-redux/actions/channels', () => ({
|
||||
patchChannel: jest.fn(),
|
||||
updateChannelPrivacy: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the ConvertConfirmModal component
|
||||
jest.mock('components/admin_console/team_channel_settings/convert_confirm_modal', () => {
|
||||
return jest.fn().mockImplementation(({show, onCancel, onConfirm, displayName}) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div data-testid='convert-confirm-modal'>
|
||||
<div>{'Converting '}{displayName}{' to private'}</div>
|
||||
<button onClick={onCancel}>{'Cancel'}</button>
|
||||
<button onClick={onConfirm}>{'Yes, Convert Channel'}</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
let mockChannelPropertiesPermission = true;
|
||||
let mockConvertToPublicPermission = true;
|
||||
let mockConvertToPrivatePermission = true;
|
||||
|
||||
jest.mock('mattermost-redux/selectors/entities/roles', () => ({
|
||||
haveITeamPermission: jest.fn().mockReturnValue(true),
|
||||
haveIChannelPermission: jest.fn().mockImplementation((state, teamId, channelId, permission: string) => {
|
||||
if (permission === 'manage_private_channel_properties' || permission === 'manage_public_channel_properties') {
|
||||
return mockChannelPropertiesPermission;
|
||||
}
|
||||
if (permission === 'convert_public_channel_to_private') {
|
||||
return mockConvertToPrivatePermission;
|
||||
}
|
||||
if (permission === 'convert_private_channel_to_public') {
|
||||
return mockConvertToPublicPermission;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
getRoles: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('selectors/views/textbox', () => ({
|
||||
showPreviewOnChannelSettingsHeaderModal: jest.fn().mockReturnValue(false),
|
||||
showPreviewOnChannelSettingsPurposeModal: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('actions/views/textbox', () => ({
|
||||
setShowPreviewOnChannelSettingsHeaderModal: jest.fn(),
|
||||
setShowPreviewOnChannelSettingsPurposeModal: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the isChannelAdmin function
|
||||
jest.mock('mattermost-redux/utils/user_utils', () => {
|
||||
const original = jest.requireActual('mattermost-redux/utils/user_utils');
|
||||
return {
|
||||
...original,
|
||||
isChannelAdmin: jest.fn().mockReturnValue(false),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the ShowFormat component to make it easier to test
|
||||
jest.mock('components/advanced_text_editor/show_formatting/show_formatting', () => (
|
||||
jest.fn().mockImplementation((props) => (
|
||||
<button
|
||||
data-testid='mock-show-format'
|
||||
onClick={props.onClick}
|
||||
className={props.active ? 'active' : ''}
|
||||
>
|
||||
{'Toggle Preview'}
|
||||
</button>
|
||||
))
|
||||
));
|
||||
|
||||
// Create a mock channel member
|
||||
const mockChannelMember = TestHelper.getChannelMembershipMock({
|
||||
roles: 'channel_user system_admin',
|
||||
});
|
||||
|
||||
// Mock the current user
|
||||
const mockUser = TestHelper.getUserMock({
|
||||
id: 'user_id',
|
||||
roles: 'system_admin',
|
||||
});
|
||||
|
||||
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
|
||||
...jest.requireActual('mattermost-redux/selectors/entities/channels') as typeof import('mattermost-redux/selectors/entities/channels'),
|
||||
getChannelMember: jest.fn(() => mockChannelMember),
|
||||
}));
|
||||
|
||||
jest.mock('mattermost-redux/selectors/entities/common', () => {
|
||||
return {
|
||||
...jest.requireActual('mattermost-redux/selectors/entities/common') as typeof import('mattermost-redux/selectors/entities/users'),
|
||||
getCurrentUser: () => mockUser,
|
||||
};
|
||||
});
|
||||
|
||||
// Create a mock channel for testing
|
||||
const mockChannel = TestHelper.getChannelMock({
|
||||
id: 'channel1',
|
||||
team_id: 'team1',
|
||||
display_name: 'Test Channel',
|
||||
name: 'test-channel',
|
||||
purpose: 'Testing purpose',
|
||||
header: 'Initial header',
|
||||
type: 'O',
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
channel: mockChannel,
|
||||
setAreThereUnsavedChanges: jest.fn(),
|
||||
};
|
||||
|
||||
describe('ChannelSettingsInfoTab', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockChannelPropertiesPermission = true;
|
||||
mockConvertToPublicPermission = true;
|
||||
mockConvertToPrivatePermission = true;
|
||||
});
|
||||
|
||||
it('should render with the correct initial values', () => {
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Check that the channel name field has the correct value.
|
||||
expect(screen.getByRole('textbox', {name: 'Channel name'})).toHaveValue('Test Channel');
|
||||
|
||||
// Check that the purpose field has the correct value.
|
||||
expect(screen.getByTestId('channel_settings_purpose_textbox')).toHaveValue('Testing purpose');
|
||||
|
||||
// Check that the header field has the correct value.
|
||||
expect(screen.getByTestId('channel_settings_header_textbox')).toHaveValue('Initial header');
|
||||
|
||||
// Check that the public channel button is selected.
|
||||
expect(screen.getByRole('button', {name: /Public Channel/}).classList.contains('selected')).toBe(true);
|
||||
});
|
||||
|
||||
it('should show SaveChangesPanel when changes are made', async () => {
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Initially, SaveChangesPanel should not be visible.
|
||||
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
|
||||
|
||||
// Wrap the interaction in act to handle state updates properly
|
||||
await act(async () => {
|
||||
// Change the channel name.
|
||||
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'Updated Channel Name');
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// SaveChangesPanel should now be visible.
|
||||
expect(screen.queryByRole('button', {name: 'Save'})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call patchChannel with updated values when Save is clicked (non-privacy changes)', async () => {
|
||||
const {patchChannel} = require('mattermost-redux/actions/channels');
|
||||
patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Wrap all user interactions in act to handle state updates properly
|
||||
await act(async () => {
|
||||
// Change the channel name.
|
||||
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'Updated Channel Name');
|
||||
|
||||
// Change the channel purpose.
|
||||
const purposeInput = screen.getByTestId('channel_settings_purpose_textbox');
|
||||
await userEvent.clear(purposeInput);
|
||||
await userEvent.type(purposeInput, 'Updated purpose');
|
||||
|
||||
// Change the channel header.
|
||||
const headerInput = screen.getByTestId('channel_settings_header_textbox');
|
||||
await userEvent.clear(headerInput);
|
||||
await userEvent.type(headerInput, 'Updated header');
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Click the Save button in the SaveChangesPanel.
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
|
||||
});
|
||||
|
||||
// Verify patchChannel was called with the updated values (without type change).
|
||||
expect(patchChannel).toHaveBeenCalledWith('channel1', {
|
||||
...mockChannel,
|
||||
display_name: 'Updated Channel Name',
|
||||
name: 'updated-channel-name',
|
||||
purpose: 'Updated purpose',
|
||||
header: 'Updated header',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset form when Reset button is clicked', async () => {
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Wrap the interaction in act to handle state updates properly
|
||||
await act(async () => {
|
||||
// Change the channel name.
|
||||
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'Updated Channel Name');
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// SaveChangesPanel should now be visible.
|
||||
expect(screen.queryByRole('button', {name: 'Save'})).toBeInTheDocument();
|
||||
|
||||
// Click the Reset button.
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Reset'}));
|
||||
});
|
||||
|
||||
// Form should be reset to original values.
|
||||
expect(screen.getByRole('textbox', {name: 'Channel name'})).toHaveValue('Test Channel');
|
||||
|
||||
// SaveChangesPanel should be hidden after reset.
|
||||
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error state when save fails', async () => {
|
||||
const {patchChannel} = require('mattermost-redux/actions/channels');
|
||||
patchChannel.mockReturnValue({type: 'MOCK_ACTION', error: {message: 'Error saving channel'}});
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Wrap the interaction in act to handle state updates properly
|
||||
await act(async () => {
|
||||
// Change the channel name.
|
||||
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'Updated Channel Name');
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Click the Save button.
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
|
||||
});
|
||||
|
||||
// SaveChangesPanel should show 'error' state.
|
||||
const errorMessage = screen.getByText(/There are errors in the form above/);
|
||||
const errorPanel = errorMessage.closest('.SaveChangesPanel');
|
||||
expect(errorPanel).toHaveClass('error');
|
||||
});
|
||||
|
||||
// Instead of clicking a non-existent element to trigger a channel name error,
|
||||
// simulate an invalid input by clearing the channel name (which is required).
|
||||
it('should show error when channel name field has an error', async () => {
|
||||
renderWithContext(
|
||||
<ChannelSettingsInfoTab
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wrap the interaction in act to handle state updates properly
|
||||
await act(async () => {
|
||||
// Clear the channel name to simulate an error.
|
||||
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'Updated Channel Name');
|
||||
await userEvent.clear(nameInput);
|
||||
nameInput.blur();
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// SaveChangesPanel should show error state.
|
||||
const errorMessage = screen.getByText(/There are errors in the form above/);
|
||||
const errorPanel = errorMessage.closest('.SaveChangesPanel');
|
||||
expect(errorPanel).toHaveClass('error');
|
||||
});
|
||||
|
||||
it('should show error when purpose exceeds character limit', async () => {
|
||||
renderWithContext(
|
||||
<ChannelSettingsInfoTab
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Create a string that exceeds the allowed character limit
|
||||
const longPurpose = 'a'.repeat(1025);
|
||||
|
||||
// Wrap the interaction in act to handle state updates properly
|
||||
await act(async () => {
|
||||
const purposeInput = screen.getByTestId('channel_settings_purpose_textbox');
|
||||
await userEvent.clear(purposeInput);
|
||||
await userEvent.type(purposeInput, longPurpose);
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// SaveChangesPanel should show error state.
|
||||
const errorMessage = screen.getByText(/There are errors in the form above/);
|
||||
const errorPanel = errorMessage.closest('.SaveChangesPanel');
|
||||
expect(errorPanel).toHaveClass('error');
|
||||
});
|
||||
|
||||
it('should show error when header exceeds character limit', async () => {
|
||||
renderWithContext(
|
||||
<ChannelSettingsInfoTab
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Create a string that exceeds the header character limit.
|
||||
const longHeader = 'a'.repeat(1025);
|
||||
|
||||
// Wrap the interaction in act to handle state updates properly
|
||||
await act(async () => {
|
||||
const headerInput = screen.getByTestId('channel_settings_header_textbox');
|
||||
await userEvent.clear(headerInput);
|
||||
await userEvent.type(headerInput, longHeader);
|
||||
});
|
||||
|
||||
// Add a small delay to ensure all state updates are processed
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// SaveChangesPanel should show error state.
|
||||
const errorMessage = screen.getByText(/There are errors in the form above/);
|
||||
const errorPanel = errorMessage.closest('.SaveChangesPanel');
|
||||
expect(errorPanel).toHaveClass('error');
|
||||
});
|
||||
|
||||
it('should render ChannelNameFormField and AdvancedTextbox as readOnly when user does not have permission', () => {
|
||||
mockChannelPropertiesPermission = false;
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Check that the name input is disabled
|
||||
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
|
||||
expect(nameInput).toBeDisabled();
|
||||
|
||||
// When in readOnly mode, the preview toggle button should not be present
|
||||
expect(screen.queryByTestId('mock-show-format')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render ChannelNameFormField and AdvancedTextbox as not readOnly when user has permission', () => {
|
||||
mockChannelPropertiesPermission = true;
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Check that the name input is not disabled
|
||||
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
|
||||
expect(nameInput).not.toBeDisabled();
|
||||
|
||||
// When not in readOnly mode, at least one preview toggle button should be present
|
||||
const previewButtons = screen.queryAllByTestId('mock-show-format');
|
||||
expect(previewButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not allow channel type change when user lacks permissions', async () => {
|
||||
// Set permissions to false
|
||||
mockConvertToPublicPermission = false;
|
||||
mockConvertToPrivatePermission = false;
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Private channel button should be disabled
|
||||
const privateButton = screen.getByRole('button', {name: /Private Channel/});
|
||||
expect(privateButton).toHaveClass('disabled');
|
||||
});
|
||||
|
||||
it('should allow channel type change UI when user has permission to convert to private', async () => {
|
||||
// Set convert permission to true
|
||||
mockConvertToPrivatePermission = true;
|
||||
mockConvertToPublicPermission = true;
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Private channel button should not be disabled
|
||||
const privateButton = screen.getByRole('button', {name: /Private Channel/});
|
||||
expect(privateButton).not.toBeDisabled();
|
||||
|
||||
// Should be able to change to private in the UI
|
||||
await userEvent.click(privateButton);
|
||||
|
||||
// Verify the private button is now selected
|
||||
expect(privateButton).toHaveClass('selected');
|
||||
});
|
||||
|
||||
it('should never allow conversion from private to public', async () => {
|
||||
// Set convert permission to true (even with permission, it should be prevented)
|
||||
mockConvertToPublicPermission = true;
|
||||
|
||||
// Start with a private channel
|
||||
const privateChannel = {
|
||||
...mockChannel,
|
||||
type: 'P' as ChannelType,
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<ChannelSettingsInfoTab
|
||||
{...baseProps}
|
||||
channel={privateChannel}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Public channel button should be disabled regardless of permissions
|
||||
const publicButton = screen.getByRole('button', {name: /Public Channel/});
|
||||
expect(publicButton).toHaveClass('disabled');
|
||||
|
||||
// Private button should be selected
|
||||
const privateButton = screen.getByRole('button', {name: /Private Channel/});
|
||||
expect(privateButton).toHaveClass('selected');
|
||||
});
|
||||
|
||||
it('should show ConvertConfirmModal when converting from public to private', async () => {
|
||||
mockConvertToPrivatePermission = true;
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Change to private channel
|
||||
const privateButton = screen.getByRole('button', {name: /Private Channel/});
|
||||
await userEvent.click(privateButton);
|
||||
|
||||
// Click Save button
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
|
||||
});
|
||||
|
||||
// Verify the modal is shown
|
||||
expect(screen.getByTestId('convert-confirm-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should convert channel when confirming in ConvertConfirmModal', async () => {
|
||||
mockConvertToPrivatePermission = true;
|
||||
|
||||
const {updateChannelPrivacy} = require('mattermost-redux/actions/channels');
|
||||
updateChannelPrivacy.mockReturnValue({type: 'MOCK_ACTION', data: {}});
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Change to private channel
|
||||
const privateButton = screen.getByRole('button', {name: /Private Channel/});
|
||||
await userEvent.click(privateButton);
|
||||
|
||||
// Click Save button to show modal
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
|
||||
});
|
||||
|
||||
// Click confirm button in modal
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByText(/Yes, Convert Channel/i));
|
||||
});
|
||||
|
||||
// Verify updateChannelPrivacy was called
|
||||
expect(updateChannelPrivacy).toHaveBeenCalledWith('channel1', 'P');
|
||||
});
|
||||
|
||||
it('should not convert channel when canceling in ConvertConfirmModal', async () => {
|
||||
mockConvertToPrivatePermission = true;
|
||||
|
||||
const {updateChannelPrivacy} = require('mattermost-redux/actions/channels');
|
||||
updateChannelPrivacy.mockReturnValue({type: 'MOCK_ACTION', data: {}});
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Change to private channel
|
||||
const privateButton = screen.getByRole('button', {name: /Private Channel/});
|
||||
await userEvent.click(privateButton);
|
||||
|
||||
// Click Save button to show modal
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
|
||||
});
|
||||
|
||||
// Click cancel button in modal
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByText(/Cancel/i));
|
||||
});
|
||||
|
||||
// Verify updateChannelPrivacy was not called
|
||||
expect(updateChannelPrivacy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors when converting channel privacy', async () => {
|
||||
mockConvertToPrivatePermission = true;
|
||||
|
||||
const {updateChannelPrivacy} = require('mattermost-redux/actions/channels');
|
||||
updateChannelPrivacy.mockReturnValue({
|
||||
type: 'MOCK_ACTION',
|
||||
error: {message: 'Error changing privacy'},
|
||||
});
|
||||
|
||||
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
|
||||
|
||||
// Change to private channel
|
||||
const privateButton = screen.getByRole('button', {name: /Private Channel/});
|
||||
await userEvent.click(privateButton);
|
||||
|
||||
// Click Save button to show modal
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
|
||||
});
|
||||
|
||||
// Click confirm button in modal
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByText(/Yes, Convert Channel/i));
|
||||
});
|
||||
|
||||
// Verify error state is shown
|
||||
expect(screen.getByText(/There are errors in the form above/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useState, useEffect, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {Channel, ChannelType} from '@mattermost/types/channels';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
|
||||
import {patchChannel, updateChannelPrivacy} from 'mattermost-redux/actions/channels';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import Permissions from 'mattermost-redux/constants/permissions';
|
||||
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
|
||||
|
||||
import {
|
||||
setShowPreviewOnChannelSettingsHeaderModal,
|
||||
setShowPreviewOnChannelSettingsPurposeModal,
|
||||
} from 'actions/views/textbox';
|
||||
import {
|
||||
showPreviewOnChannelSettingsHeaderModal,
|
||||
showPreviewOnChannelSettingsPurposeModal,
|
||||
} from 'selectors/views/textbox';
|
||||
|
||||
import ConvertConfirmModal from 'components/admin_console/team_channel_settings/convert_confirm_modal';
|
||||
import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field';
|
||||
import type {TextboxElement} from 'components/textbox';
|
||||
import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox';
|
||||
import SaveChangesPanel, {type SaveChangesPanelState} from 'components/widgets/modals/components/save_changes_panel';
|
||||
import PublicPrivateSelector from 'components/widgets/public-private-selector/public-private-selector';
|
||||
|
||||
import Constants from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
type ChannelSettingsInfoTabProps = {
|
||||
channel: Channel;
|
||||
onCancel?: () => void;
|
||||
setAreThereUnsavedChanges?: (unsaved: boolean) => void;
|
||||
showTabSwitchError?: boolean;
|
||||
};
|
||||
|
||||
function ChannelSettingsInfoTab({
|
||||
channel,
|
||||
onCancel,
|
||||
setAreThereUnsavedChanges,
|
||||
showTabSwitchError,
|
||||
}: ChannelSettingsInfoTabProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const shouldShowPreviewPurpose = useSelector(showPreviewOnChannelSettingsPurposeModal);
|
||||
const shouldShowPreviewHeader = useSelector(showPreviewOnChannelSettingsHeaderModal);
|
||||
|
||||
// Permissions for transforming channel type
|
||||
const canConvertToPrivate = useSelector((state: GlobalState) =>
|
||||
haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE),
|
||||
);
|
||||
const canConvertToPublic = useSelector((state: GlobalState) =>
|
||||
haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CONVERT_PRIVATE_CHANNEL_TO_PUBLIC),
|
||||
);
|
||||
|
||||
// Permissions for managing channel (name, header, purpose)
|
||||
const channelPropertiesPermission = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
|
||||
const canManageChannelProperties = useSelector((state: GlobalState) =>
|
||||
haveIChannelPermission(state, channel.team_id, channel.id, channelPropertiesPermission),
|
||||
);
|
||||
|
||||
// Constants
|
||||
const HEADER_MAX_LENGTH = 1024;
|
||||
|
||||
// Internal state variables
|
||||
const [internalUrlError, setUrlError] = useState('');
|
||||
const [channelNameError, setChannelNameError] = useState('');
|
||||
const [characterLimitExceeded, setCharacterLimitExceeded] = useState(false);
|
||||
const [showConvertConfirmModal, setShowConvertConfirmModal] = useState(false);
|
||||
|
||||
// Removed switchingTabsWithUnsaved state as we now use the showTabSwitchError prop directly
|
||||
|
||||
// The fields we allow editing
|
||||
const [displayName, setDisplayName] = useState(channel?.display_name ?? '');
|
||||
const [channelUrl, setChannelURL] = useState(channel?.name ?? '');
|
||||
const [channelPurpose, setChannelPurpose] = useState(channel.purpose ?? '');
|
||||
const [channelHeader, setChannelHeader] = useState(channel?.header ?? '');
|
||||
const [channelType, setChannelType] = useState<ChannelType>(channel?.type as ChannelType ?? Constants.OPEN_CHANNEL as ChannelType);
|
||||
|
||||
// UI Feedback: errors, states
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
// SaveChangesPanel state
|
||||
const [saveChangesPanelState, setSaveChangesPanelState] = useState<SaveChangesPanelState>();
|
||||
|
||||
// Handler for channel name validation errors
|
||||
const handleChannelNameError = useCallback((isError: boolean, errorMessage?: string) => {
|
||||
setChannelNameError(errorMessage || '');
|
||||
|
||||
// If there's an error, update the error to show in the SaveChangesPanel
|
||||
if (isError && errorMessage) {
|
||||
setFormError(errorMessage);
|
||||
} else if (formError === channelNameError) {
|
||||
// Only clear error if it's the same as the channel name error
|
||||
setFormError('');
|
||||
}
|
||||
}, [channelNameError, formError, setFormError]);
|
||||
|
||||
// Update parent component when changes occur
|
||||
useEffect(() => {
|
||||
// Calculate unsaved changes directly
|
||||
const unsavedChanges = channel ? (
|
||||
displayName !== channel.display_name ||
|
||||
channelUrl !== channel.name ||
|
||||
channelPurpose !== channel.purpose ||
|
||||
channelHeader !== channel.header ||
|
||||
channelType !== channel.type
|
||||
) : false;
|
||||
|
||||
setAreThereUnsavedChanges?.(unsavedChanges);
|
||||
}, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, setAreThereUnsavedChanges]);
|
||||
|
||||
const handleURLChange = useCallback((newURL: string) => {
|
||||
if (internalUrlError) {
|
||||
setFormError('');
|
||||
setSaveChangesPanelState(undefined);
|
||||
setUrlError('');
|
||||
}
|
||||
setChannelURL(newURL);
|
||||
}, [internalUrlError]);
|
||||
|
||||
const togglePurposePreview = useCallback(() => {
|
||||
dispatch(setShowPreviewOnChannelSettingsPurposeModal(!shouldShowPreviewPurpose));
|
||||
}, [dispatch, shouldShowPreviewPurpose]);
|
||||
|
||||
const toggleHeaderPreview = useCallback(() => {
|
||||
dispatch(setShowPreviewOnChannelSettingsHeaderModal(!shouldShowPreviewHeader));
|
||||
}, [dispatch, shouldShowPreviewHeader]);
|
||||
|
||||
const handleChannelTypeChange = (type: ChannelType) => {
|
||||
// Never allow conversion from private to public, regardless of permissions
|
||||
if (channel.type === Constants.PRIVATE_CHANNEL && type === Constants.OPEN_CHANNEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has permission to convert from public to private
|
||||
if (channel.type === Constants.OPEN_CHANNEL && type === Constants.PRIVATE_CHANNEL && !canConvertToPrivate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChannelType(type);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const handleHeaderChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
// Update the header value
|
||||
setChannelHeader(newValue);
|
||||
|
||||
// Check for character limit
|
||||
if (newValue.length > HEADER_MAX_LENGTH) {
|
||||
setFormError(formatMessage({
|
||||
id: 'edit_channel_header_modal.error',
|
||||
defaultMessage: 'The text entered exceeds the character limit. The channel header is limited to {maxLength} characters.',
|
||||
}, {
|
||||
maxLength: HEADER_MAX_LENGTH,
|
||||
}));
|
||||
} else if (formError && !channelNameError) {
|
||||
// Only clear form error if there's no channel name error
|
||||
// This prevents clearing channel name errors when editing the header
|
||||
setFormError('');
|
||||
}
|
||||
}, [HEADER_MAX_LENGTH, formError, channelNameError, setFormError, formatMessage]);
|
||||
|
||||
const handlePurposeChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
// Update the purpose value
|
||||
setChannelPurpose(newValue);
|
||||
|
||||
// Check for character limit
|
||||
if (newValue.length > Constants.MAX_CHANNELPURPOSE_LENGTH) {
|
||||
setFormError(formatMessage({
|
||||
id: 'channel_settings.error_purpose_length',
|
||||
defaultMessage: 'The text entered exceeds the character limit. The channel purpose is limited to {maxLength} characters.',
|
||||
}, {
|
||||
maxLength: Constants.MAX_CHANNELPURPOSE_LENGTH,
|
||||
}));
|
||||
} else if (formError && !channelNameError) {
|
||||
// Only clear server error if there's no channel name error
|
||||
// This prevents clearing channel name errors when editing the purpose
|
||||
setFormError('');
|
||||
}
|
||||
}, [formError, channelNameError, setFormError, formatMessage]);
|
||||
|
||||
const handleServerError = (err: ServerError) => {
|
||||
const errorMsg = err.message || formatMessage({id: 'channel_settings.unknown_error', defaultMessage: 'Something went wrong.'});
|
||||
setFormError(errorMsg);
|
||||
setSaveChangesPanelState('error');
|
||||
|
||||
// Check if the error is related to a URL conflict
|
||||
if (err.message && (
|
||||
err.message.toLowerCase().includes('url') ||
|
||||
err.message.toLowerCase().includes('name') ||
|
||||
err.message.toLowerCase().includes('already exists')
|
||||
)) {
|
||||
setUrlError(errorMsg); // Set the URL error to show in the URL input
|
||||
}
|
||||
};
|
||||
|
||||
// Validate & Save - using useCallback to ensure it has the latest state values
|
||||
const handleSave = useCallback(async (): Promise<boolean> => {
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!displayName.trim()) {
|
||||
setFormError(formatMessage({
|
||||
id: 'channel_settings.error_display_name_required',
|
||||
defaultMessage: 'Channel name is required',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
// write the code to validate if the channel is changing from public to private
|
||||
if (channel.type === Constants.OPEN_CHANNEL && channelType === Constants.PRIVATE_CHANNEL) {
|
||||
const {error} = await dispatch(updateChannelPrivacy(channel.id, General.PRIVATE_CHANNEL));
|
||||
if (error) {
|
||||
handleServerError(error as ServerError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build updated channel object
|
||||
const updated: Channel = {
|
||||
...channel,
|
||||
display_name: displayName.trim(),
|
||||
name: channelUrl.trim(),
|
||||
purpose: channelPurpose.trim(),
|
||||
header: channelHeader.trim(),
|
||||
};
|
||||
|
||||
const {error} = await dispatch(patchChannel(channel.id, updated));
|
||||
if (error) {
|
||||
handleServerError(error as ServerError);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, setFormError, handleServerError]);
|
||||
|
||||
// Handle save changes panel actions
|
||||
const handleSaveChanges = useCallback(async () => {
|
||||
// Check if privacy is changing from public to private
|
||||
const isPrivacyChanging = channel.type === Constants.OPEN_CHANNEL &&
|
||||
channelType === Constants.PRIVATE_CHANNEL;
|
||||
|
||||
// If privacy is changing, show confirmation modal
|
||||
if (isPrivacyChanging) {
|
||||
setShowConvertConfirmModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise proceed with normal save
|
||||
const success = await handleSave();
|
||||
if (!success) {
|
||||
setSaveChangesPanelState('error');
|
||||
return;
|
||||
}
|
||||
setSaveChangesPanelState('saved');
|
||||
}, [channel, channelType, handleSave]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSaveChangesPanelState(undefined);
|
||||
}, []);
|
||||
|
||||
const hideConvertConfirmModal = useCallback(() => {
|
||||
setShowConvertConfirmModal(false);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
// First, hide the panel immediately to prevent further interactions
|
||||
setSaveChangesPanelState(undefined);
|
||||
|
||||
// Then reset all form fields to their original values
|
||||
setDisplayName(channel?.display_name ?? '');
|
||||
setChannelURL(channel?.name ?? '');
|
||||
setChannelPurpose(channel?.purpose ?? '');
|
||||
setChannelHeader(channel?.header ?? '');
|
||||
setChannelType(channel?.type as ChannelType ?? Constants.OPEN_CHANNEL as ChannelType);
|
||||
|
||||
// Clear errors
|
||||
setUrlError('');
|
||||
setFormError('');
|
||||
setCharacterLimitExceeded(false);
|
||||
setChannelNameError('');
|
||||
|
||||
// If parent provided an onCancel callback, call it
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}, [channel, onCancel, setFormError]);
|
||||
|
||||
// Calculate if there are errors
|
||||
const hasErrors = Boolean(formError) ||
|
||||
characterLimitExceeded ||
|
||||
Boolean(channelNameError) ||
|
||||
Boolean(showTabSwitchError) ||
|
||||
Boolean(internalUrlError);
|
||||
|
||||
// Memoize the calculation for whether to show the save changes panel
|
||||
const shouldShowPanel = useMemo(() => {
|
||||
const unsavedChanges = channel ? (
|
||||
displayName !== channel.display_name ||
|
||||
channelUrl !== channel.name ||
|
||||
channelPurpose !== channel.purpose ||
|
||||
channelHeader !== channel.header ||
|
||||
channelType !== channel.type
|
||||
) : false;
|
||||
|
||||
return unsavedChanges || saveChangesPanelState === 'saved';
|
||||
}, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, saveChangesPanelState]);
|
||||
|
||||
return (
|
||||
<div className='ChannelSettingsModal__infoTab'>
|
||||
{/* ConvertConfirmModal for channel privacy changes */}
|
||||
<ConvertConfirmModal
|
||||
show={showConvertConfirmModal}
|
||||
onCancel={hideConvertConfirmModal}
|
||||
onConfirm={async () => {
|
||||
hideConvertConfirmModal();
|
||||
const success = await handleSave();
|
||||
if (!success) {
|
||||
setSaveChangesPanelState('error');
|
||||
return;
|
||||
}
|
||||
setSaveChangesPanelState('saved');
|
||||
}}
|
||||
displayName={channel?.display_name || ''}
|
||||
toPublic={false} // Always false since we're only converting from public to private
|
||||
/>
|
||||
|
||||
{/* Channel Name Section*/}
|
||||
<label
|
||||
htmlFor='input_channel-settings-name'
|
||||
className='Input_legend'
|
||||
>
|
||||
{formatMessage({id: 'channel_settings.label.name', defaultMessage: 'Channel Name'})}
|
||||
</label>
|
||||
<ChannelNameFormField
|
||||
value={displayName}
|
||||
name='channel-settings-name'
|
||||
placeholder={formatMessage({
|
||||
id: 'channel_settings_modal.name.placeholder',
|
||||
defaultMessage: 'Enter a name for your channel',
|
||||
})}
|
||||
onDisplayNameChange={(name) => {
|
||||
setDisplayName(name);
|
||||
}}
|
||||
onURLChange={handleURLChange}
|
||||
onErrorStateChange={handleChannelNameError}
|
||||
urlError={internalUrlError}
|
||||
currentUrl={channelUrl}
|
||||
readOnly={!canManageChannelProperties}
|
||||
/>
|
||||
|
||||
{/* Channel Type Section*/}
|
||||
<PublicPrivateSelector
|
||||
className='ChannelSettingsModal__typeSelector'
|
||||
selected={channelType}
|
||||
publicButtonProps={{
|
||||
title: formatMessage({id: 'channel_modal.type.public.title', defaultMessage: 'Public Channel'}),
|
||||
description: formatMessage({id: 'channel_modal.type.public.description', defaultMessage: 'Anyone can join'}),
|
||||
|
||||
// Always disable public button if current channel is private, regardless of permissions
|
||||
disabled: channel.type === Constants.PRIVATE_CHANNEL || !canConvertToPublic,
|
||||
}}
|
||||
privateButtonProps={{
|
||||
title: formatMessage({id: 'channel_modal.type.private.title', defaultMessage: 'Private Channel'}),
|
||||
description: formatMessage({id: 'channel_modal.type.private.description', defaultMessage: 'Only invited members'}),
|
||||
disabled: !canConvertToPrivate,
|
||||
}}
|
||||
onChange={handleChannelTypeChange}
|
||||
/>
|
||||
|
||||
{/* Purpose Section*/}
|
||||
<AdvancedTextbox
|
||||
id='channel_settings_purpose_textbox'
|
||||
value={channelPurpose}
|
||||
channelId={channel.id}
|
||||
onChange={handlePurposeChange}
|
||||
createMessage={formatMessage({
|
||||
id: 'channel_settings_modal.purpose.placeholder',
|
||||
defaultMessage: 'Enter a purpose for this channel',
|
||||
})}
|
||||
maxLength={Constants.MAX_CHANNELPURPOSE_LENGTH}
|
||||
preview={shouldShowPreviewPurpose}
|
||||
togglePreview={togglePurposePreview}
|
||||
useChannelMentions={false}
|
||||
onKeyPress={() => {}}
|
||||
descriptionMessage={formatMessage({
|
||||
id: 'channel_settings.purpose.description',
|
||||
defaultMessage: 'Describe how this channel should be used.',
|
||||
})}
|
||||
hasError={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH}
|
||||
errorMessage={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH ? formatMessage({
|
||||
id: 'channel_settings.error_purpose_length',
|
||||
defaultMessage: 'The channel purpose exceeds the maximum character limit of {maxLength} characters.',
|
||||
}, {
|
||||
maxLength: Constants.MAX_CHANNELPURPOSE_LENGTH,
|
||||
}) : undefined
|
||||
}
|
||||
showCharacterCount={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH}
|
||||
readOnly={!canManageChannelProperties}
|
||||
name={formatMessage({id: 'channel_settings.purpose.label', defaultMessage: 'Channel Purpose'})}
|
||||
/>
|
||||
|
||||
{/* Channel Header Section*/}
|
||||
<AdvancedTextbox
|
||||
id='channel_settings_header_textbox'
|
||||
value={channelHeader}
|
||||
channelId={channel.id}
|
||||
onChange={handleHeaderChange}
|
||||
createMessage={formatMessage({
|
||||
id: 'channel_settings_modal.header.placeholder',
|
||||
defaultMessage: 'Enter a header description or important links',
|
||||
})}
|
||||
maxLength={HEADER_MAX_LENGTH}
|
||||
preview={shouldShowPreviewHeader}
|
||||
togglePreview={toggleHeaderPreview}
|
||||
useChannelMentions={false}
|
||||
onKeyPress={() => {}}
|
||||
descriptionMessage={formatMessage({
|
||||
id: 'channel_settings.purpose.header',
|
||||
defaultMessage: 'This is the text that will appear in the header of the channel beside the channel name. You can use markdown to include links by typing [Link Title](http://example.com).',
|
||||
})}
|
||||
hasError={channelHeader.length > HEADER_MAX_LENGTH}
|
||||
errorMessage={channelHeader.length > HEADER_MAX_LENGTH ? formatMessage({
|
||||
id: 'edit_channel_header_modal.error',
|
||||
defaultMessage: 'The channel header exceeds the maximum character limit of {maxLength} characters.',
|
||||
}, {
|
||||
maxLength: HEADER_MAX_LENGTH,
|
||||
}) : undefined
|
||||
}
|
||||
showCharacterCount={channelHeader.length > HEADER_MAX_LENGTH}
|
||||
readOnly={!canManageChannelProperties}
|
||||
name={formatMessage({id: 'channel_settings.header.label', defaultMessage: 'Channel Header'})}
|
||||
/>
|
||||
|
||||
{/* SaveChangesPanel for unsaved changes */}
|
||||
{shouldShowPanel && (
|
||||
<SaveChangesPanel
|
||||
handleSubmit={handleSaveChanges}
|
||||
handleCancel={handleCancel}
|
||||
handleClose={handleClose}
|
||||
tabChangeError={hasErrors}
|
||||
state={hasErrors ? 'error' : saveChangesPanelState}
|
||||
{...(!showTabSwitchError && { // for swowTabShiwthError use the default message
|
||||
customErrorMessage: formatMessage({
|
||||
id: 'channel_settings.save_changes_panel.standard_error',
|
||||
defaultMessage: 'There are errors in the form above',
|
||||
}),
|
||||
})}
|
||||
cancelButtonText={formatMessage({
|
||||
id: 'channel_settings.save_changes_panel.reset',
|
||||
defaultMessage: 'Reset',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelSettingsInfoTab;
|
||||
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.ChannelSettingsModal {
|
||||
label.Input_legend {
|
||||
margin-bottom: 4px;
|
||||
color: var(--center-channel-color);
|
||||
font-family: Metropolis, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
label.Input_subheading {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-family: Open Sans, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.GenericModal__wrapper {
|
||||
display: flex;
|
||||
overflow: visible;
|
||||
max-width: 1200px;
|
||||
max-height: 90vh;
|
||||
flex-direction: column;
|
||||
border: var(--border-default);
|
||||
border-radius: var(--radius-l);
|
||||
box-shadow: var(--elevation-6);
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
overflow: visible;
|
||||
width: auto;
|
||||
min-height: 150px;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__bodyWrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.settings-table {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.settings-links {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
overflow: visible !important; // This is necessary to override the overflow hidden from the modal for emoji list
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
.AdvancedTextbox {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.channel-settings-name-container, .AdvancedTextbox {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__infoTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 60px;
|
||||
|
||||
#PreviewInputTextButton {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
// This is a workaround forcing styles to override the ones from the textbox component that affect post input
|
||||
.textbox-preview-area, #channel_settings_purpose_textbox, #channel_settings_header_textbox {
|
||||
height: 80px !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
// forcing z-index on the emoji list to be on top of the modal
|
||||
.suggestion-list {
|
||||
z-index: 100000;
|
||||
}
|
||||
|
||||
.textbox-preview-area {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__typeSelector {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__purposeContainer, &__headerContainer {
|
||||
margin-top: 28px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 8em;
|
||||
max-height: 60vh;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
background: var(--center-channel-bg);
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
resize: none;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--center-channel-color-rgb), 0.48);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--button-bg);
|
||||
}
|
||||
|
||||
&.with-error {
|
||||
border-color: var(--error-text);
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 1px var(--error-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__headerContainer {
|
||||
margin-top: 28px;
|
||||
|
||||
&--preview-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-preview-wrapper {
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
&__archiveTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.btn-danger {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
max-height: unset;
|
||||
flex-direction: column;
|
||||
border-radius: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 900px) and (min-width: 768px) {
|
||||
.modal-content {
|
||||
max-height: 90vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 600px) {
|
||||
.modal-content,
|
||||
.GenericModal__wrapper {
|
||||
max-height: 85vh !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// styles for the archive channel confirm modal
|
||||
.archiveChannelConfirmModal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.GenericModal__header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
h1#genericModalLabel {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.GenericModal__body {
|
||||
.ConfirmModal__body {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.ConfirmModal__footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import ChannelSettingsModal from './channel_settings_modal';
|
||||
|
||||
// Variables to control permission check results in tests
|
||||
let mockPrivateChannelPermission = true;
|
||||
let mockPublicChannelPermission = true;
|
||||
|
||||
// Mock the redux selectors
|
||||
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
|
||||
getChannel: jest.fn().mockImplementation((state, channelId) => {
|
||||
// Return a mock channel based on the channelId
|
||||
return {
|
||||
id: channelId,
|
||||
team_id: 'team1',
|
||||
display_name: 'Test Channel',
|
||||
name: channelId === 'default_channel' ? 'town-square' : 'test-channel',
|
||||
purpose: 'Testing purpose',
|
||||
header: 'Channel header',
|
||||
type: mockChannelType, // Use a variable to control the channel type
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
last_post_at: 0,
|
||||
total_msg_count: 0,
|
||||
extra_update_at: 0,
|
||||
creator_id: 'creator1',
|
||||
last_root_post_at: 0,
|
||||
scheme_id: '',
|
||||
group_constrained: false,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the roles selector which is used for permission checks
|
||||
jest.mock('mattermost-redux/selectors/entities/roles', () => ({
|
||||
haveIChannelPermission: jest.fn().mockImplementation((state, teamId, channelId, permission) => {
|
||||
// Return different values based on the permission being checked
|
||||
if (permission === 'delete_private_channel') {
|
||||
return mockPrivateChannelPermission;
|
||||
}
|
||||
if (permission === 'delete_public_channel') {
|
||||
return mockPublicChannelPermission;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the child components to simplify testing
|
||||
jest.mock('./channel_settings_info_tab', () => {
|
||||
return function MockInfoTab(): JSX.Element {
|
||||
return <div data-testid='info-tab'>{'Info Tab Content'}</div>;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./channel_settings_configuration_tab', () => {
|
||||
return function MockConfigTab(): JSX.Element {
|
||||
return <div data-testid='config-tab'>{'Configuration Tab Content'}</div>;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./channel_settings_archive_tab', () => {
|
||||
return function MockArchiveTab(): JSX.Element {
|
||||
return <div data-testid='archive-tab'>{'Archive Tab Content'}</div>;
|
||||
};
|
||||
});
|
||||
|
||||
// Define the tab type for the settings sidebar
|
||||
type TabType = {
|
||||
name: string;
|
||||
uiName: string;
|
||||
display?: boolean;
|
||||
};
|
||||
|
||||
// Variable to control the channel type in tests
|
||||
let mockChannelType = 'O';
|
||||
|
||||
// Mock the settings sidebar
|
||||
jest.mock('components/settings_sidebar', () => {
|
||||
return function MockSettingsSidebar({tabs, activeTab, updateTab}: {tabs: TabType[]; activeTab: string; updateTab: (tab: string) => void}): JSX.Element {
|
||||
return (
|
||||
<div data-testid='settings-sidebar'>
|
||||
{tabs.filter((tab) => tab.display !== false).map((tab) => (
|
||||
<button
|
||||
data-testid={`${tab.name}-tab-button`}
|
||||
key={tab.name}
|
||||
role='tab'
|
||||
aria-selected={activeTab === tab.name}
|
||||
aria-label={tab.name}
|
||||
onClick={() => updateTab(tab.name)}
|
||||
>
|
||||
{tab.uiName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
channelId: 'channel1',
|
||||
isOpen: true,
|
||||
onExited: jest.fn(),
|
||||
focusOriginElement: 'button1',
|
||||
};
|
||||
|
||||
describe('ChannelSettingsModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockChannelType = 'O'; // Default to public channel
|
||||
mockPrivateChannelPermission = true;
|
||||
mockPublicChannelPermission = true;
|
||||
});
|
||||
|
||||
it('should render the modal with correct header text', async () => {
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
|
||||
expect(screen.getByText('Channel Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Info tab by default', async () => {
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
|
||||
|
||||
// Wait for the lazy-loaded components
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('info-tab')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch tabs when clicked', async () => {
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
|
||||
|
||||
// Wait for the sidebar to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initially the info tab should be active
|
||||
expect(screen.getByTestId('info-tab')).toBeInTheDocument();
|
||||
|
||||
// Find and click the archive tab
|
||||
const archiveTab = screen.getByRole('tab', {name: 'archive'});
|
||||
await userEvent.click(archiveTab);
|
||||
|
||||
// Now the archive tab should be visible
|
||||
expect(screen.getByTestId('archive-tab')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show archive tab for default channel', async () => {
|
||||
renderWithContext(
|
||||
<ChannelSettingsModal
|
||||
{...{...baseProps, channelId: 'default_channel'}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for the sidebar to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The info tab should be visible
|
||||
expect(screen.getByTestId('info-tab')).toBeInTheDocument();
|
||||
|
||||
// The archive tab should not be in the document
|
||||
expect(screen.queryByRole('tab', {name: 'archive'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show archive tab for public channel when user has permission', async () => {
|
||||
mockChannelType = 'O';
|
||||
mockPublicChannelPermission = true;
|
||||
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
|
||||
|
||||
// Wait for the sidebar to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The archive tab should be visible
|
||||
expect(screen.getByRole('tab', {name: 'archive'})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show archive tab for public channel when user does not have permission', async () => {
|
||||
mockChannelType = 'O';
|
||||
mockPublicChannelPermission = false;
|
||||
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
|
||||
|
||||
// Wait for the sidebar to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The archive tab should not be in the document
|
||||
expect(screen.queryByRole('tab', {name: 'archive'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show archive tab for private channel when user has permission', async () => {
|
||||
mockChannelType = 'P';
|
||||
mockPrivateChannelPermission = true;
|
||||
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
|
||||
|
||||
// Wait for the sidebar to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The archive tab should be visible
|
||||
expect(screen.getByRole('tab', {name: 'archive'})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show archive tab for private channel when user does not have permission', async () => {
|
||||
mockChannelType = 'P';
|
||||
mockPrivateChannelPermission = false;
|
||||
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
|
||||
|
||||
// Wait for the sidebar to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The archive tab should not be in the document
|
||||
expect(screen.queryByRole('tab', {name: 'archive'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show configuration tab with no license', async () => {
|
||||
const baseState: DeepPartial<GlobalState> = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
SkuShortName: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>, baseState as GlobalState);
|
||||
expect(screen.queryByTestId('configuration-tab-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show configuration tab with professional license', async () => {
|
||||
const baseState: DeepPartial<GlobalState> = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
SkuShortName: 'professional',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>, baseState as GlobalState);
|
||||
expect(screen.queryByTestId('configuration-tab-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show configuration tab with enterprise license', async () => {
|
||||
const baseState: DeepPartial<GlobalState> = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
SkuShortName: 'enterprise',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>, baseState as GlobalState);
|
||||
expect(screen.queryByTestId('configuration-tab-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show configuration tab when premium license', async () => {
|
||||
const baseState: DeepPartial<GlobalState> = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
SkuShortName: 'premium',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithContext(<ChannelSettingsModal {...baseProps}/>, baseState as GlobalState);
|
||||
expect(screen.getByTestId('configuration-tab-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import Permissions from 'mattermost-redux/constants/permissions';
|
||||
import {selectChannelBannerEnabled} from 'mattermost-redux/selectors/entities/channel_banner';
|
||||
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
|
||||
|
||||
import {
|
||||
setShowPreviewOnChannelSettingsHeaderModal,
|
||||
setShowPreviewOnChannelSettingsPurposeModal,
|
||||
} from 'actions/views/textbox';
|
||||
|
||||
import {focusElement} from 'utils/a11y_utils';
|
||||
import Constants from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import ChannelSettingsArchiveTab from './channel_settings_archive_tab';
|
||||
import ChannelSettingsConfigurationTab from './channel_settings_configuration_tab';
|
||||
import ChannelSettingsInfoTab from './channel_settings_info_tab';
|
||||
|
||||
import './channel_settings_modal.scss';
|
||||
|
||||
// Lazy-loaded components
|
||||
const SettingsSidebar = React.lazy(() => import('components/settings_sidebar'));
|
||||
|
||||
type ChannelSettingsModalProps = {
|
||||
channelId: string;
|
||||
onExited: () => void;
|
||||
isOpen: boolean;
|
||||
focusOriginElement?: string;
|
||||
};
|
||||
|
||||
enum ChannelSettingsTabs {
|
||||
INFO = 'info',
|
||||
CONFIGURATION = 'configuration',
|
||||
ARCHIVE = 'archive',
|
||||
}
|
||||
|
||||
const SHOW_PANEL_ERROR_STATE_TAB_SWITCH_TIMEOUT = 3000;
|
||||
|
||||
function ChannelSettingsModal({channelId, isOpen, onExited, focusOriginElement}: ChannelSettingsModalProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const channel = useSelector((state: GlobalState) => getChannel(state, channelId)) as Channel;
|
||||
const shouldShowConfigurationTab = useSelector(selectChannelBannerEnabled);
|
||||
|
||||
const canArchivePrivateChannels = useSelector((state: GlobalState) =>
|
||||
haveIChannelPermission(state, channel.team_id, channel.id, Permissions.DELETE_PRIVATE_CHANNEL),
|
||||
);
|
||||
|
||||
const canArchivePublicChannels = useSelector((state: GlobalState) =>
|
||||
haveIChannelPermission(state, channel.team_id, channel.id, Permissions.DELETE_PUBLIC_CHANNEL),
|
||||
);
|
||||
|
||||
const [show, setShow] = useState(isOpen);
|
||||
|
||||
// Active tab
|
||||
const [activeTab, setActiveTab] = useState<ChannelSettingsTabs>(ChannelSettingsTabs.INFO);
|
||||
|
||||
// State for showing error in the save changes panel when trying to switch tabs with unsaved changes
|
||||
const [showTabSwitchError, setShowTabSwitchError] = useState(false);
|
||||
|
||||
// State to track if there are unsaved changes
|
||||
const [areThereUnsavedChanges, setAreThereUnsavedChanges] = useState(false);
|
||||
|
||||
// Refs
|
||||
const modalBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Called to set the active tab, prompting save changes panel if there are unsaved changes
|
||||
const updateTab = (newTab: string) => {
|
||||
/**
|
||||
* If there are unsaved changes, show an error in the save changes panel
|
||||
* and reset it after a timeout to indicate the user needs to save or discard changes
|
||||
* before switching tabs.
|
||||
*/
|
||||
if (areThereUnsavedChanges) {
|
||||
setShowTabSwitchError(true);
|
||||
setTimeout(() => {
|
||||
setShowTabSwitchError(false);
|
||||
}, SHOW_PANEL_ERROR_STATE_TAB_SWITCH_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = newTab as ChannelSettingsTabs;
|
||||
setActiveTab(tab);
|
||||
|
||||
if (modalBodyRef.current) {
|
||||
modalBodyRef.current.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleHide = () => {
|
||||
handleHideConfirm();
|
||||
};
|
||||
|
||||
const handleHideConfirm = () => {
|
||||
// Reset preview states to false when closing the modal
|
||||
dispatch(setShowPreviewOnChannelSettingsHeaderModal(false));
|
||||
dispatch(setShowPreviewOnChannelSettingsPurposeModal(false));
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
// Called after the fade-out completes
|
||||
const handleExited = () => {
|
||||
// Clear anything if needed
|
||||
setActiveTab(ChannelSettingsTabs.INFO);
|
||||
if (focusOriginElement) {
|
||||
focusElement(focusOriginElement, true);
|
||||
}
|
||||
onExited();
|
||||
};
|
||||
|
||||
// Renders content based on active tab
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case ChannelSettingsTabs.INFO:
|
||||
return renderInfoTab();
|
||||
case ChannelSettingsTabs.CONFIGURATION:
|
||||
return renderConfigurationTab();
|
||||
case ChannelSettingsTabs.ARCHIVE:
|
||||
return renderArchiveTab();
|
||||
default:
|
||||
return renderInfoTab();
|
||||
}
|
||||
};
|
||||
|
||||
const renderInfoTab = () => {
|
||||
return (
|
||||
<ChannelSettingsInfoTab
|
||||
channel={channel}
|
||||
setAreThereUnsavedChanges={setAreThereUnsavedChanges}
|
||||
showTabSwitchError={showTabSwitchError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderConfigurationTab = () => {
|
||||
return (
|
||||
<ChannelSettingsConfigurationTab
|
||||
channel={channel}
|
||||
setAreThereUnsavedChanges={setAreThereUnsavedChanges}
|
||||
showTabSwitchError={showTabSwitchError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderArchiveTab = () => {
|
||||
return (
|
||||
<ChannelSettingsArchiveTab
|
||||
channel={channel}
|
||||
onHide={handleHideConfirm}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Define tabs for the settings sidebar
|
||||
const tabs = [
|
||||
{
|
||||
name: ChannelSettingsTabs.INFO,
|
||||
uiName: formatMessage({id: 'channel_settings.tab.info', defaultMessage: 'Info'}),
|
||||
icon: 'icon icon-information-outline',
|
||||
iconTitle: formatMessage({id: 'generic_icons.info', defaultMessage: 'Info Icon'}),
|
||||
},
|
||||
{
|
||||
name: ChannelSettingsTabs.CONFIGURATION,
|
||||
uiName: formatMessage({id: 'channel_settings.tab.configuration', defaultMessage: 'Configuration'}),
|
||||
icon: 'icon icon-cog-outline',
|
||||
iconTitle: formatMessage({id: 'generic_icons.settings', defaultMessage: 'Settings Icon'}),
|
||||
display: shouldShowConfigurationTab,
|
||||
},
|
||||
{
|
||||
name: ChannelSettingsTabs.ARCHIVE,
|
||||
uiName: formatMessage({id: 'channel_settings.tab.archive', defaultMessage: 'Archive Channel'}),
|
||||
icon: 'icon icon-archive-outline',
|
||||
iconTitle: formatMessage({id: 'generic_icons.archive', defaultMessage: 'Archive Icon'}),
|
||||
newGroup: true,
|
||||
display: channel.name !== Constants.DEFAULT_CHANNEL && // archive is not available for the default channel
|
||||
((channel.type === Constants.PRIVATE_CHANNEL && canArchivePrivateChannels) ||
|
||||
(channel.type === Constants.OPEN_CHANNEL && canArchivePublicChannels)),
|
||||
},
|
||||
];
|
||||
|
||||
// Renders the body: left sidebar for tabs, the content on the right
|
||||
const renderModalBody = () => {
|
||||
return (
|
||||
<div
|
||||
ref={modalBodyRef}
|
||||
className='settings-table'
|
||||
>
|
||||
<div className='settings-links'>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSidebar
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
updateTab={updateTab}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
<div className='settings-content minimize-settings'>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const modalTitle = formatMessage({id: 'channel_settings.modal.title', defaultMessage: 'Channel Settings'});
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
id='channelSettingsModal'
|
||||
ariaLabel={modalTitle}
|
||||
className='ChannelSettingsModal settings-modal'
|
||||
show={show}
|
||||
onHide={handleHide}
|
||||
onExited={handleExited}
|
||||
compassDesign={true}
|
||||
modalHeaderText={modalTitle}
|
||||
bodyPadding={false}
|
||||
modalLocation={'top'}
|
||||
>
|
||||
<div className='ChannelSettingsModal__bodyWrapper'>
|
||||
{renderModalBody()}
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelSettingsModal;
|
||||
@@ -20,7 +20,7 @@ const ChannelHeader = makeAsyncComponent('ChannelHeader', lazy(() => import('com
|
||||
const FileUploadOverlay = makeAsyncComponent('FileUploadOverlay', lazy(() => import('components/file_upload_overlay')));
|
||||
const ChannelBookmarks = makeAsyncComponent('ChannelBookmarks', lazy(() => import('components/channel_bookmarks')));
|
||||
const AdvancedCreatePost = makeAsyncComponent('AdvancedCreatePost', lazy(() => import('components/advanced_create_post')));
|
||||
const ChannelBanner = makeAsyncComponent('ChannelBanner', lazy(() => import('components/channel_banner')));
|
||||
const ChannelBanner = makeAsyncComponent('ChannelBanner', lazy(() => import('components/channel_banner/channel_banner')));
|
||||
|
||||
export type Props = PropsFromRedux & RouteComponentProps<{
|
||||
postid?: string;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
import {focusElement} from 'utils/a11y_utils';
|
||||
|
||||
import './confirm_modal.scss';
|
||||
|
||||
type Props = {
|
||||
@@ -81,6 +82,12 @@ type Props = {
|
||||
* Set to hide the cancel button
|
||||
*/
|
||||
hideCancel?: boolean;
|
||||
|
||||
/*
|
||||
* The element that triggered the modal
|
||||
*/
|
||||
focusOriginElement?: string;
|
||||
|
||||
};
|
||||
|
||||
type State = {
|
||||
@@ -123,6 +130,13 @@ export default class ConfirmModal extends React.Component<Props, State> {
|
||||
this.props.onCancel?.(this.state.checked);
|
||||
};
|
||||
|
||||
handleExited = () => {
|
||||
this.props.onExited?.();
|
||||
if (this.props.focusOriginElement) {
|
||||
focusElement(this.props.focusOriginElement!, true);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
let checkbox;
|
||||
if (this.props.showCheckbox) {
|
||||
@@ -169,11 +183,12 @@ export default class ConfirmModal extends React.Component<Props, State> {
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
id={classNames('confirmModal', this.props.id)}
|
||||
id={this.props.id || 'confirmModal'}
|
||||
className={`ConfirmModal a11y__modal ${this.props.modalClass}`}
|
||||
show={this.props.show}
|
||||
onHide={this.handleCancel}
|
||||
onExited={this.props.onExited}
|
||||
onExited={this.handleExited}
|
||||
ariaLabelledby='confirmModalLabel'
|
||||
compassDesign={true}
|
||||
modalHeaderText={this.props.title}
|
||||
>
|
||||
@@ -191,11 +206,11 @@ export default class ConfirmModal extends React.Component<Props, State> {
|
||||
{this.props.checkboxInFooter && checkbox}
|
||||
{cancelButton}
|
||||
<button
|
||||
autoFocus={true}
|
||||
type='button'
|
||||
className={this.props.confirmButtonClass}
|
||||
onClick={this.handleConfirm}
|
||||
id='confirmModalButton'
|
||||
autoFocus={true}
|
||||
>
|
||||
{this.props.confirmButtonText}
|
||||
</button>
|
||||
|
||||
@@ -498,6 +498,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
|
||||
title="Save Outgoing OAuth Connection"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
@@ -516,7 +517,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-labelledby="confirmModalLabel"
|
||||
aria-modal="true"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
@@ -1101,6 +1102,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
|
||||
title="Save Outgoing OAuth Connection"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
@@ -1119,7 +1121,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-labelledby="confirmModalLabel"
|
||||
aria-modal="true"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
|
||||
@@ -487,6 +487,7 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho
|
||||
title="Save Outgoing OAuth Connection"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
@@ -505,7 +506,7 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-labelledby="confirmModalLabel"
|
||||
aria-modal="true"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
|
||||
@@ -549,6 +549,7 @@ https://myothersite.com/api/v2"
|
||||
}
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
@@ -564,6 +565,7 @@ https://myothersite.com/api/v2"
|
||||
/>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
@@ -571,7 +573,7 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-labelledby="confirmModalLabel"
|
||||
aria-modal="true"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
@@ -593,6 +595,7 @@ https://myothersite.com/api/v2"
|
||||
"remove": [Function],
|
||||
}
|
||||
}
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
renderBackdrop={[Function]}
|
||||
restoreFocus={true}
|
||||
@@ -669,6 +672,7 @@ https://myothersite.com/api/v2"
|
||||
title="Save Outgoing OAuth Connection"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
@@ -687,7 +691,7 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-labelledby="confirmModalLabel"
|
||||
aria-modal="true"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
@@ -1305,6 +1309,7 @@ https://myothersite.com/api/v2"
|
||||
}
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
@@ -1320,6 +1325,7 @@ https://myothersite.com/api/v2"
|
||||
/>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
@@ -1327,7 +1333,7 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-labelledby="confirmModalLabel"
|
||||
aria-modal="true"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
@@ -1349,6 +1355,7 @@ https://myothersite.com/api/v2"
|
||||
"remove": [Function],
|
||||
}
|
||||
}
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
renderBackdrop={[Function]}
|
||||
restoreFocus={true}
|
||||
@@ -1425,6 +1432,7 @@ https://myothersite.com/api/v2"
|
||||
title="Save Outgoing OAuth Connection"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
@@ -1443,7 +1451,7 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-labelledby="confirmModalLabel"
|
||||
aria-modal="true"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
@@ -2060,6 +2068,7 @@ https://myothersite.com/api/v2"
|
||||
}
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
@@ -2075,6 +2084,7 @@ https://myothersite.com/api/v2"
|
||||
/>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
@@ -2082,7 +2092,7 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-labelledby="confirmModalLabel"
|
||||
aria-modal="true"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
@@ -2104,6 +2114,7 @@ https://myothersite.com/api/v2"
|
||||
"remove": [Function],
|
||||
}
|
||||
}
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
renderBackdrop={[Function]}
|
||||
restoreFocus={true}
|
||||
@@ -2180,6 +2191,7 @@ https://myothersite.com/api/v2"
|
||||
title="Save Outgoing OAuth Connection"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
@@ -2198,7 +2210,7 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="genericModalLabel"
|
||||
aria-labelledby="confirmModalLabel"
|
||||
aria-modal="true"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/RenameChannelModal should match snapshot 1`] = `
|
||||
<Modal
|
||||
animation={true}
|
||||
aria-labelledby="renameChannelModalLabel"
|
||||
autoFocus={true}
|
||||
backdrop={true}
|
||||
bsClass="modal"
|
||||
dialogClassName="a11y__modal"
|
||||
dialogComponentClass={[Function]}
|
||||
enforceFocus={true}
|
||||
keyboard={true}
|
||||
manager={
|
||||
ModalManager {
|
||||
"add": [Function],
|
||||
"containers": Array [],
|
||||
"data": Array [],
|
||||
"handleContainerOverflow": true,
|
||||
"hideSiblingNodes": true,
|
||||
"isTopModal": [Function],
|
||||
"modals": Array [],
|
||||
"remove": [Function],
|
||||
}
|
||||
}
|
||||
onEntering={[Function]}
|
||||
onExited={[MockFunction]}
|
||||
onHide={[Function]}
|
||||
renderBackdrop={[Function]}
|
||||
restoreFocus={true}
|
||||
role="none"
|
||||
show={true}
|
||||
>
|
||||
<ModalHeader
|
||||
bsClass="modal-header"
|
||||
closeButton={true}
|
||||
closeLabel="Close"
|
||||
>
|
||||
<ModalTitle
|
||||
bsClass="modal-title"
|
||||
componentClass="h1"
|
||||
id="renameChannelModalLabel"
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="Rename Channel"
|
||||
id="rename_channel.title"
|
||||
/>
|
||||
</ModalTitle>
|
||||
</ModalHeader>
|
||||
<form
|
||||
role="form"
|
||||
>
|
||||
<ModalBody
|
||||
bsClass="modal-body"
|
||||
componentClass="div"
|
||||
>
|
||||
<div
|
||||
className="form-group"
|
||||
>
|
||||
<label
|
||||
className="control-label"
|
||||
htmlFor="display_name"
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="Display Name"
|
||||
id="rename_channel.displayName"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
aria-label="display name"
|
||||
className="form-control"
|
||||
id="display_name"
|
||||
maxLength={64}
|
||||
onChange={[Function]}
|
||||
placeholder="Enter display name"
|
||||
type="text"
|
||||
value="Fake Channel"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="form-group"
|
||||
>
|
||||
<label
|
||||
className="control-label"
|
||||
htmlFor="channel_name"
|
||||
>
|
||||
URL
|
||||
</label>
|
||||
<div
|
||||
className="input-group input-group--limit"
|
||||
>
|
||||
<WithTooltip
|
||||
title="fake-channel/channels"
|
||||
>
|
||||
<span
|
||||
className="input-group-addon"
|
||||
>
|
||||
fake-channel/channels/
|
||||
</span>
|
||||
</WithTooltip>
|
||||
<input
|
||||
aria-label="rename channel"
|
||||
className="form-control"
|
||||
id="channel_name"
|
||||
maxLength={64}
|
||||
onChange={[Function]}
|
||||
readOnly={false}
|
||||
type="text"
|
||||
value="fake-channel"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className="input__help"
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="You can use lowercase letters, numbers, dashes, and underscores."
|
||||
id="change_url.helpText"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
bsClass="modal-footer"
|
||||
componentClass="div"
|
||||
>
|
||||
<button
|
||||
className="btn btn-tertiary"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="Cancel"
|
||||
id="rename_channel.cancel"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
id="save-button"
|
||||
onClick={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="Save"
|
||||
id="rename_channel.save"
|
||||
/>
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
`;
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import type {Dispatch} from 'redux';
|
||||
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {patchChannel} from 'mattermost-redux/actions/channels';
|
||||
import {createSelector} from 'mattermost-redux/selectors/create_selector';
|
||||
import {getTeam} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import {getSiteURL} from 'utils/url';
|
||||
|
||||
import RenameChannelModal from './rename_channel_modal';
|
||||
|
||||
const mapStateToPropsRenameChannel = createSelector(
|
||||
'mapStateToPropsRenameChannel',
|
||||
(state: GlobalState) => {
|
||||
const currentTeamId = state.entities.teams.currentTeamId;
|
||||
const team = getTeam(state, currentTeamId);
|
||||
const currentTeamUrl = `${getSiteURL()}/${team ? team.name : ''}`;
|
||||
return {
|
||||
currentTeamUrl,
|
||||
team,
|
||||
};
|
||||
},
|
||||
(teamInfo) => ({...teamInfo}),
|
||||
);
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
patchChannel,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToPropsRenameChannel, mapDispatchToProps)(RenameChannelModal);
|
||||
@@ -1,210 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
|
||||
import {RequestStatus} from 'mattermost-redux/constants';
|
||||
|
||||
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import RenameChannelModal from './rename_channel_modal';
|
||||
import type {RenameChannelModal as RenameChannelModalClass} from './rename_channel_modal';
|
||||
|
||||
describe('components/RenameChannelModal', () => {
|
||||
const channel: Channel = TestHelper.getChannelMock({
|
||||
id: 'fake-id',
|
||||
name: 'fake-channel',
|
||||
display_name: 'Fake Channel',
|
||||
});
|
||||
|
||||
const team: Team = TestHelper.getTeamMock({
|
||||
name: 'Fake Team',
|
||||
display_name: 'fake-team',
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
show: true,
|
||||
onExited: jest.fn(),
|
||||
channel: {...channel},
|
||||
requestStatus: RequestStatus.NOT_STARTED,
|
||||
team: {...team},
|
||||
currentTeamUrl: 'fake-channel',
|
||||
actions: {patchChannel: jest.fn().mockResolvedValue({data: true})},
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should submit form', () => {
|
||||
const {actions: {patchChannel}} = baseProps;
|
||||
const props = {...baseProps, requestStatus: RequestStatus.STARTED};
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...props}/>,
|
||||
);
|
||||
|
||||
wrapper.find('#display_name').simulate(
|
||||
'change', {preventDefault: jest.fn(), target: {value: 'valid name'}},
|
||||
);
|
||||
|
||||
wrapper.find('#save-button').simulate('click');
|
||||
|
||||
expect(patchChannel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('should validate channel url (name)', () => {
|
||||
const testCases: Array<[{name: string; value: string}, boolean]> = [
|
||||
[{name: 'must be one or more characters', value: ''}, false],
|
||||
[{name: 'must start with a letter or number', value: '_channel'}, false],
|
||||
[{name: 'must end with a letter or number', value: 'channel_'}, false],
|
||||
[{name: 'can contain two underscores in a row', value: 'channel__two'}, true],
|
||||
[{name: 'can not resemble direct message channel url', value: 'uzsfmtmniifsjgesce4u7yznyh__uzsfmtmniifsjgesce4u7yznyh'}, false],
|
||||
[{name: 'valid channel url', value: 'a_valid_channel'}, true],
|
||||
];
|
||||
|
||||
testCases.forEach(([testCaseProps, patchShouldHaveBeenCalled]) => {
|
||||
it(testCaseProps.name, () => {
|
||||
const {actions: {patchChannel}} = baseProps;
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...baseProps}/>,
|
||||
);
|
||||
|
||||
wrapper.setState({channelName: testCaseProps.value});
|
||||
|
||||
wrapper.find('#save-button').simulate('click');
|
||||
if (patchShouldHaveBeenCalled) {
|
||||
expect(patchChannel).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(patchChannel).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should not call patchChannel as channel.name.length > Constants.MAX_CHANNELNAME_LENGTH (64)', () => {
|
||||
const {actions: {patchChannel}} = baseProps;
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...baseProps}/>,
|
||||
);
|
||||
|
||||
wrapper.find('#display_name').simulate(
|
||||
'change', {preventDefault: jest.fn(), target: {value: 'string-above-sixtyfour-characters-to-test-the-channel-maxlength-limit-properly-in-the-component'}},
|
||||
);
|
||||
|
||||
wrapper.find('#save-button').simulate('click');
|
||||
|
||||
expect(patchChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should change state when display_name is edited', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...baseProps}/>,
|
||||
);
|
||||
|
||||
wrapper.find('#display_name').simulate(
|
||||
'change', {preventDefault: jest.fn(), target: {value: 'New Fake Channel'}},
|
||||
);
|
||||
|
||||
expect(wrapper.state('displayName')).toBe('New Fake Channel');
|
||||
});
|
||||
|
||||
test('should call setError function', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...baseProps}/>,
|
||||
);
|
||||
|
||||
const instance = wrapper.instance() as RenameChannelModalClass;
|
||||
|
||||
instance.setError({message: 'This is an error message'});
|
||||
expect(wrapper.state('serverError')).toBe('This is an error message');
|
||||
});
|
||||
|
||||
test('should call unsetError function', () => {
|
||||
const props = {...baseProps, serverError: {message: 'This is an error message'}};
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...props}/>,
|
||||
);
|
||||
|
||||
wrapper.setState({serverError: props.serverError.message});
|
||||
expect(wrapper.state('serverError')).toBe('This is an error message');
|
||||
|
||||
wrapper.find('#save-button').simulate('click');
|
||||
expect(wrapper.state('serverError')).toBe('');
|
||||
});
|
||||
|
||||
test('should call handleSubmit function', async () => {
|
||||
const patchChannel = jest.fn().
|
||||
mockResolvedValueOnce({error: true}).
|
||||
mockResolvedValue({data: true});
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal
|
||||
{...baseProps}
|
||||
actions={{patchChannel}}
|
||||
/>,
|
||||
);
|
||||
|
||||
wrapper.setState({displayName: 'Changed Name', channelName: 'changed-name'});
|
||||
|
||||
const instance = wrapper.instance() as RenameChannelModalClass;
|
||||
instance.onSaveSuccess = jest.fn();
|
||||
instance.setError = jest.fn();
|
||||
|
||||
await instance.handleSubmit();
|
||||
expect(patchChannel).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.state('displayName')).toBe('Changed Name');
|
||||
expect(wrapper.state('channelName')).toBe('changed-name');
|
||||
expect(instance.onSaveSuccess).not.toBeCalled();
|
||||
expect(instance.setError).toBeCalledTimes(1);
|
||||
expect(instance.setError).toBeCalledWith(true);
|
||||
|
||||
await instance.handleSubmit();
|
||||
expect(patchChannel).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.state('displayName')).toBe('Changed Name');
|
||||
expect(wrapper.state('channelName')).toBe('changed-name');
|
||||
expect(instance.onSaveSuccess).toBeCalledTimes(1);
|
||||
expect(instance.setError).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should call handleCancel', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...baseProps}/>,
|
||||
);
|
||||
|
||||
const instance = wrapper.instance() as RenameChannelModalClass;
|
||||
instance.handleCancel();
|
||||
|
||||
expect(wrapper.state('show')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should call handleHide function', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...baseProps}/>,
|
||||
);
|
||||
|
||||
const instance = wrapper.instance() as RenameChannelModalClass;
|
||||
instance.handleHide();
|
||||
|
||||
expect(wrapper.state('show')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should call onNameChange function', () => {
|
||||
const changedName = {target: {value: 'changed-name'}};
|
||||
const wrapper = shallowWithIntl(
|
||||
<RenameChannelModal {...baseProps}/>,
|
||||
);
|
||||
|
||||
const instance = wrapper.instance() as RenameChannelModalClass;
|
||||
instance.onNameChange(changedName);
|
||||
|
||||
expect(wrapper.state('channelName')).toBe('changed-name');
|
||||
});
|
||||
});
|
||||
@@ -1,367 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import type {ChangeEvent, MouseEvent} from 'react';
|
||||
import {Modal} from 'react-bootstrap';
|
||||
import {defineMessages, FormattedMessage, injectIntl} from 'react-intl';
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
|
||||
import type {ActionResult} from 'mattermost-redux/types/actions';
|
||||
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
import {getHistory} from 'utils/browser_history';
|
||||
import Constants from 'utils/constants';
|
||||
import {getShortenedURL, validateChannelUrl} from 'utils/url';
|
||||
import * as Utils from 'utils/utils';
|
||||
|
||||
const holders = defineMessages({
|
||||
maxLength: {
|
||||
id: 'rename_channel.maxLength',
|
||||
defaultMessage: 'This field must be less than {maxLength, number} characters',
|
||||
},
|
||||
url: {
|
||||
id: 'rename_channel.url',
|
||||
defaultMessage: 'URL',
|
||||
},
|
||||
defaultError: {
|
||||
id: 'rename_channel.defaultError',
|
||||
defaultMessage: ' - Cannot be changed for the default channel',
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* react-intl helper object
|
||||
*/
|
||||
intl: IntlShape;
|
||||
|
||||
/**
|
||||
* Function that is called when modal is hidden
|
||||
*/
|
||||
onExited: () => void;
|
||||
|
||||
/**
|
||||
* Object with info about current channel
|
||||
*/
|
||||
channel: Channel;
|
||||
|
||||
/**
|
||||
* Object with info about current team
|
||||
*/
|
||||
team?: Team;
|
||||
|
||||
/**
|
||||
* String with the current team URL
|
||||
*/
|
||||
currentTeamUrl: string;
|
||||
|
||||
/*
|
||||
* Object with redux action creators
|
||||
*/
|
||||
actions: {
|
||||
|
||||
/*
|
||||
* Action creator to patch current channel
|
||||
*/
|
||||
patchChannel: (channelId: string, patch: Channel) => Promise<ActionResult>;
|
||||
};
|
||||
}
|
||||
|
||||
type State = {
|
||||
displayName: string;
|
||||
channelName: string;
|
||||
serverError?: string;
|
||||
urlErrors: React.ReactNode[];
|
||||
displayNameError: React.ReactNode;
|
||||
invalid: boolean;
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
export class RenameChannelModal extends React.PureComponent<Props, State> {
|
||||
private textbox?: HTMLInputElement;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
displayName: props.channel.display_name,
|
||||
channelName: props.channel.name,
|
||||
serverError: '',
|
||||
urlErrors: [],
|
||||
displayNameError: '',
|
||||
invalid: false,
|
||||
show: true,
|
||||
};
|
||||
}
|
||||
|
||||
setError = (err: ServerError) => {
|
||||
this.setState({serverError: err.message});
|
||||
};
|
||||
|
||||
unsetError = () => {
|
||||
this.setState({serverError: ''});
|
||||
};
|
||||
|
||||
handleEntering = () => {
|
||||
if (this.textbox) {
|
||||
Utils.placeCaretAtEnd(this.textbox);
|
||||
}
|
||||
};
|
||||
|
||||
handleHide = (e?: MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
serverError: '',
|
||||
urlErrors: [],
|
||||
displayNameError: '',
|
||||
invalid: false,
|
||||
show: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleSubmit = async (e?: MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const channel = Object.assign({}, this.props.channel);
|
||||
const oldName = channel.name;
|
||||
const oldDisplayName = channel.display_name;
|
||||
const state = {...this.state, serverError: ''};
|
||||
const {formatMessage} = this.props.intl;
|
||||
const {actions: {patchChannel}} = this.props;
|
||||
|
||||
channel.display_name = this.state.displayName.trim();
|
||||
if (!channel.display_name || channel.display_name.length < Constants.MIN_CHANNELNAME_LENGTH) {
|
||||
state.displayNameError = (
|
||||
<FormattedMessage
|
||||
id='rename_channel.minLength'
|
||||
defaultMessage='Display name must have at least {minLength, number} characters.'
|
||||
values={{
|
||||
minLength: Constants.MIN_CHANNELNAME_LENGTH,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
state.invalid = true;
|
||||
} else if (channel.display_name.length > Constants.MAX_CHANNELNAME_LENGTH) {
|
||||
state.displayNameError = formatMessage(holders.maxLength, {maxLength: Constants.MAX_CHANNELNAME_LENGTH});
|
||||
state.invalid = true;
|
||||
} else {
|
||||
state.displayNameError = '';
|
||||
}
|
||||
|
||||
channel.name = this.state.channelName.trim();
|
||||
const urlErrors = validateChannelUrl(channel.name);
|
||||
if (urlErrors.length > 0) {
|
||||
state.invalid = true;
|
||||
}
|
||||
state.urlErrors = urlErrors;
|
||||
|
||||
this.setState(state);
|
||||
|
||||
if (state.invalid) {
|
||||
return;
|
||||
}
|
||||
if (oldName === channel.name && oldDisplayName === channel.display_name) {
|
||||
this.onSaveSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await patchChannel(channel.id, channel);
|
||||
|
||||
if (data) {
|
||||
this.onSaveSuccess();
|
||||
} else if (error) {
|
||||
this.setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
onSaveSuccess = () => {
|
||||
this.handleHide();
|
||||
this.unsetError();
|
||||
if (this.props.team) {
|
||||
getHistory().push('/' + this.props.team.name + '/channels/' + this.state.channelName);
|
||||
}
|
||||
};
|
||||
|
||||
handleCancel = (e?: MouseEvent) => {
|
||||
this.setState({
|
||||
displayName: this.props.channel.display_name,
|
||||
channelName: this.props.channel.name,
|
||||
});
|
||||
|
||||
this.handleHide(e);
|
||||
};
|
||||
|
||||
onNameChange = (e: ChangeEvent<HTMLInputElement> | {target: {value: string}}) => {
|
||||
const name = e.target.value.trim().replace(/[^A-Za-z0-9-_]/g, '').toLowerCase();
|
||||
this.setState({channelName: name});
|
||||
};
|
||||
|
||||
onDisplayNameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({displayName: e.target.value});
|
||||
};
|
||||
|
||||
getTextbox = (node: HTMLInputElement) => {
|
||||
this.textbox = node;
|
||||
};
|
||||
|
||||
render(): JSX.Element {
|
||||
let displayNameError = null;
|
||||
if (this.state.displayNameError) {
|
||||
displayNameError = <p className='input__help error'>{this.state.displayNameError}</p>;
|
||||
}
|
||||
|
||||
let urlErrors = null;
|
||||
let urlHelpText = null;
|
||||
let urlInputClass = 'input-group input-group--limit';
|
||||
if (this.state.urlErrors.length > 0) {
|
||||
urlErrors = <p className='input__help error'>{this.state.urlErrors}</p>;
|
||||
urlInputClass += ' has-error';
|
||||
} else {
|
||||
urlHelpText = (
|
||||
<p className='input__help'>
|
||||
<FormattedMessage
|
||||
id='change_url.helpText'
|
||||
defaultMessage='You can use lowercase letters, numbers, dashes, and underscores.'
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
let serverError = null;
|
||||
if (this.state.serverError) {
|
||||
serverError = <div className='form-group has-error'><div className='as-bs-label control-label'>{this.state.serverError}</div></div>;
|
||||
}
|
||||
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
let urlInputLabel = formatMessage(holders.url);
|
||||
let readOnlyHandleInput = false;
|
||||
if (this.props.channel.name === Constants.DEFAULT_CHANNEL) {
|
||||
urlInputLabel += formatMessage(holders.defaultError);
|
||||
readOnlyHandleInput = true;
|
||||
}
|
||||
|
||||
const fullUrl = this.props.currentTeamUrl + '/channels';
|
||||
const shortUrl = `${getShortenedURL(fullUrl, 35)}/`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
dialogClassName='a11y__modal'
|
||||
show={this.state.show}
|
||||
onHide={this.handleCancel}
|
||||
onEntering={this.handleEntering}
|
||||
onExited={this.props.onExited}
|
||||
role='none'
|
||||
aria-labelledby='renameChannelModalLabel'
|
||||
>
|
||||
<Modal.Header closeButton={true}>
|
||||
<Modal.Title
|
||||
componentClass='h1'
|
||||
id='renameChannelModalLabel'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='rename_channel.title'
|
||||
defaultMessage='Rename Channel'
|
||||
/>
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<form role='form'>
|
||||
<Modal.Body>
|
||||
<div className='form-group'>
|
||||
<label
|
||||
className='control-label'
|
||||
htmlFor='display_name'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='rename_channel.displayName'
|
||||
defaultMessage='Display Name'
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
onChange={this.onDisplayNameChange}
|
||||
type='text'
|
||||
ref={this.getTextbox}
|
||||
id='display_name'
|
||||
className='form-control'
|
||||
placeholder={formatMessage({
|
||||
id: 'rename_channel.displayNameHolder',
|
||||
defaultMessage: 'Enter display name',
|
||||
})}
|
||||
value={this.state.displayName}
|
||||
maxLength={Constants.MAX_CHANNELNAME_LENGTH}
|
||||
aria-label={formatMessage({id: 'rename_channel.displayName', defaultMessage: 'Display Name'}).toLowerCase()}
|
||||
/>
|
||||
{displayNameError}
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label
|
||||
className='control-label'
|
||||
htmlFor='channel_name'
|
||||
>
|
||||
{urlInputLabel}
|
||||
</label>
|
||||
|
||||
<div className={urlInputClass}>
|
||||
<WithTooltip
|
||||
title={fullUrl}
|
||||
>
|
||||
<span className='input-group-addon'>{shortUrl}</span>
|
||||
</WithTooltip>
|
||||
<input
|
||||
onChange={this.onNameChange}
|
||||
type='text'
|
||||
className='form-control'
|
||||
id='channel_name'
|
||||
value={this.state.channelName}
|
||||
maxLength={Constants.MAX_CHANNELNAME_LENGTH}
|
||||
readOnly={readOnlyHandleInput}
|
||||
aria-label={formatMessage({id: 'rename_channel.title', defaultMessage: 'Rename Channel'}).toLowerCase()}
|
||||
/>
|
||||
</div>
|
||||
{urlHelpText}
|
||||
{urlErrors}
|
||||
</div>
|
||||
{serverError}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-tertiary'
|
||||
onClick={this.handleCancel}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='rename_channel.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleSubmit}
|
||||
type='submit'
|
||||
id='save-button'
|
||||
className='btn btn-primary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='rename_channel.save'
|
||||
defaultMessage='Save'
|
||||
/>
|
||||
</button>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(RenameChannelModal);
|
||||
@@ -8,13 +8,14 @@ import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import Constants from 'utils/constants';
|
||||
import {isKeyPressed} from 'utils/keyboard';
|
||||
import {a11yFocus} from 'utils/utils';
|
||||
|
||||
export type Tab = {
|
||||
icon: string | {url: string};
|
||||
iconTitle: string;
|
||||
name: string;
|
||||
uiName: string;
|
||||
newGroup?: boolean;
|
||||
display?: boolean; // Controls whether the tab is displayed, defaults to true
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
@@ -26,13 +27,49 @@ export type Props = {
|
||||
};
|
||||
|
||||
export default class SettingsSidebar extends React.PureComponent<Props> {
|
||||
buttonRefs: Array<RefObject<HTMLButtonElement>>;
|
||||
totalTabs: Tab[];
|
||||
buttonRefs: Map<string, RefObject<HTMLButtonElement>>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.totalTabs = [...this.props.tabs, ...this.props.pluginTabs || []];
|
||||
this.buttonRefs = this.totalTabs.map(() => React.createRef());
|
||||
|
||||
// Initialize an empty Map for button refs
|
||||
this.buttonRefs = new Map();
|
||||
|
||||
// Initialize refs for all tabs
|
||||
this.initializeButtonRefs(props.tabs, props.pluginTabs);
|
||||
}
|
||||
|
||||
// Initialize or update button refs for all tabs
|
||||
private initializeButtonRefs(tabs: Tab[], pluginTabs?: Tab[]) {
|
||||
// Clear existing refs if reinitializing
|
||||
this.buttonRefs.clear();
|
||||
|
||||
// Create refs for all tabs, regardless of display status
|
||||
tabs.forEach((tab) => {
|
||||
this.buttonRefs.set(tab.name, React.createRef());
|
||||
});
|
||||
|
||||
// Create refs for plugin tabs if they exist
|
||||
if (pluginTabs?.length) {
|
||||
pluginTabs.forEach((tab) => {
|
||||
this.buttonRefs.set(tab.name, React.createRef());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update refs when props change
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
// Check if tabs or pluginTabs have changed
|
||||
if (prevProps.tabs !== this.props.tabs || prevProps.pluginTabs !== this.props.pluginTabs) {
|
||||
this.initializeButtonRefs(this.props.tabs, this.props.pluginTabs);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all visible tabs in the correct order
|
||||
private getVisibleTabs(): Tab[] {
|
||||
const visibleTabs = this.props.tabs.filter((tab) => tab.display !== false);
|
||||
const visiblePluginTabs = this.props.pluginTabs?.filter((tab) => tab.display !== false) || [];
|
||||
return [...visibleTabs, ...visiblePluginTabs];
|
||||
}
|
||||
|
||||
public handleClick = (tab: Tab, e: React.MouseEvent) => {
|
||||
@@ -41,27 +78,57 @@ export default class SettingsSidebar extends React.PureComponent<Props> {
|
||||
(e.target as Element).closest('.settings-modal')?.classList.add('display--content');
|
||||
};
|
||||
|
||||
public handleKeyUp = (index: number, e: React.KeyboardEvent) => {
|
||||
public handleKeyUp = (tab: Tab, e: React.KeyboardEvent) => {
|
||||
// Only handle UP and DOWN arrow keys
|
||||
if (!isKeyPressed(e, Constants.KeyCodes.UP) && !isKeyPressed(e, Constants.KeyCodes.DOWN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent default behavior
|
||||
e.preventDefault();
|
||||
|
||||
// Get all visible tabs
|
||||
const visibleTabs = this.getVisibleTabs();
|
||||
|
||||
// If no tabs are visible, do nothing
|
||||
if (visibleTabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the current tab's position in the visible tabs
|
||||
const currentIndex = visibleTabs.findIndex((t) => t.name === tab.name);
|
||||
|
||||
// If tab not found in visible tabs, do nothing
|
||||
if (currentIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextIndex: number;
|
||||
|
||||
// Determine which tab to focus based on the key pressed
|
||||
if (isKeyPressed(e, Constants.KeyCodes.UP)) {
|
||||
if (index > 0) {
|
||||
this.props.updateTab(this.totalTabs[index - 1].name);
|
||||
a11yFocus(this.buttonRefs[index - 1].current);
|
||||
} else {
|
||||
this.props.updateTab(this.totalTabs[this.totalTabs.length - 1].name);
|
||||
a11yFocus(this.buttonRefs[this.buttonRefs.length - 1].current);
|
||||
}
|
||||
} else if (isKeyPressed(e, Constants.KeyCodes.DOWN)) {
|
||||
if (index < this.totalTabs.length - 1) {
|
||||
this.props.updateTab(this.totalTabs[index + 1].name);
|
||||
a11yFocus(this.buttonRefs[index + 1].current);
|
||||
} else {
|
||||
this.props.updateTab(this.totalTabs[0].name);
|
||||
a11yFocus(this.buttonRefs[0].current);
|
||||
}
|
||||
// UP arrow key - move to previous tab or wrap to last
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : visibleTabs.length - 1;
|
||||
} else {
|
||||
// DOWN arrow key - move to next tab or wrap to first
|
||||
nextIndex = currentIndex < visibleTabs.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
|
||||
// Get the target tab
|
||||
const targetTab = visibleTabs[nextIndex];
|
||||
|
||||
// Update the active tab
|
||||
this.props.updateTab(targetTab.name);
|
||||
|
||||
// Focus the target tab button directly
|
||||
const targetButton = this.buttonRefs.get(targetTab.name)?.current;
|
||||
if (targetButton) {
|
||||
// Use direct focus instead of a11yFocus to ensure Cypress tests can detect the focus change
|
||||
targetButton.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private renderTab(tab: Tab, index: number) {
|
||||
private renderTab(tab: Tab) {
|
||||
const key = `${tab.name}_li`;
|
||||
const isActive = this.props.activeTab === tab.name;
|
||||
|
||||
@@ -84,52 +151,63 @@ export default class SettingsSidebar extends React.PureComponent<Props> {
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
ref={this.buttonRefs[index]}
|
||||
id={`${tab.name}Button`}
|
||||
className={classNames('cursor--pointer style--none nav-pills__tab', {active: isActive})}
|
||||
onClick={this.handleClick.bind(null, tab)}
|
||||
onKeyUp={this.handleKeyUp.bind(null, index)}
|
||||
aria-label={tab.uiName.toLowerCase()}
|
||||
role='tab'
|
||||
aria-selected={isActive}
|
||||
tabIndex={!isActive && !this.props.isMobileView ? -1 : 0}
|
||||
aria-controls={`${tab.name}Settings`}
|
||||
>
|
||||
{icon}
|
||||
{tab.uiName}
|
||||
</button>
|
||||
<React.Fragment key={key}>
|
||||
{tab.newGroup && <hr/>}
|
||||
<button
|
||||
data-testid={`${tab.name}-tab-button`}
|
||||
ref={this.buttonRefs.get(tab.name)}
|
||||
id={`${tab.name}Button`}
|
||||
className={classNames('cursor--pointer style--none nav-pills__tab', {active: isActive})}
|
||||
onClick={this.handleClick.bind(null, tab)}
|
||||
onKeyUp={this.handleKeyUp.bind(null, tab)}
|
||||
aria-label={tab.uiName.toLowerCase()}
|
||||
role='tab'
|
||||
aria-selected={isActive}
|
||||
tabIndex={!isActive && !this.props.isMobileView ? -1 : 0}
|
||||
aria-controls={`${tab.name}Settings`}
|
||||
>
|
||||
{icon}
|
||||
{tab.uiName}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const tabList = this.props.tabs.map((tab, index) => this.renderTab(tab, index));
|
||||
// Filter regular tabs and plugin tabs separately for rendering
|
||||
const visibleTabs = this.props.tabs.filter((tab) => tab.display !== false);
|
||||
|
||||
// Map regular tabs
|
||||
const tabList = visibleTabs.map((tab) => this.renderTab(tab));
|
||||
|
||||
let pluginTabList: React.ReactNode;
|
||||
if (this.props.pluginTabs?.length) {
|
||||
pluginTabList = (
|
||||
<>
|
||||
<hr/>
|
||||
<div
|
||||
role='group'
|
||||
aria-labelledby='userSettingsModal.pluginPreferences.header'
|
||||
>
|
||||
const visiblePluginTabs = this.props.pluginTabs.filter((tab) => tab.display !== false);
|
||||
if (visiblePluginTabs.length) {
|
||||
pluginTabList = (
|
||||
<>
|
||||
<hr/>
|
||||
<div
|
||||
key={'plugin preferences heading'}
|
||||
role='heading'
|
||||
className={'header'}
|
||||
aria-level={3}
|
||||
id='userSettingsModal_pluginPreferences_header'
|
||||
role='group'
|
||||
aria-labelledby='userSettingsModal.pluginPreferences.header'
|
||||
>
|
||||
<FormattedMessage
|
||||
id={'userSettingsModal.pluginPreferences.header'}
|
||||
defaultMessage={'PLUGIN PREFERENCES'}
|
||||
/>
|
||||
<div
|
||||
key={'plugin preferences heading'}
|
||||
role='heading'
|
||||
className={'header'}
|
||||
aria-level={3}
|
||||
id='userSettingsModal_pluginPreferences_header'
|
||||
>
|
||||
<FormattedMessage
|
||||
id={'userSettingsModal.pluginPreferences.header'}
|
||||
defaultMessage={'PLUGIN PREFERENCES'}
|
||||
/>
|
||||
</div>
|
||||
{visiblePluginTabs.map((tab) => this.renderTab(tab))}
|
||||
</div>
|
||||
{this.props.pluginTabs.map((tab, index) => this.renderTab(tab, index + this.props.tabs.length))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -121,7 +121,6 @@ Object {
|
||||
role="alert"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon error icon-alert-circle-outline"
|
||||
/>
|
||||
<span>
|
||||
|
||||
@@ -493,6 +493,7 @@ export default class SuggestionBox extends React.PureComponent {
|
||||
});
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
this.props.onKeyPress(ke);
|
||||
@@ -506,6 +507,11 @@ export default class SuggestionBox extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -578,6 +584,7 @@ export default class SuggestionBox extends React.PureComponent {
|
||||
this.selectNext();
|
||||
e.preventDefault();
|
||||
} else if ((Keyboard.isKeyPressed(e, KeyCodes.ENTER) && !ctrlOrMetaKeyPressed) || (this.props.completeOnTab && Keyboard.isKeyPressed(e, KeyCodes.TAB))) {
|
||||
e.stopPropagation();
|
||||
let matchedPretext = '';
|
||||
for (let i = 0; i < this.state.terms.length; i++) {
|
||||
if (this.state.terms[i] === this.state.selection) {
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('components/TeamSettings', () => {
|
||||
const newDomainText = screen.getByText(newDomain);
|
||||
expect(newDomainText).toBeInTheDocument();
|
||||
|
||||
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
|
||||
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
|
||||
await act(async () => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('components/TeamSettings', () => {
|
||||
userEvent.upload(input, file);
|
||||
});
|
||||
|
||||
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
|
||||
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
|
||||
await act(async () => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
@@ -87,7 +87,7 @@ describe('components/TeamSettings', () => {
|
||||
userEvent.upload(input, file);
|
||||
});
|
||||
|
||||
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
|
||||
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
|
||||
await act(async () => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
@@ -107,7 +107,7 @@ describe('components/TeamSettings', () => {
|
||||
act(() => {
|
||||
userEvent.clear(input);
|
||||
});
|
||||
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
|
||||
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
|
||||
await act(async () => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
@@ -124,7 +124,7 @@ describe('components/TeamSettings', () => {
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'a');
|
||||
});
|
||||
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
|
||||
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
|
||||
await act(async () => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
@@ -139,7 +139,7 @@ describe('components/TeamSettings', () => {
|
||||
const input = screen.getByTestId('teamNameInput');
|
||||
userEvent.clear(input);
|
||||
userEvent.type(input, 'new_team_name');
|
||||
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
|
||||
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
|
||||
await act(async () => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
@@ -155,7 +155,7 @@ describe('components/TeamSettings', () => {
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'new_team_description');
|
||||
});
|
||||
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
|
||||
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
|
||||
await act(async () => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
@@ -172,7 +172,7 @@ describe('components/TeamSettings', () => {
|
||||
userEvent.type(nameInput, 'new_team_name');
|
||||
userEvent.clear(descriptionInput);
|
||||
userEvent.type(descriptionInput, 'new_team_description');
|
||||
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
|
||||
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
|
||||
await act(async () => {
|
||||
userEvent.click(saveButton);
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ export type Props = {
|
||||
openWhenEmpty?: boolean;
|
||||
priorityProfiles?: UserProfile[];
|
||||
hasLabels?: boolean;
|
||||
hasError?: boolean;
|
||||
isInEditMode?: boolean;
|
||||
};
|
||||
|
||||
@@ -201,7 +202,6 @@ export default class Textbox extends React.PureComponent<Props> {
|
||||
if (!prevProps.preview && this.props.preview) {
|
||||
this.preview.current?.focus();
|
||||
}
|
||||
|
||||
this.updateSuggestions(prevProps);
|
||||
}
|
||||
|
||||
@@ -278,13 +278,17 @@ export default class Textbox extends React.PureComponent<Props> {
|
||||
textboxClassName += ' textarea--has-labels';
|
||||
}
|
||||
|
||||
if (this.props.hasError) {
|
||||
textboxClassName += ' textarea--has-errors';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.wrapper}
|
||||
className={classNames('textarea-wrapper', {'textarea-wrapper-preview': this.props.preview})}
|
||||
>
|
||||
<div
|
||||
tabIndex={this.props.tabIndex || 0}
|
||||
tabIndex={this.props.tabIndex}
|
||||
ref={this.preview}
|
||||
className={classNames('form-control custom-textarea textbox-preview-area', {'textarea--has-labels': this.props.hasLabels})}
|
||||
onKeyPress={this.props.onKeyPress}
|
||||
|
||||
@@ -70,6 +70,7 @@ exports[`DesktopNotificationSettings should match snapshot, on max setting 1`] =
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
class="a11y--active a11y--focused"
|
||||
type="checkbox"
|
||||
/>
|
||||
Notify me about replies to threads I'm following
|
||||
@@ -293,6 +294,7 @@ exports[`DesktopNotificationSettings should not show desktop thread notification
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
class="a11y--active a11y--focused"
|
||||
type="checkbox"
|
||||
/>
|
||||
Use different settings for my mobile devices
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
@import 'utils/mixins';
|
||||
|
||||
.AdvancedTextbox {
|
||||
position: relative;
|
||||
|
||||
&__wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__label {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -6px; // Adjusted from -8px to -6px to center with border
|
||||
left: 12px;
|
||||
display: flex;
|
||||
width: auto;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background-color: var(--center-channel-bg);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition-duration: 0.15s;
|
||||
transition-property: opacity, transform, color;
|
||||
transition-timing-function: ease-in-out;
|
||||
white-space: nowrap;
|
||||
|
||||
&--active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&--focused {
|
||||
color: var(--button-bg); // Change color to match the border when focused
|
||||
}
|
||||
|
||||
&--error {
|
||||
color: var(--error-text); // Change color to red when there's an error
|
||||
}
|
||||
|
||||
// Error state takes precedence over focus state
|
||||
&--focused.AdvancedTextbox__label--error {
|
||||
color: var(--error-text);
|
||||
}
|
||||
}
|
||||
|
||||
.textarea-wrapper {
|
||||
position: relative;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--center-channel-color-rgb), 0.48);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--button-bg);
|
||||
}
|
||||
|
||||
// forcing this styles since textarea has ohter styles that affect the post input, so keeping it here isolated inside advanced textbox
|
||||
.textarea--has-errors{
|
||||
border-color: var(--error-text) !important;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--error-text) !important;
|
||||
box-shadow: 0 0 0 1px var(--error-text) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Override default textarea styles to work with our border
|
||||
.custom-textarea {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__error-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__error-message {
|
||||
display: flex;
|
||||
width: 90%;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
color: var(--error-text);
|
||||
font-size: 12px;
|
||||
|
||||
i {
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&__character-count {
|
||||
margin-top: 4px;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
|
||||
&.exceeds-limit {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
&.below-minimum {
|
||||
color: var(--error-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin-top: 4px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {render, screen, fireEvent} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import type {ComponentProps} from 'react';
|
||||
|
||||
import AdvancedTextbox from './advanced_textbox';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('components/textbox', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation((props) => (
|
||||
<textarea
|
||||
data-testid='mock-textbox'
|
||||
id={props.id}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
onKeyPress={props.onKeyPress}
|
||||
placeholder={props.createMessage}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
/>
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('components/advanced_text_editor/show_formatting/show_formatting', () => (
|
||||
jest.fn().mockImplementation((props) => (
|
||||
<button
|
||||
data-testid='mock-show-format'
|
||||
onClick={props.onClick}
|
||||
className={props.active ? 'active' : ''}
|
||||
>
|
||||
{'Toggle Preview'}
|
||||
</button>
|
||||
))
|
||||
));
|
||||
|
||||
describe('AdvancedTextbox', () => {
|
||||
const defaultProps: ComponentProps<typeof AdvancedTextbox> = {
|
||||
id: 'test-textbox',
|
||||
value: 'Initial value',
|
||||
channelId: 'channel1',
|
||||
onChange: jest.fn(),
|
||||
onKeyPress: jest.fn(),
|
||||
createMessage: 'Enter text here',
|
||||
maxLength: 1000,
|
||||
preview: false,
|
||||
togglePreview: jest.fn(),
|
||||
useChannelMentions: false,
|
||||
descriptionMessage: 'This is a description',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders correctly with all props', () => {
|
||||
render(<AdvancedTextbox {...defaultProps}/>);
|
||||
|
||||
// Check if textbox is rendered with correct value
|
||||
const textbox = screen.getByTestId('mock-textbox');
|
||||
expect(textbox).toBeInTheDocument();
|
||||
expect(textbox).toHaveValue('Initial value');
|
||||
|
||||
// Check if description is rendered
|
||||
expect(screen.getByText('This is a description')).toBeInTheDocument();
|
||||
|
||||
// Check if preview toggle button is rendered
|
||||
expect(screen.getByTestId('mock-show-format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onChange when text is changed', async () => {
|
||||
render(<AdvancedTextbox {...defaultProps}/>);
|
||||
|
||||
const textbox = screen.getByTestId('mock-textbox');
|
||||
await userEvent.clear(textbox);
|
||||
await userEvent.type(textbox, 'New text');
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('calls onKeyPress when a key is pressed', async () => {
|
||||
render(<AdvancedTextbox {...defaultProps}/>);
|
||||
|
||||
const textbox = screen.getByTestId('mock-textbox');
|
||||
await userEvent.type(textbox, '{enter}');
|
||||
|
||||
expect(defaultProps.onKeyPress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('calls togglePreview when preview button is clicked', async () => {
|
||||
render(<AdvancedTextbox {...defaultProps}/>);
|
||||
|
||||
const previewButton = screen.getByTestId('mock-show-format');
|
||||
await userEvent.click(previewButton);
|
||||
|
||||
expect(defaultProps.togglePreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders with preview mode active when specified', () => {
|
||||
render(<AdvancedTextbox {...{...defaultProps, preview: true}}/>);
|
||||
|
||||
const previewButton = screen.getByTestId('mock-show-format');
|
||||
expect(previewButton).toHaveClass('active');
|
||||
});
|
||||
|
||||
test('renders without description when not provided', () => {
|
||||
render(<AdvancedTextbox {...{...defaultProps, descriptionMessage: undefined}}/>);
|
||||
|
||||
expect(screen.queryByTestId('mm-modal-generic-section-item__description')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles JSX element as descriptionMessage', () => {
|
||||
const jsxDescription = <span data-testid='jsx-description'>{'JSX Description'}</span>;
|
||||
render(<AdvancedTextbox {...{...defaultProps, descriptionMessage: jsxDescription}}/>);
|
||||
|
||||
expect(screen.getByTestId('jsx-description')).toBeInTheDocument();
|
||||
expect(screen.getByText('JSX Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays character count when showCharacterCount is true and there is error', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
maxLength: 10,
|
||||
value: 'Short text',
|
||||
showCharacterCount: true,
|
||||
};
|
||||
const {rerender} = render(<AdvancedTextbox {...props}/>);
|
||||
|
||||
rerender(<AdvancedTextbox {...{...props, value: 'This text is too long and exceeds the limit'}}/>);
|
||||
|
||||
expect(screen.getByText('43/10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows error when text exceeds character limit', async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
maxLength: 10,
|
||||
value: 'Short text',
|
||||
showCharacterCount: true,
|
||||
};
|
||||
|
||||
const {rerender} = render(<AdvancedTextbox {...props}/>);
|
||||
|
||||
// Initially under the limit
|
||||
expect(screen.queryByText(/exceeds the maximum character limit/)).not.toBeInTheDocument();
|
||||
|
||||
// Update with text that exceeds the limit
|
||||
rerender(<AdvancedTextbox {...{...props, value: 'This text is too long and exceeds the limit'}}/>);
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText(/exceeds the maximum character limit/)).toBeInTheDocument();
|
||||
expect(screen.getByText('This text is too long and exceeds the limit'.length + '/' + props.maxLength)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows error when text is below minimum character limit', async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
maxLength: 100,
|
||||
minLength: 10,
|
||||
value: '',
|
||||
showCharacterCount: true,
|
||||
};
|
||||
|
||||
const {rerender} = render(<AdvancedTextbox {...props}/>);
|
||||
|
||||
// Empty text should not trigger min length error
|
||||
expect(screen.queryByText(/must be at least/)).not.toBeInTheDocument();
|
||||
|
||||
// Update with text that is below the minimum limit
|
||||
rerender(<AdvancedTextbox {...{...props, value: 'Too short'}}/>);
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText(/must be at least 10 characters/)).toBeInTheDocument();
|
||||
expect(screen.getByText('9/10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('allows custom error message for minimum length', async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
maxLength: 100,
|
||||
minLength: 10,
|
||||
minLengthErrorMessage: 'Custom minimum length error',
|
||||
value: 'Short',
|
||||
showCharacterCount: true,
|
||||
};
|
||||
|
||||
render(<AdvancedTextbox {...props}/>);
|
||||
|
||||
// Should show custom error message
|
||||
expect(screen.getByText('Custom minimum length error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clears minimum length error when text meets requirements', async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
maxLength: 100,
|
||||
minLength: 5,
|
||||
value: 'abc',
|
||||
showCharacterCount: true,
|
||||
};
|
||||
|
||||
const {rerender} = render(<AdvancedTextbox {...props}/>);
|
||||
|
||||
// Initially below the minimum
|
||||
expect(screen.getByText(/must be at least 5 characters/)).toBeInTheDocument();
|
||||
|
||||
// Update with text that meets the minimum
|
||||
rerender(<AdvancedTextbox {...{...props, value: 'abcdef'}}/>);
|
||||
|
||||
// Error should be cleared
|
||||
expect(screen.queryByText(/must be at least 5 characters/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render preview toggle button when readOnly is true', () => {
|
||||
render(<AdvancedTextbox {...{...defaultProps, readOnly: true}}/>);
|
||||
|
||||
// Preview toggle button should not be in the document
|
||||
expect(screen.queryByTestId('mock-show-format')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('forces preview mode when readOnly is true regardless of preview prop', () => {
|
||||
// Get reference to the mocked Textbox component
|
||||
const TextboxMock = require('components/textbox').default;
|
||||
|
||||
// Clear previous calls to the mock
|
||||
TextboxMock.mockClear();
|
||||
|
||||
// Render with readOnly true and preview false
|
||||
render(<AdvancedTextbox {...{...defaultProps, readOnly: true, preview: false}}/>);
|
||||
|
||||
// Verify Textbox was called with preview=true
|
||||
expect(TextboxMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
preview: true,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
// Tests for the floating label functionality
|
||||
test('does not render label when name prop is not provided', () => {
|
||||
render(<AdvancedTextbox {...defaultProps}/>);
|
||||
|
||||
// Label should not be in the document
|
||||
expect(document.querySelector('.AdvancedTextbox__label')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders label when name prop is provided', () => {
|
||||
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label'}}/>);
|
||||
|
||||
// Label should be in the document
|
||||
const label = document.querySelector('.AdvancedTextbox__label');
|
||||
expect(label).toBeInTheDocument();
|
||||
expect(label).toHaveTextContent('Test Label');
|
||||
});
|
||||
|
||||
test('applies active class to label when component has value', () => {
|
||||
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label', value: 'Some value'}}/>);
|
||||
|
||||
// Label should have active class
|
||||
const label = document.querySelector('.AdvancedTextbox__label');
|
||||
expect(label).toHaveClass('AdvancedTextbox__label--active');
|
||||
});
|
||||
|
||||
test('applies active class to label when component is focused', () => {
|
||||
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label', value: ''}}/>);
|
||||
|
||||
// Initially label should not have active class
|
||||
let label = document.querySelector('.AdvancedTextbox__label');
|
||||
expect(label).not.toHaveClass('AdvancedTextbox__label--active');
|
||||
|
||||
// Focus the textbox
|
||||
const textbox = screen.getByTestId('mock-textbox');
|
||||
fireEvent.focus(textbox);
|
||||
|
||||
// Label should now have active class
|
||||
label = document.querySelector('.AdvancedTextbox__label');
|
||||
expect(label).toHaveClass('AdvancedTextbox__label--active');
|
||||
});
|
||||
|
||||
test('removes active class from label when component loses focus and has no value', () => {
|
||||
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label', value: ''}}/>);
|
||||
|
||||
// Focus the textbox
|
||||
const textbox = screen.getByTestId('mock-textbox');
|
||||
fireEvent.focus(textbox);
|
||||
|
||||
// Label should have active class
|
||||
let label = document.querySelector('.AdvancedTextbox__label');
|
||||
expect(label).toHaveClass('AdvancedTextbox__label--active');
|
||||
|
||||
// Blur the textbox
|
||||
fireEvent.blur(textbox);
|
||||
|
||||
// Label should not have active class
|
||||
label = document.querySelector('.AdvancedTextbox__label');
|
||||
expect(label).not.toHaveClass('AdvancedTextbox__label--active');
|
||||
});
|
||||
|
||||
test('keeps active class on label when component loses focus but has value', () => {
|
||||
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label', value: 'Some value'}}/>);
|
||||
|
||||
// Focus the textbox
|
||||
const textbox = screen.getByTestId('mock-textbox');
|
||||
fireEvent.focus(textbox);
|
||||
|
||||
// Label should have active class
|
||||
let label = document.querySelector('.AdvancedTextbox__label');
|
||||
expect(label).toHaveClass('AdvancedTextbox__label--active');
|
||||
|
||||
// Blur the textbox
|
||||
fireEvent.blur(textbox);
|
||||
|
||||
// Label should still have active class because there's a value
|
||||
label = document.querySelector('.AdvancedTextbox__label');
|
||||
expect(label).toHaveClass('AdvancedTextbox__label--active');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import ShowFormat from 'components/advanced_text_editor/show_formatting/show_formatting';
|
||||
import Textbox from 'components/textbox';
|
||||
import type {TextboxElement} from 'components/textbox';
|
||||
|
||||
import './advanced_textbox.scss';
|
||||
|
||||
type AdvancedTextboxProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
channelId: string;
|
||||
onChange: (e: React.ChangeEvent<TextboxElement>) => void;
|
||||
onKeyPress: (e: React.KeyboardEvent<TextboxElement>) => void;
|
||||
createMessage: string;
|
||||
maxLength: number;
|
||||
minLength?: number;
|
||||
minLengthErrorMessage?: string;
|
||||
preview: boolean;
|
||||
togglePreview: () => void;
|
||||
useChannelMentions?: boolean;
|
||||
descriptionMessage?: JSX.Element | string;
|
||||
hasError?: boolean;
|
||||
errorMessage?: string | JSX.Element;
|
||||
onValidate?: (value: string) => { isValid: boolean; errorMessage?: string };
|
||||
showCharacterCount?: boolean;
|
||||
readOnly?: boolean;
|
||||
name?: string; // Added name prop for floating label
|
||||
};
|
||||
|
||||
const AdvancedTextbox = ({
|
||||
id,
|
||||
value,
|
||||
channelId,
|
||||
onChange,
|
||||
onKeyPress,
|
||||
createMessage,
|
||||
maxLength,
|
||||
minLength,
|
||||
minLengthErrorMessage,
|
||||
preview,
|
||||
togglePreview,
|
||||
useChannelMentions = false,
|
||||
descriptionMessage,
|
||||
hasError,
|
||||
errorMessage,
|
||||
onValidate,
|
||||
showCharacterCount = false,
|
||||
readOnly = false,
|
||||
name,
|
||||
}: AdvancedTextboxProps) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const [internalError, setInternalError] = useState<string | JSX.Element | undefined>(errorMessage);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Derived values
|
||||
const isTooLong = value.length > maxLength;
|
||||
const isTooShort = minLength !== undefined && value.length > 0 && value.length < minLength;
|
||||
|
||||
// Update internal error when prop changes or when validation state changes
|
||||
useEffect(() => {
|
||||
if (errorMessage) {
|
||||
setInternalError(errorMessage);
|
||||
} else if (isTooLong) {
|
||||
setInternalError(formatMessage(
|
||||
{id: 'advanced_textbox.max_length_error', defaultMessage: 'Text exceeds the maximum character limit of {maxLength} characters.'},
|
||||
{maxLength},
|
||||
));
|
||||
} else if (isTooShort) {
|
||||
setInternalError(minLengthErrorMessage || formatMessage(
|
||||
{id: 'advanced_textbox.min_length_error', defaultMessage: 'Text must be at least {minLength} characters.'},
|
||||
{minLength},
|
||||
));
|
||||
} else {
|
||||
setInternalError(undefined);
|
||||
}
|
||||
}, [errorMessage, isTooLong, isTooShort, maxLength, minLength, minLengthErrorMessage, formatMessage]);
|
||||
|
||||
// Handle focus events
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
// Handle validation on change
|
||||
const handleChange = (e: React.ChangeEvent<TextboxElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
// Run validation if provided
|
||||
if (onValidate) {
|
||||
const validationResult = onValidate(newValue);
|
||||
if (validationResult.isValid === false) {
|
||||
setInternalError(validationResult.errorMessage);
|
||||
} else {
|
||||
setInternalError(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Call original onChange
|
||||
onChange(e);
|
||||
};
|
||||
|
||||
let localPreview = preview;
|
||||
if (readOnly) {
|
||||
localPreview = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='AdvancedTextbox'>
|
||||
<div className='AdvancedTextbox__wrapper'>
|
||||
{name && (
|
||||
<div className={`AdvancedTextbox__label ${(value || isFocused) ? 'AdvancedTextbox__label--active' : ''} ${isFocused ? 'AdvancedTextbox__label--focused' : ''} ${hasError || internalError ? 'AdvancedTextbox__label--error' : ''}`}>
|
||||
{name}
|
||||
</div>
|
||||
)}
|
||||
<Textbox
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyPress={onKeyPress}
|
||||
supportsCommands={false}
|
||||
suggestionListPosition='bottom'
|
||||
createMessage={createMessage}
|
||||
channelId={channelId}
|
||||
id={id}
|
||||
characterLimit={maxLength}
|
||||
preview={localPreview}
|
||||
useChannelMentions={useChannelMentions}
|
||||
hasError={hasError}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
{!readOnly && value.trim().length > 0 && (
|
||||
<ShowFormat
|
||||
onClick={togglePreview}
|
||||
active={preview}
|
||||
/>)
|
||||
}
|
||||
|
||||
<div className='AdvancedTextbox__error-wrapper'>
|
||||
{/* Error message display */}
|
||||
{internalError && (
|
||||
<div className='AdvancedTextbox__error-message'>
|
||||
<i className='icon icon-alert-circle-outline'/>
|
||||
<span>{internalError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Character count display */}
|
||||
{showCharacterCount && (isTooLong || isTooShort || internalError) && (
|
||||
<div
|
||||
className={classNames('AdvancedTextbox__character-count', {
|
||||
'exceeds-limit': isTooLong,
|
||||
'below-minimum': isTooShort,
|
||||
})}
|
||||
>
|
||||
{value.length}{'/'}
|
||||
{isTooShort ? minLength : maxLength}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message display */}
|
||||
{(descriptionMessage && !internalError) && (
|
||||
<p
|
||||
data-testid='AdvancedTextbox__description'
|
||||
className='AdvancedTextbox__description'
|
||||
>
|
||||
{descriptionMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedTextbox;
|
||||
@@ -1,27 +1,27 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/widgets/inputs/Input should match snapshot 1`] = `
|
||||
<div
|
||||
className="Input_container"
|
||||
>
|
||||
<fieldset
|
||||
className="Input_fieldset"
|
||||
<div>
|
||||
<div
|
||||
class="Input_container"
|
||||
>
|
||||
<legend
|
||||
className="Input_legend"
|
||||
/>
|
||||
<div
|
||||
className="Input_wrapper"
|
||||
<fieldset
|
||||
class="Input_fieldset"
|
||||
>
|
||||
<input
|
||||
aria-invalid={false}
|
||||
className="Input form-control medium"
|
||||
id="input_"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
<legend
|
||||
class="Input_legend"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div
|
||||
class="Input_wrapper"
|
||||
>
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="Input form-control medium"
|
||||
id="input_"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {shallow} from 'enzyme';
|
||||
import {act, screen} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
|
||||
import Input from './input';
|
||||
|
||||
// Mock the WithTooltip component to avoid ref issues
|
||||
jest.mock('components/with_tooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({children}: {children: React.ReactNode}) => children,
|
||||
}));
|
||||
|
||||
// Mock the CloseCircleIcon component to avoid ref issues
|
||||
jest.mock('@mattermost/compass-icons/components', () => ({
|
||||
CloseCircleIcon: () => <div data-testid='close-circle-icon'/>,
|
||||
}));
|
||||
|
||||
describe('components/widgets/inputs/Input', () => {
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
const {container} = renderWithContext(
|
||||
<Input/>,
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should render with clearable enabled', () => {
|
||||
test('should render with clearable enabled', async () => {
|
||||
const value = 'value';
|
||||
const clearableTooltipText = 'tooltip text';
|
||||
const onClear = jest.fn();
|
||||
|
||||
const wrapper = shallow(
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={value}
|
||||
clearable={true}
|
||||
@@ -31,18 +43,232 @@ describe('components/widgets/inputs/Input', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const clear = wrapper.find('.Input__clear');
|
||||
expect(clear.length).toEqual(1);
|
||||
expect(wrapper.find('CloseCircleIcon').length).toEqual(1);
|
||||
// Find the input with the value
|
||||
const inputElement = screen.getByDisplayValue(value);
|
||||
expect(inputElement).toBeInTheDocument();
|
||||
|
||||
const tooltip = wrapper.find(WithTooltip);
|
||||
expect(tooltip.length).toEqual(1);
|
||||
// Find the clear button's div container
|
||||
const iconElement = screen.getByTestId('close-circle-icon');
|
||||
expect(iconElement).toBeInTheDocument();
|
||||
|
||||
const titleProp = tooltip.prop('title');
|
||||
expect(titleProp).toEqual(clearableTooltipText);
|
||||
|
||||
clear.first().simulate('mousedown');
|
||||
// Click directly on the icon element
|
||||
await act(async () => {
|
||||
userEvent.click(iconElement);
|
||||
});
|
||||
|
||||
// Verify onClear was called
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('minLength validation', () => {
|
||||
test('should show error styling when input is empty with minLength set', () => {
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={''}
|
||||
minLength={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check for the +X indicator
|
||||
const indicator = screen.getByText('+5');
|
||||
expect(indicator).toBeInTheDocument();
|
||||
|
||||
// Check for error styling
|
||||
const fieldset = screen.getByRole('group');
|
||||
expect(fieldset).toHaveClass('Input_fieldset___error');
|
||||
});
|
||||
|
||||
test('should show error styling and message when input length < minLength', async () => {
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={'abc'}
|
||||
minLength={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the input
|
||||
const inputElement = screen.getByDisplayValue('abc');
|
||||
|
||||
// Simulate change to trigger validation
|
||||
await act(async () => {
|
||||
// Clear the input first
|
||||
userEvent.clear(inputElement);
|
||||
|
||||
// Then type the new value
|
||||
userEvent.type(inputElement, 'abc');
|
||||
});
|
||||
|
||||
// Check for the +X indicator
|
||||
const indicator = screen.getByText('+2');
|
||||
expect(indicator).toBeInTheDocument();
|
||||
|
||||
// Check for error styling
|
||||
const fieldset = screen.getByRole('group');
|
||||
expect(fieldset).toHaveClass('Input_fieldset___error');
|
||||
|
||||
// Check for error message
|
||||
const errorMessage = await screen.findByText(/Must be at least 5 characters/i);
|
||||
expect(errorMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not show error styling when input length >= minLength', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={'abcde'}
|
||||
minLength={5}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// With exactly 5 characters and minLength of 5, there should be no error
|
||||
|
||||
// Check that the +X indicator is not present
|
||||
expect(screen.queryByText(/\+\d+/)).not.toBeInTheDocument();
|
||||
|
||||
// Check that error message is not present
|
||||
expect(screen.queryByText(/Must be at least 5 characters/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLength (limit) validation', () => {
|
||||
test('should show error styling when input length > limit', async () => {
|
||||
// Create a mock onChange function
|
||||
const onChange = jest.fn();
|
||||
|
||||
// Render with a value that exceeds the limit
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={'abcdef'}
|
||||
limit={5}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// With 6 characters and limit of 5, there should be an error
|
||||
|
||||
// Check for the -X indicator
|
||||
const indicator = screen.getByText('-1');
|
||||
expect(indicator).toBeInTheDocument();
|
||||
|
||||
// Check for error styling
|
||||
const fieldset = screen.getByRole('group');
|
||||
expect(fieldset).toHaveClass('Input_fieldset___error');
|
||||
});
|
||||
|
||||
test('should not show error styling when input length <= limit', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={'abcde'}
|
||||
limit={5}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// With exactly 5 characters and limit of 5, there should be no error
|
||||
|
||||
// Check that the -X indicator is not present
|
||||
expect(screen.queryByText(/-\d+/)).not.toBeInTheDocument();
|
||||
|
||||
// Check that error message is not present
|
||||
expect(screen.queryByText(/Must be no more than 5 characters/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('required field validation', () => {
|
||||
test('should not show error on empty required input until blur', async () => {
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={''}
|
||||
required={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the input
|
||||
const inputElement = screen.getByRole('textbox');
|
||||
|
||||
// Check that error message is not present before blur
|
||||
expect(screen.queryByText(/This field is required/i)).not.toBeInTheDocument();
|
||||
|
||||
// Simulate blur to trigger validation
|
||||
await act(async () => {
|
||||
inputElement.focus();
|
||||
inputElement.blur();
|
||||
});
|
||||
|
||||
// Check for error message after blur
|
||||
const errorMessage = await screen.findByText(/This field is required/i);
|
||||
expect(errorMessage).toBeInTheDocument();
|
||||
|
||||
// Check for error styling
|
||||
const fieldset = screen.getByRole('group');
|
||||
expect(fieldset).toHaveClass('Input_fieldset___error');
|
||||
});
|
||||
|
||||
test('should not show error on non-empty required input', async () => {
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={'abc'}
|
||||
required={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the input
|
||||
const inputElement = screen.getByDisplayValue('abc');
|
||||
|
||||
// Simulate blur to trigger validation
|
||||
await act(async () => {
|
||||
inputElement.focus();
|
||||
inputElement.blur();
|
||||
});
|
||||
|
||||
// Check that error message is not present
|
||||
expect(screen.queryByText(/This field is required/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interaction between validations', () => {
|
||||
test('should prioritize required validation over minLength on blur for empty input', async () => {
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={''}
|
||||
required={true}
|
||||
minLength={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the input
|
||||
const inputElement = screen.getByRole('textbox');
|
||||
|
||||
// Simulate blur to trigger validation
|
||||
await act(async () => {
|
||||
inputElement.focus();
|
||||
inputElement.blur();
|
||||
});
|
||||
|
||||
// Check for required error message
|
||||
const errorMessage = await screen.findByText(/This field is required/i);
|
||||
expect(errorMessage).toBeInTheDocument();
|
||||
|
||||
// Check that minLength error message is not present
|
||||
expect(screen.queryByText(/Must be at least 5 characters/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show both minLength indicator and limit indicator when applicable', () => {
|
||||
renderWithContext(
|
||||
<Input
|
||||
value={'abc'}
|
||||
minLength={5}
|
||||
limit={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check for the +X indicator for minLength
|
||||
const indicator = screen.getByText('+2');
|
||||
expect(indicator).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
|
||||
wrapperClassName?: string;
|
||||
inputClassName?: string;
|
||||
limit?: number;
|
||||
minLength?: number;
|
||||
useLegend?: boolean;
|
||||
customMessage?: CustomMessageInputType;
|
||||
inputSize?: SIZE;
|
||||
@@ -61,6 +62,7 @@ const Input = React.forwardRef((
|
||||
wrapperClassName,
|
||||
inputClassName,
|
||||
limit,
|
||||
minLength,
|
||||
customMessage,
|
||||
maxLength,
|
||||
inputSize = SIZE.MEDIUM,
|
||||
@@ -99,6 +101,21 @@ const Input = React.forwardRef((
|
||||
}
|
||||
}, [customMessage]);
|
||||
|
||||
// Re-validate input when value changes (e.g. when a parent component sets a new value,not just when the user types)
|
||||
useEffect(() => {
|
||||
// Only run validation if we're not focused (to avoid validating during typing)
|
||||
// and if there is currently an error displayed
|
||||
if (!focused && customInputLabel?.type === 'error') {
|
||||
// Clear error state when value changes
|
||||
setCustomInputLabel(null);
|
||||
|
||||
// Re-run validation to check if the new value is valid
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
validateInput();
|
||||
}
|
||||
}
|
||||
}, [value]); // Only run when value changes
|
||||
|
||||
const handleOnFocus = (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFocused(true);
|
||||
|
||||
@@ -117,6 +134,7 @@ const Input = React.forwardRef((
|
||||
};
|
||||
|
||||
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// Clear custom messages when user types
|
||||
setCustomInputLabel(null);
|
||||
|
||||
if (onChange) {
|
||||
@@ -131,17 +149,37 @@ const Input = React.forwardRef((
|
||||
};
|
||||
|
||||
const validateInput = () => {
|
||||
if (!required || (value !== null && value !== '')) {
|
||||
return;
|
||||
// Only check for required field validation on blur
|
||||
// Length validation is handled through derived values in the render function
|
||||
if (required && (value === null || value === '')) {
|
||||
const validationErrorMsg = formatMessage({id: 'widget.input.required', defaultMessage: 'This field is required'});
|
||||
setCustomInputLabel({type: ItemStatus.ERROR, value: validationErrorMsg});
|
||||
}
|
||||
const validationErrorMsg = formatMessage({id: 'widget.input.required', defaultMessage: 'This field is required'});
|
||||
setCustomInputLabel({type: ItemStatus.ERROR, value: validationErrorMsg});
|
||||
};
|
||||
|
||||
const showLegend = Boolean(focused || value);
|
||||
const error = customInputLabel?.type === ItemStatus.ERROR;
|
||||
const warning = customInputLabel?.type === ItemStatus.WARNING;
|
||||
const limitExceeded = limit && value && !Array.isArray(value) ? value.toString().length - limit : 0;
|
||||
const minLengthNotMet = minLength && value !== undefined && !Array.isArray(value) ? minLength - value.toString().length : (minLength || 0);
|
||||
|
||||
// Show min length error even when the input is empty (to match existing behavior in tests)
|
||||
const isMinLengthError = minLengthNotMet > 0;
|
||||
const isMaxLengthError = limitExceeded > 0;
|
||||
|
||||
// Generate derived error messages
|
||||
let derivedErrorMessage: React.ReactNode | null = null;
|
||||
if (isMaxLengthError && !customInputLabel) {
|
||||
derivedErrorMessage = formatMessage(
|
||||
{id: 'widget.input.max_length', defaultMessage: 'Must be no more than {limit} characters'},
|
||||
{limit},
|
||||
);
|
||||
} else if (isMinLengthError && !customInputLabel) {
|
||||
derivedErrorMessage = formatMessage(
|
||||
{id: 'widget.input.min_length', defaultMessage: 'Must be at least {minLength} characters'},
|
||||
{minLength},
|
||||
);
|
||||
}
|
||||
|
||||
const clearButton = value && clearable ? (
|
||||
<div
|
||||
@@ -207,7 +245,7 @@ const Input = React.forwardRef((
|
||||
<div className={classNames('Input_container', containerClassName, {disabled})}>
|
||||
<fieldset
|
||||
className={classNames('Input_fieldset', className, {
|
||||
Input_fieldset___error: error || hasError || limitExceeded > 0,
|
||||
Input_fieldset___error: hasError || limitExceeded > 0 || isMinLengthError || customInputLabel?.type === 'error',
|
||||
Input_fieldset___legend: showLegend,
|
||||
})}
|
||||
>
|
||||
@@ -225,28 +263,32 @@ const Input = React.forwardRef((
|
||||
{'-'}{limitExceeded}
|
||||
</span>
|
||||
)}
|
||||
{isMinLengthError && (
|
||||
<span className='Input_limit-exceeded'>
|
||||
{'+'}{minLengthNotMet}
|
||||
</span>
|
||||
)}
|
||||
{inputSuffix}
|
||||
{clearButton}
|
||||
</div>
|
||||
{addon}
|
||||
</fieldset>
|
||||
{customInputLabel && (
|
||||
{/* Display custom or derived error messages */}
|
||||
{(customInputLabel || derivedErrorMessage) && (
|
||||
<div
|
||||
className={`Input___customMessage Input___${customInputLabel?.type || 'error'}`}
|
||||
id={errorId}
|
||||
className={`Input___customMessage Input___${customInputLabel.type}`}
|
||||
role={error || warning ? 'alert' : undefined}
|
||||
>
|
||||
{customInputLabel.type && (
|
||||
<i
|
||||
className={classNames(`icon ${customInputLabel.type}`, {
|
||||
'icon-alert-outline': customInputLabel.type === ItemStatus.WARNING,
|
||||
'icon-alert-circle-outline': customInputLabel.type === ItemStatus.ERROR,
|
||||
'icon-information-outline': customInputLabel.type === ItemStatus.INFO,
|
||||
'icon-check': customInputLabel.type === ItemStatus.SUCCESS,
|
||||
})}
|
||||
aria-hidden='true'
|
||||
/>)}
|
||||
<span>{customInputLabel.value}</span>
|
||||
<i
|
||||
className={classNames(`icon ${customInputLabel?.type || 'error'}`, {
|
||||
'icon-alert-outline': (customInputLabel?.type || 'error') === ItemStatus.WARNING,
|
||||
'icon-alert-circle-outline': (customInputLabel?.type || 'error') === ItemStatus.ERROR,
|
||||
'icon-information-outline': (customInputLabel?.type || 'error') === ItemStatus.INFO,
|
||||
'icon-check': (customInputLabel?.type || 'error') === ItemStatus.SUCCESS,
|
||||
})}
|
||||
/>
|
||||
<span>{customInputLabel?.value || derivedErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.mm-save-changes-panel {
|
||||
.SaveChangesPanel {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
@@ -56,6 +56,7 @@
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: 10px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
}
|
||||
|
||||
@@ -124,5 +125,10 @@
|
||||
background-color: var(--button-color);
|
||||
color: var(--denim-button-bg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--center-channel-bg);
|
||||
opacity: 0.32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,31 +11,49 @@ import './save_changes_panel.scss';
|
||||
|
||||
export type SaveChangesPanelState = 'editing' | 'saved' | 'error' | undefined;
|
||||
|
||||
const CLOSE_TIMEOUT = 1200;
|
||||
|
||||
type Props = {
|
||||
handleSubmit: () => void;
|
||||
handleCancel: () => void;
|
||||
handleClose: () => void;
|
||||
tabChangeError?: boolean;
|
||||
state: SaveChangesPanelState;
|
||||
customErrorMessage?: string;
|
||||
saveButtonText?: React.ReactNode;
|
||||
cancelButtonText?: React.ReactNode;
|
||||
}
|
||||
function SaveChangesPanel({handleSubmit, handleCancel, handleClose, tabChangeError = false, state = 'editing'}: Props) {
|
||||
const panelClassName = classNames('mm-save-changes-panel', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
|
||||
const messageClassName = classNames('mm-save-changes-panel__message', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
|
||||
const cancelButtonClassName = classNames('mm-save-changes-panel__cancel-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
|
||||
const saveButtonClassName = classNames('mm-save-changes-panel__save-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
|
||||
function SaveChangesPanel({
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
handleClose,
|
||||
tabChangeError = false,
|
||||
state = 'editing',
|
||||
customErrorMessage,
|
||||
saveButtonText,
|
||||
cancelButtonText,
|
||||
}: Props) {
|
||||
const panelClassName = classNames('SaveChangesPanel', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
|
||||
const messageClassName = classNames('SaveChangesPanel__message', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
|
||||
const cancelButtonClassName = classNames('SaveChangesPanel__cancel-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
|
||||
const saveButtonClassName = classNames('SaveChangesPanel__save-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
if (state === 'saved') {
|
||||
timeoutId = setTimeout(() => {
|
||||
handleClose();
|
||||
}, 1200);
|
||||
}, CLOSE_TIMEOUT);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [handleClose, state]);
|
||||
|
||||
const generateMessage = () => {
|
||||
if (customErrorMessage && (tabChangeError || state === 'error')) {
|
||||
return customErrorMessage;
|
||||
}
|
||||
|
||||
if (tabChangeError || state === 'editing') {
|
||||
return (
|
||||
<FormattedMessage
|
||||
@@ -65,7 +83,7 @@ function SaveChangesPanel({handleSubmit, handleCancel, handleClose, tabChangeErr
|
||||
const generateControlButtons = () => {
|
||||
if (state === 'saved') {
|
||||
return (
|
||||
<div className='mm-save-changes-panel__btn-ctr'>
|
||||
<div className='SaveChangesPanel__btn-ctr'>
|
||||
<button
|
||||
id='panelCloseButton'
|
||||
data-testid='panelCloseButton'
|
||||
@@ -81,33 +99,34 @@ function SaveChangesPanel({handleSubmit, handleCancel, handleClose, tabChangeErr
|
||||
);
|
||||
}
|
||||
|
||||
const saveButtonDisabled = tabChangeError || state === 'error';
|
||||
|
||||
return (
|
||||
<div className='mm-save-changes-panel__btn-ctr'>
|
||||
<div className='SaveChangesPanel__btn-ctr'>
|
||||
<button
|
||||
data-testid='mm-save-changes-panel__cancel-btn'
|
||||
data-testid='SaveChangesPanel__cancel-btn'
|
||||
className={cancelButtonClassName}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='saveChangesPanel.cancel'
|
||||
defaultMessage='Undo'
|
||||
/>
|
||||
{cancelButtonText || (
|
||||
<FormattedMessage
|
||||
id='saveChangesPanel.cancel'
|
||||
defaultMessage='Undo'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
data-testid='mm-save-changes-panel__save-btn'
|
||||
data-testid='SaveChangesPanel__save-btn'
|
||||
className={saveButtonClassName}
|
||||
onClick={handleSubmit}
|
||||
disabled={saveButtonDisabled}
|
||||
>
|
||||
{state === 'error' ?
|
||||
<FormattedMessage
|
||||
id='saveChangesPanel.tryAgain'
|
||||
defaultMessage='Try again'
|
||||
/> :
|
||||
{saveButtonText || (
|
||||
<FormattedMessage
|
||||
id='saveChangesPanel.save'
|
||||
defaultMessage='Save'
|
||||
/>
|
||||
}
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2936,6 +2936,8 @@
|
||||
"adminConsole.list.table.rowsCount.50": "50",
|
||||
"adminConsole.list.table.rowsCount.show(rowsPerPage)": "rows per page",
|
||||
"advanced_text_editor.remote_user_hour": "The time for {user} is {time}",
|
||||
"advanced_textbox.max_length_error": "Text exceeds the maximum character limit of {maxLength} characters.",
|
||||
"advanced_textbox.min_length_error": "Text must be at least {minLength} characters.",
|
||||
"air_gapped_contact_sales_modal.body": "Please access the link below to contact sales.",
|
||||
"air_gapped_contact_sales_modal.title": "Looks like you do not have access to the internet",
|
||||
"air_gapped_modal.close": "Close",
|
||||
@@ -3225,7 +3227,6 @@
|
||||
"center_panel.input.cannot_load_component": "Something went wrong while loading the component. Please wait a moment, or try reloading the app.",
|
||||
"center_panel.reloadPage": "Reload",
|
||||
"change_url.endWithLetter": "URLs must end with a lowercase letter or number.",
|
||||
"change_url.helpText": "You can use lowercase letters, numbers, dashes, and underscores.",
|
||||
"change_url.invalidDirectMessage": "User IDs are not allowed in channel URLs.",
|
||||
"change_url.invalidUrl": "Invalid URL",
|
||||
"change_url.longer": "URLs must have at least 1 character.",
|
||||
@@ -3234,6 +3235,11 @@
|
||||
"change_url.startAndEndWithLetter": "URLs must start and end with a lowercase letter or number.",
|
||||
"change_url.startWithLetter": "URLs must start with a lowercase letter or number.",
|
||||
"channel_banner.aria_label": "Channel banner text",
|
||||
"channel_banner.banner_color.label": "Banner color",
|
||||
"channel_banner.banner_text.label": "Banner text",
|
||||
"channel_banner.banner_text.placeholder": "Channel banner text",
|
||||
"channel_banner.label.name": "Channel Banner",
|
||||
"channel_banner.label.subtext": "When enabled, a customized banner will display at the top of the channel.",
|
||||
"channel_bookmarks.addBookmark": "Add a bookmark",
|
||||
"channel_bookmarks.addBookmarkLimitReached": "Cannot add more than {limit} bookmarks",
|
||||
"channel_bookmarks.addLink": "Add a link",
|
||||
@@ -3262,11 +3268,11 @@
|
||||
"channel_bookmarks.editBookmarkLabel": "Bookmark menu",
|
||||
"channel_bookmarks.open": "Open",
|
||||
"channel_groups": "{channel} Groups",
|
||||
"channel_header.channel_settings": "Channel Settings",
|
||||
"channel_header.channelFiles": "Channel files",
|
||||
"channel_header.channelHasGuests": "Channel has guests",
|
||||
"channel_header.channelMembers": "Members",
|
||||
"channel_header.closeChannelInfo": "Close Info",
|
||||
"channel_header.convert": "Convert to Private Channel",
|
||||
"channel_header.delete": "Archive Channel",
|
||||
"channel_header.directchannel": "{displayName} (you) Channel Menu",
|
||||
"channel_header.directchannel.you": "{displayname} (you) ",
|
||||
@@ -3283,11 +3289,8 @@
|
||||
"channel_header.otherchannel": "{displayName} Channel Menu",
|
||||
"channel_header.pinnedPosts": "Pinned messages",
|
||||
"channel_header.recentMentions": "Recent mentions",
|
||||
"channel_header.rename": "Rename Channel",
|
||||
"channel_header.search": "Search",
|
||||
"channel_header.setConversationHeader": "Edit Header",
|
||||
"channel_header.setHeader": "Edit Channel Header",
|
||||
"channel_header.setPurpose": "Edit Channel Purpose",
|
||||
"channel_header.settings": "Settings",
|
||||
"channel_header.unarchive": "Unarchive Channel",
|
||||
"channel_header.unmute": "Unmute Channel",
|
||||
@@ -3388,7 +3391,7 @@
|
||||
"channel_modal.handleTooShort": "Channel URL must be 1 or more lowercase alphanumeric characters",
|
||||
"channel_modal.modalTitle": "Create a new channel",
|
||||
"channel_modal.name.label": "Channel name",
|
||||
"channel_modal.name.longer": "Channel names must have at least 1 character.",
|
||||
"channel_modal.name.longer": "Channel names must have at least 2 character.",
|
||||
"channel_modal.name.placeholder": "Enter a name for your new channel",
|
||||
"channel_modal.name.shorter": "Channel names must have maximum 64 characters.",
|
||||
"channel_modal.purpose.info": "This will be displayed when browsing for channels.",
|
||||
@@ -3424,6 +3427,30 @@
|
||||
"channel_notifications.resetToDefault": "Reset to default",
|
||||
"channel_notifications.ThreadsReplyTitle": "Thread reply notifications",
|
||||
"channel_select.placeholder": "--- Select a channel ---",
|
||||
"channel_settings_modal.header.placeholder": "Enter a header for this channel",
|
||||
"channel_settings_modal.name.placeholder": "Enter a name for your channel",
|
||||
"channel_settings_modal.purpose.placeholder": "Enter a purpose for this channel (optional)",
|
||||
"channel_settings.archive.button": "Archive this channel",
|
||||
"channel_settings.archive.warning": "Archiving a channel removes it from the user interface, but doesn't permanently delete the channel. New messages can't be posted to archived channels.",
|
||||
"channel_settings.error_banner_color_required": "Banner color is required",
|
||||
"channel_settings.error_banner_text_required": "Banner text is required",
|
||||
"channel_settings.error_display_name_required": "Channel name is required",
|
||||
"channel_settings.error_purpose_length": "The text entered exceeds the character limit. The channel purpose is limited to {maxLength} characters.",
|
||||
"channel_settings.header.label": "Channel Header",
|
||||
"channel_settings.label.name": "Channel Name",
|
||||
"channel_settings.modal.archiveTitle": "Archive Channel?",
|
||||
"channel_settings.modal.confirmArchive": "Confirm",
|
||||
"channel_settings.modal.title": "Channel Settings",
|
||||
"channel_settings.purpose.description": "Describe how this channel should be used.",
|
||||
"channel_settings.purpose.header": "This is the text that will appear in the header of the channel beside the channel name. You can use markdown to include links by typing [Link Title](http://example.com).",
|
||||
"channel_settings.purpose.label": "Channel Purpose",
|
||||
"channel_settings.save_changes_panel.banner_text.required_error": "Channel banner text cannot be empty when enabled",
|
||||
"channel_settings.save_changes_panel.reset": "Reset",
|
||||
"channel_settings.save_changes_panel.standard_error": "There are errors in the form above",
|
||||
"channel_settings.tab.archive": "Archive Channel",
|
||||
"channel_settings.tab.configuration": "Configuration",
|
||||
"channel_settings.tab.info": "Info",
|
||||
"channel_settings.unknown_error": "Something went wrong.",
|
||||
"channel_switch_modal.deactivated": "Deactivated",
|
||||
"channel_toggle_button.private": "Private",
|
||||
"channel_toggle_button.public": "Public",
|
||||
@@ -3439,7 +3466,6 @@
|
||||
"channelNotifications.mobileNotification.newMessages": "All new messages {optionalDefault}",
|
||||
"channelNotifications.mobileNotification.nothing": "Nothing {optionalDefault}",
|
||||
"channelSelectorModal.title": "Add Channels to <b>Channel Selection</b> List",
|
||||
"channelSettings": "Channel Settings",
|
||||
"channelView.archivedChannel": "You are viewing an <b>archived channel</b>. New messages cannot be posted.",
|
||||
"channelView.archivedChannelWithDeactivatedUser": "You are viewing an archived channel with a <b>deactivated user</b>. New messages cannot be posted.",
|
||||
"channelView.login.successfull": "Login Successful",
|
||||
@@ -4008,6 +4034,7 @@
|
||||
"generic_icons.reload": "Reload Icon",
|
||||
"generic_icons.reply": "Reply Icon",
|
||||
"generic_icons.search": "Search Icon",
|
||||
"generic_icons.settings": "Settings Icon",
|
||||
"generic_icons.success": "Success Icon",
|
||||
"generic_icons.upgradeBadge": "Upgrade badge",
|
||||
"generic_icons.user_groups": "User Groups Icon",
|
||||
@@ -4922,15 +4949,6 @@
|
||||
"removed_channel.someone": "Someone",
|
||||
"rename_category_modal.rename": "Rename",
|
||||
"rename_category_modal.renameCategory": "Rename Category",
|
||||
"rename_channel.cancel": "Cancel",
|
||||
"rename_channel.defaultError": " - Cannot be changed for the default channel",
|
||||
"rename_channel.displayName": "Display Name",
|
||||
"rename_channel.displayNameHolder": "Enter display name",
|
||||
"rename_channel.maxLength": "This field must be less than {maxLength, number} characters",
|
||||
"rename_channel.minLength": "Display name must have at least {minLength, number} characters.",
|
||||
"rename_channel.save": "Save",
|
||||
"rename_channel.title": "Rename Channel",
|
||||
"rename_channel.url": "URL",
|
||||
"restricted_indicator.tooltip.mesage": "During your trial you are able to use this feature.",
|
||||
"restricted_indicator.tooltip.message.blocked": "This is a paid feature, available with a free {trialLength}-day trial",
|
||||
"restricted_indicator.tooltip.title": "{minimumPlanRequiredForFeature} feature",
|
||||
@@ -4962,7 +4980,6 @@
|
||||
"saveChangesPanel.message": "You have unsaved changes",
|
||||
"saveChangesPanel.save": "Save",
|
||||
"saveChangesPanel.saved": "Settings saved",
|
||||
"saveChangesPanel.tryAgain": "Try again",
|
||||
"schedule_post.custom_time_modal.cancel_button_text": "Cancel",
|
||||
"schedule_post.custom_time_modal.confirm_button_text": "Schedule",
|
||||
"schedule_post.custom_time_modal.dm_user_time": "{dmUserTime} for {dmUserName}",
|
||||
@@ -6090,6 +6107,8 @@
|
||||
"webapp.mattermost.feature.unlimited_messages": "Unlimited Messages",
|
||||
"webapp.mattermost.feature.upgrade_downgraded_workspace": "Revert the workspace to a paid plan",
|
||||
"widget.input.clear": "Clear",
|
||||
"widget.input.max_length": "Must be no more than {limit} characters",
|
||||
"widget.input.min_length": "Must be at least {minLength} characters",
|
||||
"widget.input.required": "This field is required",
|
||||
"widget.passwordInput.createPassword": "Choose a Password",
|
||||
"widget.passwordInput.password": "Password",
|
||||
|
||||
@@ -8,12 +8,20 @@ import {General} from 'mattermost-redux/constants';
|
||||
import {getChannel, getChannelBanner} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
export const selectShowChannelBanner = (state: GlobalState, channelId: string): boolean => {
|
||||
export const selectChannelBannerEnabled = (state: GlobalState): boolean => {
|
||||
const license = getLicense(state);
|
||||
return license?.SkuShortName === General.SKUPremium;
|
||||
};
|
||||
|
||||
export const selectShowChannelBanner = (state: GlobalState, channelId: string): boolean => {
|
||||
const enabled = selectChannelBannerEnabled(state);
|
||||
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isPremiumLicense = license?.SkuShortName === General.SKUPremium;
|
||||
const channelBannerInfo = getChannelBanner(state, channelId);
|
||||
const channel = getChannel(state, channelId);
|
||||
const isValidChannelType = Boolean(channel && (channel.type === General.OPEN_CHANNEL || channel.type === General.PRIVATE_CHANNEL));
|
||||
return isPremiumLicense && isValidChannelType && channelBannerEnabled(channelBannerInfo);
|
||||
return isValidChannelType && channelBannerEnabled(channelBannerInfo);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ describe('Reducers.RHS', () => {
|
||||
shouldShowPreviewOnCreateComment: false,
|
||||
shouldShowPreviewOnCreatePost: false,
|
||||
shouldShowPreviewOnEditChannelHeaderModal: false,
|
||||
shouldShowPreviewOnChannelSettingsHeaderModal: false,
|
||||
shouldShowPreviewOnChannelSettingsPurposeModal: false,
|
||||
};
|
||||
|
||||
test('Initial state', () => {
|
||||
@@ -65,4 +67,34 @@ describe('Reducers.RHS', () => {
|
||||
shouldShowPreviewOnEditChannelHeaderModal: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('update show preview value on channel settings header modal', () => {
|
||||
const nextState = textboxReducer(
|
||||
{},
|
||||
{
|
||||
type: ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_HEADER_MODAL,
|
||||
showPreview: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(nextState).toEqual({
|
||||
...initialState,
|
||||
shouldShowPreviewOnChannelSettingsHeaderModal: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('update show preview value on channel settings purpose modal', () => {
|
||||
const nextState = textboxReducer(
|
||||
{},
|
||||
{
|
||||
type: ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_PURPOSE_MODAL,
|
||||
showPreview: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(nextState).toEqual({
|
||||
...initialState,
|
||||
shouldShowPreviewOnChannelSettingsPurposeModal: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,8 +45,34 @@ function shouldShowPreviewOnEditChannelHeaderModal(state = false, action: MMActi
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowPreviewOnChannelSettingsHeaderModal(state = false, action: MMAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_HEADER_MODAL:
|
||||
return action.showPreview;
|
||||
|
||||
case UserTypes.LOGOUT_SUCCESS:
|
||||
return false;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowPreviewOnChannelSettingsPurposeModal(state = false, action: MMAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_PURPOSE_MODAL:
|
||||
return action.showPreview;
|
||||
|
||||
case UserTypes.LOGOUT_SUCCESS:
|
||||
return false;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
shouldShowPreviewOnCreateComment,
|
||||
shouldShowPreviewOnCreatePost,
|
||||
shouldShowPreviewOnEditChannelHeaderModal,
|
||||
shouldShowPreviewOnChannelSettingsHeaderModal,
|
||||
shouldShowPreviewOnChannelSettingsPurposeModal,
|
||||
});
|
||||
|
||||
@@ -304,7 +304,6 @@ body.app__body #root {
|
||||
#channel_view.channel-view {
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-l);
|
||||
border-left: var(--border-light);
|
||||
background: var(--center-channel-bg);
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,16 @@
|
||||
|
||||
input {
|
||||
height: 36px;
|
||||
|
||||
&:not(:focus) {
|
||||
border-right: none !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
border-top-right-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.color-icon {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
@@ -19,8 +26,8 @@
|
||||
|
||||
.color-pad {
|
||||
padding: 5px;
|
||||
border-color: rgba(var(--center-channel-color-rgb), 0.32) !important;
|
||||
border-radius: 0 2px 2px 0;
|
||||
border: var(--border-default);
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: functions.v(center-channel-bg) !important;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,11 @@ export function showPreviewOnCreatePost(state: GlobalState) {
|
||||
export function showPreviewOnEditChannelHeaderModal(state: GlobalState) {
|
||||
return state.views.textbox.shouldShowPreviewOnEditChannelHeaderModal;
|
||||
}
|
||||
|
||||
export function showPreviewOnChannelSettingsHeaderModal(state: GlobalState) {
|
||||
return state.views.textbox.shouldShowPreviewOnChannelSettingsHeaderModal;
|
||||
}
|
||||
|
||||
export function showPreviewOnChannelSettingsPurposeModal(state: GlobalState) {
|
||||
return state.views.textbox.shouldShowPreviewOnChannelSettingsPurposeModal;
|
||||
}
|
||||
|
||||
@@ -217,5 +217,7 @@ export type ViewsState = {
|
||||
shouldShowPreviewOnCreatePost: boolean;
|
||||
shouldShowPreviewOnEditChannelHeaderModal: boolean;
|
||||
shouldShowPreviewOnEditPostModal: boolean;
|
||||
shouldShowPreviewOnChannelSettingsHeaderModal: boolean;
|
||||
shouldShowPreviewOnChannelSettingsPurposeModal: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -265,6 +265,8 @@ export const ActionTypes = keyMirror({
|
||||
SET_SHOW_PREVIEW_ON_CREATE_COMMENT: null,
|
||||
SET_SHOW_PREVIEW_ON_CREATE_POST: null,
|
||||
SET_SHOW_PREVIEW_ON_EDIT_CHANNEL_HEADER_MODAL: null,
|
||||
SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_HEADER_MODAL: null,
|
||||
SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_PURPOSE_MODAL: null,
|
||||
|
||||
TOGGLE_RHS_MENU: null,
|
||||
OPEN_RHS_MENU: null,
|
||||
@@ -340,6 +342,7 @@ export const ModalIdentifiers = {
|
||||
CHANNEL_NOTIFICATIONS: 'channel_notifications',
|
||||
CHANNEL_INVITE: 'channel_invite',
|
||||
CHANNEL_MEMBERS: 'channel_members',
|
||||
CHANNEL_SETTINGS: 'channel_settings',
|
||||
TEAM_MEMBERS: 'team_members',
|
||||
ADD_USER_TO_CHANNEL: 'add_user_to_channel',
|
||||
ADD_USER_TO_ROLE: 'add_user_to_role',
|
||||
|
||||
Ссылка в новой задаче
Block a user